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:
grokkybara[bot] 2026-07-30 19:07:40 +00:00
commit dd04f397b1
367 changed files with 29489 additions and 10051 deletions

View file

@ -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),

View file

@ -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 => {

View file

@ -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}"

View file

@ -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) {

View file

@ -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

View file

@ -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;

View 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)"
);
}
}