Synced from monorepo
Synced from monorepo Changes: - Temporarily disable session share link creation in the TUI - Do not approve plan on empty Enter from the revise prompt - Expose chat product Skills via ACP available_commands_update - Return immediately from a blocking wait on an already-completed ACP task - Split headless pager module for clearer structure - Stop git worktree prune from removing user registrations on resume - Use compaction sampler tokenizer for item token counts - Opt-in extra root CAs via GROK_EXTRA_CA_BUNDLE - Cancel all session subagents when the user stops - Let the session persistence actor exit when its session ends - Make fullscreen terminal resize much cheaper on long sessions - Report honestly from kill_task when an ACP task does not exist - Hide /usage for external-auth deployments - Forward the history-load trailer’s computer_reason to the client - Remove ineffective no-op tool reminder - Declare slash-command screen-mode support in one place - Keep settings enum picker on the committed value until Enter - Reap a PTY’s full process tree - Stream tool calls from headless mode over ACP - Bridge gateway task lifecycle to ACP for chat session background tasks - Don’t warn about truncated history on a suppressed replay - Fit full-replace summarizer input and recover on context-length errors - Stop dropping agents over an unrecognized frontmatter color - Add /undo as a slash alias for /rewind - Harden sleep/wake token-refresh paths against forced re-login - Add session/list ACP method - Give each sampling backend its own conversion module - Treat an unenrolled child process as a lint error - Suppress the cancelled marker on send-now wake turns - Stop tearing down Roslyn on every edit, and read C# diagnostics Source-Revision: 2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39
This commit is contained in:
parent
500129c714
commit
dd04f397b1
367 changed files with 29489 additions and 10051 deletions
|
|
@ -365,13 +365,11 @@ pub enum PaletteCommand {
|
|||
OpenAgentsModal,
|
||||
}
|
||||
/// Build the default set of palette entries with section grouping.
|
||||
///
|
||||
/// `sharing_enabled` controls whether `/share` is included. `screen_mode`
|
||||
/// exposes the draft-preserving external-editor row only in minimal mode.
|
||||
pub(crate) fn default_palette_entries(
|
||||
sharing_enabled: bool,
|
||||
screen_mode: crate::app::ScreenMode,
|
||||
slash: &crate::slash::SlashController,
|
||||
) -> Vec<PaletteEntry> {
|
||||
let screen_mode = slash.screen_mode();
|
||||
let mut entries = vec![
|
||||
// ── Session ──
|
||||
PaletteEntry {
|
||||
|
|
@ -574,6 +572,15 @@ pub(crate) fn default_palette_entries(
|
|||
{
|
||||
return false;
|
||||
}
|
||||
if let PaletteCommand::SlashCommand(text) = &entry.command
|
||||
&& let Some(invocation) = crate::slash::parse_invocation(text.trim())
|
||||
&& !slash
|
||||
.registry()
|
||||
.mode_support(invocation.token)
|
||||
.supports(screen_mode)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
screen_mode.is_minimal() || !matches!(entry.command, PaletteCommand::EditPromptExternal)
|
||||
});
|
||||
entries
|
||||
|
|
@ -583,9 +590,9 @@ pub(crate) fn default_palette_entries(
|
|||
pub(crate) fn filter_palette_entries(
|
||||
query: &str,
|
||||
sharing_enabled: bool,
|
||||
screen_mode: crate::app::ScreenMode,
|
||||
slash: &crate::slash::SlashController,
|
||||
) -> Vec<PaletteEntry> {
|
||||
let all = default_palette_entries(sharing_enabled, screen_mode);
|
||||
let all = default_palette_entries(sharing_enabled, slash);
|
||||
let query_lower = query.to_lowercase();
|
||||
if query_lower.is_empty() {
|
||||
return all;
|
||||
|
|
@ -1305,9 +1312,15 @@ mod palette_sharing_tests {
|
|||
.iter()
|
||||
.any(|e| matches!(&e.command, PaletteCommand::SlashCommand(s) if s.trim() == "/share"))
|
||||
}
|
||||
fn slash(mode: crate::app::ScreenMode) -> crate::slash::SlashController {
|
||||
let mut controller =
|
||||
crate::slash::SlashController::with_builtins(std::path::PathBuf::from("."));
|
||||
controller.set_screen_mode(mode);
|
||||
controller
|
||||
}
|
||||
#[test]
|
||||
fn default_palette_includes_share_when_enabled() {
|
||||
let entries = default_palette_entries(true, crate::app::ScreenMode::Fullscreen);
|
||||
let entries = default_palette_entries(true, &slash(crate::app::ScreenMode::Fullscreen));
|
||||
assert!(
|
||||
has_share(&entries),
|
||||
"/share should be present when sharing_enabled=true"
|
||||
|
|
@ -1315,7 +1328,7 @@ mod palette_sharing_tests {
|
|||
}
|
||||
#[test]
|
||||
fn default_palette_includes_dashboard() {
|
||||
let entries = default_palette_entries(true, crate::app::ScreenMode::Fullscreen);
|
||||
let entries = default_palette_entries(true, &slash(crate::app::ScreenMode::Fullscreen));
|
||||
let has_dashboard = entries.iter().any(
|
||||
|e| matches!(&e.command, PaletteCommand::SlashCommand(s) if s.trim() == "/dashboard"),
|
||||
);
|
||||
|
|
@ -1329,15 +1342,57 @@ mod palette_sharing_tests {
|
|||
"palette entry must use the 'Agent Dashboard' label"
|
||||
);
|
||||
}
|
||||
fn slash_rows(mode: crate::app::ScreenMode) -> Vec<String> {
|
||||
default_palette_entries(true, &slash(mode))
|
||||
.into_iter()
|
||||
.filter_map(|entry| match entry.command {
|
||||
PaletteCommand::SlashCommand(text) => Some(text.trim().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[test]
|
||||
fn palette_drops_slash_rows_the_mode_cannot_run() {
|
||||
let minimal = slash_rows(crate::app::ScreenMode::Minimal);
|
||||
for gated in ["/theme", "/dashboard", "/tutorial"] {
|
||||
assert!(!minimal.contains(&gated.to_string()), "{gated} in minimal");
|
||||
}
|
||||
assert!(
|
||||
minimal.contains(&"/compact".to_string()),
|
||||
"mode-agnostic rows stay: {minimal:?}"
|
||||
);
|
||||
let fullscreen = slash_rows(crate::app::ScreenMode::Fullscreen);
|
||||
for offered in ["/theme", "/dashboard", "/tutorial"] {
|
||||
assert!(
|
||||
fullscreen.contains(&offered.to_string()),
|
||||
"{offered} missing in fullscreen"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn every_palette_slash_row_resolves_to_a_registered_command() {
|
||||
let builtins = crate::slash::commands::builtin_commands();
|
||||
for row in slash_rows(crate::app::ScreenMode::Fullscreen) {
|
||||
let invocation = crate::slash::parse_invocation(&row)
|
||||
.unwrap_or_else(|| panic!("palette row {row:?} is not a slash invocation"));
|
||||
assert!(
|
||||
builtins
|
||||
.iter()
|
||||
.any(|command| command.name() == invocation.token
|
||||
|| command.aliases().contains(&invocation.token)),
|
||||
"palette row {row:?} names no builtin command"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn edit_prompt_palette_entry_is_minimal_only() {
|
||||
let minimal = default_palette_entries(true, crate::app::ScreenMode::Minimal);
|
||||
let minimal = default_palette_entries(true, &slash(crate::app::ScreenMode::Minimal));
|
||||
assert!(
|
||||
minimal
|
||||
.iter()
|
||||
.any(|entry| matches!(entry.command, PaletteCommand::EditPromptExternal))
|
||||
);
|
||||
let fullscreen = default_palette_entries(true, crate::app::ScreenMode::Fullscreen);
|
||||
let fullscreen = default_palette_entries(true, &slash(crate::app::ScreenMode::Fullscreen));
|
||||
assert!(
|
||||
!fullscreen
|
||||
.iter()
|
||||
|
|
@ -1346,7 +1401,7 @@ mod palette_sharing_tests {
|
|||
}
|
||||
#[test]
|
||||
fn default_palette_omits_share_when_disabled() {
|
||||
let entries = default_palette_entries(false, crate::app::ScreenMode::Fullscreen);
|
||||
let entries = default_palette_entries(false, &slash(crate::app::ScreenMode::Fullscreen));
|
||||
assert!(
|
||||
!has_share(&entries),
|
||||
"/share must not appear in palette when sharing_enabled=false"
|
||||
|
|
@ -1354,12 +1409,13 @@ mod palette_sharing_tests {
|
|||
}
|
||||
#[test]
|
||||
fn filter_palette_omits_share_when_disabled() {
|
||||
let entries = filter_palette_entries("", false, crate::app::ScreenMode::Fullscreen);
|
||||
let entries = filter_palette_entries("", false, &slash(crate::app::ScreenMode::Fullscreen));
|
||||
assert!(
|
||||
!has_share(&entries),
|
||||
"/share must not appear in unfiltered palette when sharing_enabled=false"
|
||||
);
|
||||
let entries = filter_palette_entries("share", false, crate::app::ScreenMode::Fullscreen);
|
||||
let entries =
|
||||
filter_palette_entries("share", false, &slash(crate::app::ScreenMode::Fullscreen));
|
||||
assert!(
|
||||
!has_share(&entries),
|
||||
"/share must not appear when filtering for 'share' with sharing_enabled=false"
|
||||
|
|
@ -1367,7 +1423,8 @@ mod palette_sharing_tests {
|
|||
}
|
||||
#[test]
|
||||
fn filter_palette_includes_share_when_enabled_and_matched() {
|
||||
let entries = filter_palette_entries("share", true, crate::app::ScreenMode::Fullscreen);
|
||||
let entries =
|
||||
filter_palette_entries("share", true, &slash(crate::app::ScreenMode::Fullscreen));
|
||||
assert!(
|
||||
has_share(&entries),
|
||||
"/share should match a 'share' query when sharing_enabled=true"
|
||||
|
|
@ -1376,7 +1433,7 @@ mod palette_sharing_tests {
|
|||
#[test]
|
||||
fn palette_tools_section_routes_each_tab_to_itself() {
|
||||
use crate::views::extensions_modal::ExtensionsTab;
|
||||
let entries = default_palette_entries(true, crate::app::ScreenMode::Fullscreen);
|
||||
let entries = default_palette_entries(true, &slash(crate::app::ScreenMode::Fullscreen));
|
||||
for (label, expected) in [
|
||||
("Hooks", ExtensionsTab::Hooks),
|
||||
("Plugins", ExtensionsTab::Plugins),
|
||||
|
|
|
|||
|
|
@ -142,41 +142,48 @@ fn handle_picking_enum(state: &mut SettingsModalState, key: &KeyEvent) -> Settin
|
|||
// `action_for_string` already knows how to resolve via
|
||||
// `snapshot.resolve_model_name` AND treats the empty
|
||||
// canonical as a `Clear*` sentinel.
|
||||
let close = std::mem::take(&mut state.close_on_picker_exit);
|
||||
if !close {
|
||||
state.transition_to_browse();
|
||||
}
|
||||
let kind_is_dynamic = matches!(
|
||||
state.registry.find(setting_key).map(|m| &m.kind),
|
||||
Some(SettingKind::DynamicEnum { .. })
|
||||
);
|
||||
state.transition_to_browse();
|
||||
if kind_is_dynamic {
|
||||
let Some(canonical) = picker_choice_at_owned(state, setting_key, choices_idx)
|
||||
else {
|
||||
return SettingsKeyOutcome::Changed;
|
||||
};
|
||||
if let Some(action) =
|
||||
let commit = if kind_is_dynamic {
|
||||
picker_choice_at_owned(state, setting_key, choices_idx).and_then(|canonical| {
|
||||
action_for_string(setting_key, canonical, &state.pager_snapshot)
|
||||
{
|
||||
return SettingsKeyOutcome::Action(action);
|
||||
}
|
||||
return SettingsKeyOutcome::Changed;
|
||||
}
|
||||
let Some(current_canonical) = picker_choice_at(state, setting_key, choices_idx) else {
|
||||
return SettingsKeyOutcome::Changed;
|
||||
})
|
||||
} else {
|
||||
picker_choice_at(state, setting_key, choices_idx)
|
||||
.and_then(|c| action_for_enum_commit(setting_key, c))
|
||||
};
|
||||
if let Some(action) = action_for_enum_commit(setting_key, current_canonical) {
|
||||
return SettingsKeyOutcome::Action(action);
|
||||
match (close, commit) {
|
||||
(true, Some(action)) => SettingsKeyOutcome::ActionThenClose(action),
|
||||
(true, None) => SettingsKeyOutcome::Close,
|
||||
(false, Some(action)) => SettingsKeyOutcome::Action(action),
|
||||
(false, None) => SettingsKeyOutcome::Changed,
|
||||
}
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
// Revert preview and return to Browse. Non-preview Enums
|
||||
// skip the revert (no live visual was applied).
|
||||
state.transition_to_browse();
|
||||
let close = std::mem::take(&mut state.close_on_picker_exit);
|
||||
if !close {
|
||||
state.transition_to_browse();
|
||||
}
|
||||
if let SettingValue::Enum(orig) = &original_value
|
||||
&& let Some(action) = action_for_enum(setting_key, orig)
|
||||
{
|
||||
return SettingsKeyOutcome::Action(action);
|
||||
return if close {
|
||||
SettingsKeyOutcome::ActionThenClose(action)
|
||||
} else {
|
||||
SettingsKeyOutcome::Action(action)
|
||||
};
|
||||
}
|
||||
if close {
|
||||
SettingsKeyOutcome::Close
|
||||
} else {
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
// `d` reset: close picker, revert preview if applicable,
|
||||
// then open the reset-confirm overlay. Consent choosers opt out of
|
||||
|
|
@ -867,22 +874,16 @@ pub fn handle_settings_mouse(
|
|||
column: u16,
|
||||
row: u16,
|
||||
) -> SettingsKeyOutcome {
|
||||
// Clicking anywhere on the chrome
|
||||
// breadcrumb (the full `Settings › <label>` title) in a
|
||||
// sub-pane mode collapses back to Browse. Dispatched FIRST
|
||||
// so it wins over the picker / editor mouse handlers (which
|
||||
// would otherwise ignore the click as out-of-content). The
|
||||
// synthetic Esc is routed through the active sub-pane handler
|
||||
// so the same revert-preview / mode-transition logic runs as
|
||||
// for keyboard Esc — `handle_picking_enum` reverts the
|
||||
// preview action, and `handle_editing_value` just transitions
|
||||
// back.
|
||||
// Breadcrumb is hierarchical "up": always return to Browse (never
|
||||
// dismiss via `close_on_picker_exit`). Reuse sub-pane Esc handlers for
|
||||
// preview revert after clearing the deep-link flag.
|
||||
if matches!(
|
||||
kind,
|
||||
MouseEventKind::Down(crossterm::event::MouseButton::Left)
|
||||
) && let Some(rect) = state.settings_breadcrumb_rect
|
||||
&& rect_contains(rect, column, row)
|
||||
{
|
||||
state.close_on_picker_exit = false;
|
||||
let synthetic = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||||
match state.state.mode_kind() {
|
||||
SettingsModeKind::PickingEnum => {
|
||||
|
|
|
|||
|
|
@ -869,9 +869,9 @@ fn wrapped_description_height(
|
|||
(wrapped.len() as u16).min(cap)
|
||||
}
|
||||
|
||||
// Picker prefix constants (hoisted to avoid per-frame allocation).
|
||||
const PICKER_PREFIX_FOCUSED: &str = " \u{25CF} ";
|
||||
const PICKER_PREFIX_UNFOCUSED: &str = " \u{25CB} ";
|
||||
// Picker prefix width templates (glyphs are drawn separately).
|
||||
const PICKER_PREFIX_SELECTED: &str = " \u{25CF} ";
|
||||
const PICKER_PREFIX_UNSELECTED: &str = " \u{25CB} ";
|
||||
|
||||
pub(super) const PICKER_PREFIX_W: u16 = 4;
|
||||
const PICKER_SEPARATOR: &str = " \u{00B7} ";
|
||||
|
|
@ -944,14 +944,14 @@ pub(super) fn render_picking_enum(
|
|||
theme: &Theme,
|
||||
) {
|
||||
debug_assert_eq!(
|
||||
PICKER_PREFIX_FOCUSED.width(),
|
||||
PICKER_PREFIX_SELECTED.width(),
|
||||
PICKER_PREFIX_W as usize,
|
||||
"PICKER_PREFIX_W drifted from PICKER_PREFIX_FOCUSED width",
|
||||
"PICKER_PREFIX_W drifted from PICKER_PREFIX_SELECTED width",
|
||||
);
|
||||
debug_assert_eq!(
|
||||
PICKER_PREFIX_UNFOCUSED.width(),
|
||||
PICKER_PREFIX_UNSELECTED.width(),
|
||||
PICKER_PREFIX_W as usize,
|
||||
"PICKER_PREFIX_W drifted from PICKER_PREFIX_UNFOCUSED width",
|
||||
"PICKER_PREFIX_W drifted from PICKER_PREFIX_UNSELECTED width",
|
||||
);
|
||||
debug_assert_eq!(
|
||||
PICKER_SEPARATOR.width(),
|
||||
|
|
@ -959,12 +959,20 @@ pub(super) fn render_picking_enum(
|
|||
"PICKER_SEPARATOR_W drifted from PICKER_SEPARATOR width",
|
||||
);
|
||||
|
||||
let (setting_key, choices_idx) = match &state.state.mode {
|
||||
let (setting_key, choices_idx, original_value) = match &state.state.mode {
|
||||
SettingsMode::PickingEnum {
|
||||
key, choices_idx, ..
|
||||
} => (*key, *choices_idx),
|
||||
key,
|
||||
choices_idx,
|
||||
original_value,
|
||||
..
|
||||
} => (*key, *choices_idx, original_value),
|
||||
_ => unreachable!("picker renderer requires PickingEnum state"),
|
||||
};
|
||||
let committed_canonical: Option<&str> = match original_value {
|
||||
SettingValue::Enum(s) => Some(s),
|
||||
SettingValue::String(s) => Some(s.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
let Some(meta) = state.registry.find(setting_key) else {
|
||||
return;
|
||||
};
|
||||
|
|
@ -1050,6 +1058,7 @@ pub(super) fn render_picking_enum(
|
|||
{
|
||||
let choice = &choices[choice_i];
|
||||
let is_focused = choice_i == choices_idx;
|
||||
let is_current = committed_canonical.is_some_and(|c| c == choice.canonical);
|
||||
|
||||
let is_hovered = !is_focused && state.hover_row == Some(choice_i);
|
||||
let bg = settings_list_row_bg(theme, is_focused, is_hovered);
|
||||
|
|
@ -1063,12 +1072,12 @@ pub(super) fn render_picking_enum(
|
|||
Style::default().fg(fg_primary).bg(bg)
|
||||
};
|
||||
let desc_style = Style::default().fg(fg_gray).bg(bg);
|
||||
let marker_style = if is_focused {
|
||||
let marker_style = if is_current {
|
||||
Style::default().fg(fg_accent).bg(bg)
|
||||
} else {
|
||||
Style::default().fg(fg_gray).bg(bg)
|
||||
};
|
||||
let marker = if is_focused {
|
||||
let marker = if is_current {
|
||||
crate::glyphs::filled_dot()
|
||||
} else {
|
||||
"\u{25CB}"
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ pub enum SettingsKeyOutcome {
|
|||
/// Used by `d`-reset-in-picker to revert preview before opening
|
||||
/// the reset-confirm overlay.
|
||||
ActionPair(Action, Action),
|
||||
/// Close the modal and dispatch `Action` (deep-link Esc revert or Enter commit).
|
||||
ActionThenClose(Action),
|
||||
/// Internal state mutation, no action.
|
||||
Changed,
|
||||
/// No-op.
|
||||
|
|
@ -210,6 +212,10 @@ pub struct SettingsModalState {
|
|||
/// `rows` in Browse, `picker_choice_rects` in PickingEnum,
|
||||
/// always `None` in EditingValue.
|
||||
pub hover_row: Option<usize>,
|
||||
/// When true, Esc/Enter from `PickingEnum` close the modal instead of
|
||||
/// returning to Browse. Set by deep-link open (`OpenSettingsFocus`
|
||||
/// / `/privacy`); cleared on leave from the picker.
|
||||
pub close_on_picker_exit: bool,
|
||||
}
|
||||
|
||||
impl SettingsModalState {
|
||||
|
|
@ -248,6 +254,7 @@ impl SettingsModalState {
|
|||
breadcrumb_hovered: false,
|
||||
expanded_keys: std::collections::HashSet::new(),
|
||||
hover_row: None,
|
||||
close_on_picker_exit: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -507,6 +514,7 @@ impl SettingsModalState {
|
|||
self.hover_row = None;
|
||||
self.settings_breadcrumb_rect = None;
|
||||
self.breadcrumb_hovered = false;
|
||||
self.close_on_picker_exit = false;
|
||||
}
|
||||
|
||||
pub fn focus_filter(&mut self) {
|
||||
|
|
|
|||
|
|
@ -1117,9 +1117,9 @@ fn mouse_moved_over_header_does_not_set_hover() {
|
|||
|
||||
/// `state.hover_row = Some(idx)` paints the hovered row's bg
|
||||
/// with the theme's `bg_hover` color. (Mirrors the existing
|
||||
/// `picker_highlights_current_choice` test's pattern: the
|
||||
/// `assert_eq` against `theme.bg_hover` survives both colored
|
||||
/// and quantize-to-Reset color levels.)
|
||||
/// `picker_separates_focus_highlight_from_committed_marker` test's
|
||||
/// pattern: the `assert_eq` against `theme.bg_hover` survives both
|
||||
/// colored and quantize-to-Reset color levels.)
|
||||
#[test]
|
||||
fn hover_row_renders_with_hover_style() {
|
||||
let mut s = make_state();
|
||||
|
|
@ -2561,6 +2561,123 @@ fn picker_esc_returns_to_browse_after_preview_nav() {
|
|||
);
|
||||
}
|
||||
|
||||
/// `/privacy` deep-link: focus + enter picker with `close_on_picker_exit`,
|
||||
/// then Esc closes the modal entirely (not Browse).
|
||||
#[test]
|
||||
fn deep_link_picker_esc_closes_modal() {
|
||||
let mut s = make_state();
|
||||
assert!(s.focus_key("coding_data_sharing"));
|
||||
assert!(s.try_enter_picking_enum());
|
||||
s.close_on_picker_exit = true;
|
||||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||||
|
||||
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
matches!(outcome, SettingsKeyOutcome::Close),
|
||||
"deep-link Esc must Close, got {outcome:?}"
|
||||
);
|
||||
assert!(
|
||||
!s.close_on_picker_exit,
|
||||
"flag must clear after Esc even when closing"
|
||||
);
|
||||
}
|
||||
|
||||
/// Settings → Privacy row → Enter into chooser: Esc returns to Browse.
|
||||
#[test]
|
||||
fn browse_enter_picker_esc_returns_to_browse() {
|
||||
let mut s = make_state();
|
||||
assert!(s.focus_key("coding_data_sharing"));
|
||||
assert!(s.try_enter_picking_enum());
|
||||
assert!(!s.close_on_picker_exit);
|
||||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||||
|
||||
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||||
"browse-path Esc must stay open (Changed), got {outcome:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(s.mode(), SettingsModalMode::Browse),
|
||||
"browse-path Esc must return to Browse, got {:?}",
|
||||
s.mode()
|
||||
);
|
||||
}
|
||||
|
||||
/// Deep-link Enter commits the choice and closes the modal (not Browse).
|
||||
#[test]
|
||||
fn deep_link_commit_closes_modal() {
|
||||
let mut s = make_state();
|
||||
assert!(s.focus_key("coding_data_sharing"));
|
||||
assert!(s.try_enter_picking_enum());
|
||||
s.close_on_picker_exit = true;
|
||||
|
||||
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match outcome {
|
||||
SettingsKeyOutcome::ActionThenClose(Action::SetCodingDataSharing { opted_in }) => {
|
||||
assert!(!opted_in, "default snapshot is opt-out");
|
||||
}
|
||||
other => panic!("expected ActionThenClose(SetCodingDataSharing), got {other:?}"),
|
||||
}
|
||||
assert!(!s.close_on_picker_exit);
|
||||
}
|
||||
|
||||
/// Browse-path Enter commits and returns to Browse (not Close).
|
||||
#[test]
|
||||
fn browse_path_enter_commit_returns_to_browse() {
|
||||
let mut s = make_state();
|
||||
assert!(s.focus_key("coding_data_sharing"));
|
||||
assert!(s.try_enter_picking_enum());
|
||||
assert!(!s.close_on_picker_exit);
|
||||
|
||||
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match outcome {
|
||||
SettingsKeyOutcome::Action(Action::SetCodingDataSharing { opted_in }) => {
|
||||
assert!(!opted_in, "default snapshot is opt-out");
|
||||
}
|
||||
other => panic!("expected Action(SetCodingDataSharing), got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
matches!(s.mode(), SettingsModalMode::Browse),
|
||||
"browse-path Enter must return to Browse, got {:?}",
|
||||
s.mode()
|
||||
);
|
||||
assert!(!s.close_on_picker_exit);
|
||||
}
|
||||
|
||||
/// Deep-link Enter on a preview enum commits via Set* and closes.
|
||||
#[test]
|
||||
fn deep_link_theme_commit_closes_with_set() {
|
||||
let mut s = make_state();
|
||||
s.transition_to_picking_enum("theme", 0, SettingValue::Enum("groknight"), true);
|
||||
s.close_on_picker_exit = true;
|
||||
|
||||
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match outcome {
|
||||
SettingsKeyOutcome::ActionThenClose(Action::SetTheme(name)) => {
|
||||
assert_eq!(name, "auto");
|
||||
}
|
||||
other => panic!("expected ActionThenClose(SetTheme), got {other:?}"),
|
||||
}
|
||||
assert!(!s.close_on_picker_exit);
|
||||
}
|
||||
|
||||
/// Deep-link Esc on a preview enum reverts the live preview and closes.
|
||||
#[test]
|
||||
fn deep_link_picker_esc_reverts_preview_and_closes() {
|
||||
let mut s = make_state();
|
||||
s.transition_to_picking_enum("theme", 0, SettingValue::Enum("groknight"), true);
|
||||
s.close_on_picker_exit = true;
|
||||
|
||||
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
match outcome {
|
||||
SettingsKeyOutcome::ActionThenClose(Action::PreviewTheme(name)) => {
|
||||
assert_eq!(name, "groknight");
|
||||
}
|
||||
other => panic!("expected ActionThenClose(PreviewTheme), got {other:?}"),
|
||||
}
|
||||
assert!(!s.close_on_picker_exit);
|
||||
}
|
||||
|
||||
/// The picker renders every choice in declaration order, top to
|
||||
/// bottom. Asserts each choice's `display` string and description
|
||||
/// appears on the expected row with the documented spacing.
|
||||
|
|
@ -2627,14 +2744,12 @@ fn picker_renders_choices_in_order() {
|
|||
);
|
||||
}
|
||||
|
||||
/// The currently-focused choice renders with the filled-disc
|
||||
/// marker `●`, `accent_user` marker color, `bg_visual` row bg,
|
||||
/// AND **BOLD** display text — three independent focus cues for
|
||||
/// low-contrast theme compatibility (parity with `cancel_turn_panel`).
|
||||
/// Focus (BG + bold) tracks `choices_idx`; the filled-disc marker
|
||||
/// tracks the committed `original_value` until Enter.
|
||||
#[test]
|
||||
fn picker_highlights_current_choice() {
|
||||
fn picker_separates_focus_highlight_from_committed_marker() {
|
||||
let mut s = picker_test_state();
|
||||
// Focus the second choice (index 1).
|
||||
// Focus the second choice while committed value remains "first".
|
||||
s.transition_to_picking_enum("test_enum", 1, SettingValue::Enum("first"), true);
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
|
|
@ -2654,32 +2769,57 @@ fn picker_highlights_current_choice() {
|
|||
.map(|c| c.symbol().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
// Layout: rows 3..6 are choices (with subtitle on row 1).
|
||||
assert_eq!(marker_at(3), "\u{25CB}", "row 3 (unfocused) should be ○");
|
||||
assert_eq!(marker_at(4), "\u{25CF}", "row 4 (focused) should be ●");
|
||||
assert_eq!(marker_at(5), "\u{25CB}", "row 5 (unfocused) should be ○");
|
||||
let marker_fg = |buf: &Buffer, y: u16| -> Option<ratatui::style::Color> {
|
||||
buf.cell((area.x + 1, y)).and_then(|c| c.style().fg)
|
||||
};
|
||||
// Layout: rows 3..5 are choices (with subtitle on row 1).
|
||||
// Row 3 = committed "first" (unfocused), row 4 = focused "second".
|
||||
assert_eq!(
|
||||
marker_at(3),
|
||||
"\u{25CF}",
|
||||
"row 3 (committed, unfocused) should be ●"
|
||||
);
|
||||
assert_eq!(
|
||||
marker_at(4),
|
||||
"\u{25CB}",
|
||||
"row 4 (focused, not committed) should be ○"
|
||||
);
|
||||
assert_eq!(marker_at(5), "\u{25CB}", "row 5 (neither) should be ○");
|
||||
|
||||
// Marker accent follows committed state, not focus.
|
||||
if theme.accent_user != theme.gray {
|
||||
assert_eq!(
|
||||
marker_fg(&buf, 3),
|
||||
Some(theme.accent_user),
|
||||
"committed marker must use accent_user"
|
||||
);
|
||||
assert_eq!(
|
||||
marker_fg(&buf, 4),
|
||||
Some(theme.gray),
|
||||
"focused-but-uncommitted marker must use gray"
|
||||
);
|
||||
}
|
||||
|
||||
// Cell at the LAST column of each row carries the row bg
|
||||
// independent of prefix-width tweaks.
|
||||
// independent of prefix-width tweaks. Compare via
|
||||
// `settings_list_row_bg` so terminal-native themes (Reset
|
||||
// tokens elevated to DarkGray) pass too.
|
||||
let bg_at = |y: u16| -> Option<ratatui::style::Color> {
|
||||
buf.cell((area.x + area.width - 1, y))
|
||||
.and_then(|c| c.style().bg)
|
||||
};
|
||||
assert_eq!(
|
||||
bg_at(4),
|
||||
Some(theme.bg_visual),
|
||||
"focused row must have bg_visual background"
|
||||
Some(settings_list_row_bg(&theme, true, false)),
|
||||
"focused row must use selection background"
|
||||
);
|
||||
assert_eq!(
|
||||
bg_at(3),
|
||||
Some(theme.bg_base),
|
||||
"unfocused row must have bg_base background"
|
||||
Some(settings_list_row_bg(&theme, false, false)),
|
||||
"committed-but-unfocused row must use base background"
|
||||
);
|
||||
|
||||
// Display text on focused row carries BOLD modifier
|
||||
// (three focus cues). Display "Second
|
||||
// Option" starts at col `PICKER_PREFIX_W` (= 4). The 'S' at
|
||||
// col 4 should be bold.
|
||||
// Display text on focused row carries BOLD; committed alone does not.
|
||||
let focused_modifier = buf
|
||||
.cell((area.x + PICKER_PREFIX_W, 4))
|
||||
.map(|c| c.style().add_modifier)
|
||||
|
|
@ -2688,13 +2828,156 @@ fn picker_highlights_current_choice() {
|
|||
focused_modifier.contains(Modifier::BOLD),
|
||||
"focused row's display must be BOLD, got modifiers {focused_modifier:?}"
|
||||
);
|
||||
let unfocused_modifier = buf
|
||||
let committed_unfocused_modifier = buf
|
||||
.cell((area.x + PICKER_PREFIX_W, 3))
|
||||
.map(|c| c.style().add_modifier)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!committed_unfocused_modifier.contains(Modifier::BOLD),
|
||||
"committed-but-unfocused row must NOT be BOLD, got modifiers {committed_unfocused_modifier:?}"
|
||||
);
|
||||
|
||||
// Committed + focused: filled dot and selection bg/bold together.
|
||||
s.transition_to_picking_enum("test_enum", 0, SettingValue::Enum("first"), true);
|
||||
let mut buf2 = Buffer::empty(area);
|
||||
render_picking_enum(&mut buf2, area, &s, &theme);
|
||||
let marker_at2 = |y: u16| -> String {
|
||||
buf2.cell((area.x + 1, y))
|
||||
.map(|c| c.symbol().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(
|
||||
marker_at2(3),
|
||||
"\u{25CF}",
|
||||
"committed+focused row should be ●"
|
||||
);
|
||||
assert_eq!(
|
||||
marker_at2(4),
|
||||
"\u{25CB}",
|
||||
"uncommitted unfocused row should be ○"
|
||||
);
|
||||
let bg_at2 = |y: u16| -> Option<ratatui::style::Color> {
|
||||
buf2.cell((area.x + area.width - 1, y))
|
||||
.and_then(|c| c.style().bg)
|
||||
};
|
||||
assert_eq!(
|
||||
bg_at2(3),
|
||||
Some(settings_list_row_bg(&theme, true, false)),
|
||||
"committed+focused row must use selection background"
|
||||
);
|
||||
assert_eq!(
|
||||
bg_at2(4),
|
||||
Some(settings_list_row_bg(&theme, false, false)),
|
||||
"uncommitted unfocused row must use base background"
|
||||
);
|
||||
let both_modifier = buf2
|
||||
.cell((area.x + PICKER_PREFIX_W, 3))
|
||||
.map(|c| c.style().add_modifier)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
both_modifier.contains(Modifier::BOLD),
|
||||
"committed+focused row must be BOLD, got modifiers {both_modifier:?}"
|
||||
);
|
||||
let unfocused_modifier = buf2
|
||||
.cell((area.x + PICKER_PREFIX_W, 4))
|
||||
.map(|c| c.style().add_modifier)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!unfocused_modifier.contains(Modifier::BOLD),
|
||||
"unfocused row's display must NOT be BOLD, got modifiers {unfocused_modifier:?}"
|
||||
"uncommitted unfocused row must NOT be BOLD, got modifiers {unfocused_modifier:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// DynamicEnum commits use `SettingValue::String`; the marker must
|
||||
/// resolve that arm the same way as static `Enum`, including the
|
||||
/// empty-canonical clear sentinel (`""` / "(no override)").
|
||||
#[test]
|
||||
fn picker_string_original_value_fills_committed_marker() {
|
||||
let mut s = picker_test_state();
|
||||
s.transition_to_picking_enum("test_enum", 1, SettingValue::String("first".into()), true);
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 12,
|
||||
};
|
||||
let mut buf = Buffer::empty(area);
|
||||
let theme = Theme::current();
|
||||
render_picking_enum(&mut buf, area, &s, &theme);
|
||||
|
||||
let marker_at = |y: u16| -> String {
|
||||
buf.cell((area.x + 1, y))
|
||||
.map(|c| c.symbol().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(
|
||||
marker_at(3),
|
||||
"\u{25CF}",
|
||||
"String original_value \"first\" must fill row 3"
|
||||
);
|
||||
assert_eq!(
|
||||
marker_at(4),
|
||||
"\u{25CB}",
|
||||
"focused non-committed row must stay hollow"
|
||||
);
|
||||
|
||||
// Empty-canonical clear sentinel (DynamicEnum "(no override)" shape):
|
||||
// empty String must fill the "" choice, not be skipped as "no value".
|
||||
const CLEAR_SENTINEL_CHOICES: &[EnumChoice] = &[
|
||||
EnumChoice {
|
||||
canonical: "",
|
||||
display: "(no override)",
|
||||
description: "Clear override.",
|
||||
},
|
||||
EnumChoice {
|
||||
canonical: "model-a",
|
||||
display: "Model A",
|
||||
description: "A model.",
|
||||
},
|
||||
];
|
||||
let entries = vec![SettingMeta {
|
||||
key: "test_dynamic_like",
|
||||
category: SettingCategory::Appearance,
|
||||
owner: SettingOwner::Shared,
|
||||
label: "Test clear sentinel",
|
||||
description: "Catalog with empty-canonical choice.",
|
||||
keywords: &[],
|
||||
kind: SettingKind::Enum {
|
||||
default: "",
|
||||
choices: CLEAR_SENTINEL_CHOICES,
|
||||
supports_preview: false,
|
||||
},
|
||||
restart_required: false,
|
||||
hidden_in_minimal: false,
|
||||
}];
|
||||
let mut s2 = SettingsModalState::new(
|
||||
Arc::new(SettingsRegistry::from_entries(entries)),
|
||||
UiConfig::default(),
|
||||
PagerLocalSnapshot::default(),
|
||||
);
|
||||
// Commit empty; focus the non-empty choice so marker ≠ focus.
|
||||
s2.transition_to_picking_enum(
|
||||
"test_dynamic_like",
|
||||
1,
|
||||
SettingValue::String(String::new()),
|
||||
false,
|
||||
);
|
||||
let mut buf2 = Buffer::empty(area);
|
||||
render_picking_enum(&mut buf2, area, &s2, &theme);
|
||||
let marker_at2 = |y: u16| -> String {
|
||||
buf2.cell((area.x + 1, y))
|
||||
.map(|c| c.symbol().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(
|
||||
marker_at2(3),
|
||||
"\u{25CF}",
|
||||
"empty String must fill empty-canonical clear row"
|
||||
);
|
||||
assert_eq!(
|
||||
marker_at2(4),
|
||||
"\u{25CB}",
|
||||
"focused non-empty choice must stay hollow while clear is committed"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -6096,6 +6379,51 @@ fn click_settings_breadcrumb_collapses_picker_to_browse() {
|
|||
);
|
||||
}
|
||||
|
||||
/// Breadcrumb is hierarchical up: even with deep-link `close_on_picker_exit`,
|
||||
/// click returns to Browse (never Close / ActionThenClose).
|
||||
#[test]
|
||||
fn click_settings_breadcrumb_ignores_close_on_picker_exit() {
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 120,
|
||||
height: 30,
|
||||
};
|
||||
let mut s = enter_picker_for("theme");
|
||||
s.close_on_picker_exit = true;
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_settings_modal(&mut buf, area, &mut s, false, None);
|
||||
let rect = s
|
||||
.settings_breadcrumb_rect
|
||||
.expect("PickingEnum must populate breadcrumb rect");
|
||||
|
||||
let outcome = handle_settings_mouse(
|
||||
&mut s,
|
||||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||||
rect.x + rect.width / 2,
|
||||
rect.y,
|
||||
);
|
||||
assert!(
|
||||
!matches!(
|
||||
outcome,
|
||||
SettingsKeyOutcome::Close | SettingsKeyOutcome::ActionThenClose(_)
|
||||
),
|
||||
"breadcrumb must not dismiss the modal, got {outcome:?}"
|
||||
);
|
||||
match outcome {
|
||||
SettingsKeyOutcome::Action(Action::PreviewTheme(orig)) => {
|
||||
assert_eq!(orig, "groknight");
|
||||
}
|
||||
other => panic!("expected preview revert Action, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
matches!(s.mode(), SettingsModalMode::Browse),
|
||||
"breadcrumb must return to Browse, got {:?}",
|
||||
s.mode()
|
||||
);
|
||||
assert!(!s.close_on_picker_exit);
|
||||
}
|
||||
|
||||
/// Sibling of `click_settings_breadcrumb_collapses_picker_to_browse`
|
||||
/// that exercises the preview-then-click path: user navigates
|
||||
/// to a different theme via Down arrow (Action::PreviewTheme
|
||||
|
|
|
|||
|
|
@ -22,11 +22,13 @@ mod hero_box;
|
|||
pub(crate) mod logo;
|
||||
mod menu;
|
||||
mod prompt;
|
||||
mod toast;
|
||||
mod top_bar;
|
||||
|
||||
pub(crate) use logo::shimmer_frame;
|
||||
use logo::{logo_line_count, render_logo};
|
||||
use menu::render_menu;
|
||||
pub(crate) use toast::paint_welcome_toast;
|
||||
pub(crate) use top_bar::location_line_at;
|
||||
use top_bar::render_top_bar;
|
||||
|
||||
|
|
|
|||
160
crates/codegen/xai-grok-pager/src/views/welcome/toast.rs
Normal file
160
crates/codegen/xai-grok-pager/src/views/welcome/toast.rs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
//! Single-row welcome toast overlay (above the prompt when present).
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::render::SafeBuf;
|
||||
use crate::theme::Theme;
|
||||
use crate::views::goal_detail::truncate_to_width;
|
||||
|
||||
/// Prefer one row above the prompt, right-aligned to it. With no prompt
|
||||
/// (login / gate), paint the last row of `area` — stacked welcome layouts
|
||||
/// put the version badge there, so the toast may overlay it briefly.
|
||||
pub(crate) fn paint_welcome_toast(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
msg: &str,
|
||||
prompt_rect: Option<Rect>,
|
||||
) {
|
||||
let theme = Theme::current();
|
||||
let max_msg = (area.width as usize).saturating_sub(4);
|
||||
if max_msg == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let body = if UnicodeWidthStr::width(msg) <= max_msg {
|
||||
std::borrow::Cow::Borrowed(msg)
|
||||
} else {
|
||||
std::borrow::Cow::Owned(truncate_to_width(msg, max_msg))
|
||||
};
|
||||
let toast = format!(" {body} ");
|
||||
let w = UnicodeWidthStr::width(toast.as_str()) as u16;
|
||||
let (x, y) = if let Some(prompt) = prompt_rect.filter(|r| r.width > 0 && r.y > area.y) {
|
||||
let max_x = area.right().saturating_sub(w).max(area.x);
|
||||
let x = prompt.right().saturating_sub(w + 1).clamp(area.x, max_x);
|
||||
(x, prompt.y.saturating_sub(1))
|
||||
} else {
|
||||
(
|
||||
area.right().saturating_sub(w.saturating_add(1)),
|
||||
area.bottom().saturating_sub(1),
|
||||
)
|
||||
};
|
||||
let style = Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.bg(theme.bg_base)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
buf.set_string_safe(x, y, &toast, style);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn toast_row(buf: &Buffer, area: Rect, y: u16) -> String {
|
||||
(area.x..area.right())
|
||||
.map(|x| buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_welcome_toast_truncates_narrow_width() {
|
||||
let area = Rect::new(0, 0, 40, 6);
|
||||
let prompt = Rect::new(0, 4, 40, 2);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let long = crate::app::link_opener::browser_unavailable_line(
|
||||
"https://x.ai/legal/terms-of-service",
|
||||
false,
|
||||
);
|
||||
|
||||
paint_welcome_toast(&mut buf, area, &long, Some(prompt));
|
||||
let y = prompt.y.saturating_sub(1);
|
||||
let long_row = toast_row(&buf, area, y);
|
||||
assert!(
|
||||
long_row.contains('\u{2026}'),
|
||||
"narrow width must truncate with ellipsis: {long_row:?}"
|
||||
);
|
||||
assert!(
|
||||
long_row.contains("https://x.ai"),
|
||||
"URL-first message should keep the URL prefix under truncation: {long_row:?}"
|
||||
);
|
||||
assert!(
|
||||
!toast_row(&buf, area, prompt.y).contains("https://"),
|
||||
"truncated toast must not spill into prompt row"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_welcome_toast_truncates_wide_glyphs_by_display_width() {
|
||||
let area = Rect::new(0, 0, 12, 4);
|
||||
let prompt = Rect::new(0, 2, 12, 2);
|
||||
let mut buf = Buffer::empty(area);
|
||||
// Each CJK glyph is display width 2; char-count truncation would overfill.
|
||||
let msg = "你好世界测试文字更多内容";
|
||||
paint_welcome_toast(&mut buf, area, msg, Some(prompt));
|
||||
let y = prompt.y.saturating_sub(1);
|
||||
let row = toast_row(&buf, area, y);
|
||||
assert!(
|
||||
row.contains('\u{2026}'),
|
||||
"wide glyphs must truncate by display width: {row:?}"
|
||||
);
|
||||
let content: String = row.chars().filter(|c| *c != ' ').collect();
|
||||
assert!(
|
||||
UnicodeWidthStr::width(content.as_str()) <= area.width as usize,
|
||||
"painted width must fit area: {row:?}"
|
||||
);
|
||||
assert!(
|
||||
!toast_row(&buf, area, prompt.y).contains('你'),
|
||||
"must not spill into prompt row"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_welcome_toast_right_aligns_to_prompt() {
|
||||
let area = Rect::new(0, 0, 80, 10);
|
||||
let prompt = Rect::new(2, 8, 76, 2);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let msg = "ok";
|
||||
paint_welcome_toast(&mut buf, area, msg, Some(prompt));
|
||||
|
||||
let y = prompt.y.saturating_sub(1);
|
||||
let row = toast_row(&buf, area, y);
|
||||
assert!(row.contains("ok"), "toast must paint: {row:?}");
|
||||
let toast = " ok ";
|
||||
let w = UnicodeWidthStr::width(toast) as u16;
|
||||
let expected_x = prompt.right().saturating_sub(w + 1);
|
||||
let content_x = (area.x..area.right())
|
||||
.find(|&x| buf.cell((x, y)).is_some_and(|c| c.symbol() == "o"))
|
||||
.expect("expected toast content");
|
||||
assert_eq!(
|
||||
content_x,
|
||||
expected_x + 1,
|
||||
"toast should right-align to prompt (content after pad)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_welcome_toast_without_prompt_right_aligns_bottom_row() {
|
||||
let area = Rect::new(0, 0, 80, 5);
|
||||
let mut buf = Buffer::empty(area);
|
||||
paint_welcome_toast(&mut buf, area, "ok", None);
|
||||
let y = area.bottom().saturating_sub(1);
|
||||
let row = toast_row(&buf, area, y);
|
||||
assert!(
|
||||
row.contains("ok"),
|
||||
"toast must paint on bottom row: {row:?}"
|
||||
);
|
||||
let toast = " ok ";
|
||||
let w = UnicodeWidthStr::width(toast) as u16;
|
||||
let expected_x = area.right().saturating_sub(w.saturating_add(1));
|
||||
let content_x = (area.x..area.right())
|
||||
.find(|&x| buf.cell((x, y)).is_some_and(|c| c.symbol() == "o"))
|
||||
.expect("expected toast content");
|
||||
assert_eq!(
|
||||
content_x,
|
||||
expected_x + 1,
|
||||
"no-prompt toast should right-align within area (content after pad)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue