Synced from monorepo

Changes:
- grok-shell: request workspaces:read/write OAuth2 scopes
- security: fix SSRF bypass via HTTP redirect in hook runner
- fix(grok-build): enterprise STT WSS URL + API-key voice bearer
- Harden identity-change purge and sync-marker invariants
- sandbox + workspace-server: delete the legacy ready-file arm
- Show billing URL when browser cannot open
- fix(pager): show folder-trust UI in minimal mode
- fix(pager): drain task_backgrounded before no-wait headless exit
- grok-agent-sdk: stop SDK-spawned agents from staging self-updates they can never adopt
- Split settings_modal into directory module
- Delegate VS Code SSH file links
- grok-shell: release the workspace session binding when a session is removed
- keep skills reachable when their name collides with a client builtin
- Preserve semantic link targets
This commit is contained in:
grokkybara[bot] 2026-07-16 20:27:30 +01:00
commit 8adf9013a0
117 changed files with 16998 additions and 14540 deletions

View file

@ -1,6 +1,6 @@
[package]
name = "xai-grok-pager"
version = "0.1.220-alpha.4"
version = "0.2.101"
edition.workspace = true
license = "Apache-2.0"
authors = ["xAI"]
@ -161,7 +161,7 @@ windows-sys = { version = "0.59", features = ["Win32_System_Console"] }
[dev-dependencies]
# Enable the render crate's test-only helpers for the pager's test build.
xai-grok-pager-render = { path = "../xai-grok-pager-render", features = [] }
xai-grok-pager-render = { path = "../xai-grok-pager-render", features = ["test-support"] }
pretty_assertions = { workspace = true }
insta = { workspace = true }
criterion = { workspace = true }
@ -273,6 +273,7 @@ default = ["jemalloc", "sandbox-enforce"]
default-bazel = [
"jemalloc",
"sandbox-enforce",
"test-support",
]
# No-op on this crate: the actual `#[global_allocator]` (and the
# `tikv-jemallocator` dep) live on the composition-root binary
@ -282,3 +283,7 @@ default-bazel = [
jemalloc = []
sandbox-enforce = ["xai-grok-sandbox/enforce"]
release-dist = []
# Exposes test-only view-model constructors/setters (e.g. `test_agent_view`,
# `AgentSession::set_yolo_mode_for_test`) to sibling crates' test builds — used
# by `xai-grok-pager-minimal`'s unit tests. Never enabled in production builds.
test-support = ["xai-grok-pager-render/test-support"]

View file

@ -556,6 +556,12 @@ grok -p "..." --no-auto-update
| Non-TTY stderr (auto-detected) | Automatic |
| `[cli] auto_update = false` | Persistent|
`GROK_DISABLE_AUTOUPDATER` set to a falsy value (`0`, `false`, `off`, `no`, or empty, any
case) counts as not set. The agent SDKs
inject `GROK_DISABLE_AUTOUPDATER=1` for the non-leader agents they spawn (a falsy value in
the SDK's isolation env keeps updates on), and the stdio agent skips its background update
unless it runs from the managed install (`$GROK_HOME/bin/grok`).
Update messages go to **stderr**. Stdout stays clean for `--output-format json`. See also [Environment Variables for Headless](#environment-variables-for-headless).
---

View file

@ -546,30 +546,30 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
)),
};
agent.scrollback.push_block(block);
} else if let Some(eid) = entry_id {
if let Some(entry) = agent.scrollback.get_by_id_mut(eid) {
if let RenderBlock::Subagent(ref mut sb) = entry.block {
match status.as_str() {
"completed" => {
sb.kind = crate::scrollback::blocks::SubagentBlockKind::Completed {
elapsed: elapsed_dur,
};
}
"cancelled" => {
sb.kind = crate::scrollback::blocks::SubagentBlockKind::Cancelled {
elapsed: elapsed_dur,
};
}
_ => {
sb.kind = crate::scrollback::blocks::SubagentBlockKind::Failed {
elapsed: elapsed_dur,
error: error.clone(),
};
}
} else if let Some(eid) = entry_id
&& let Some(entry) = agent.scrollback.get_by_id_mut(eid)
{
if let RenderBlock::Subagent(ref mut sb) = entry.block {
match status.as_str() {
"completed" => {
sb.kind = crate::scrollback::blocks::SubagentBlockKind::Completed {
elapsed: elapsed_dur,
};
}
"cancelled" => {
sb.kind = crate::scrollback::blocks::SubagentBlockKind::Cancelled {
elapsed: elapsed_dur,
};
}
_ => {
sb.kind = crate::scrollback::blocks::SubagentBlockKind::Failed {
elapsed: elapsed_dur,
error: error.clone(),
};
}
}
entry.invalidate_cache();
}
entry.invalidate_cache();
}
let mut was_running = false;
if let Some(info) = agent.subagent_sessions.get_mut(&child_session_id) {

View file

@ -62,6 +62,8 @@ pub enum Action {
CheckSubscription,
/// Open an arbitrary URL in the system browser (with scheme validation).
OpenUrl(String),
/// Open a semantic scrollback link.
OpenLink(crate::render::osc8::LinkTarget),
/// Open grok.com managed connectors, appending session teamId when set.
OpenManagedConnectors,
/// Cycle to the next visible link (or highlight the first if none selected).

View file

@ -759,12 +759,12 @@ impl AgentSession {
/// Test-only setter for `yolo_mode` (the field is private; production toggles
/// it via the permission-mode facade). Available to sibling crates' test
/// builds through the test-only helpers.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub(crate) fn set_yolo_mode_for_test(&mut self, on: bool) {
self.yolo_mode = on;
}
/// Test-only setter for `auto_mode`. See [`Self::set_yolo_mode_for_test`].
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub(crate) fn set_auto_mode_for_test(&mut self, on: bool) {
self.auto_mode = on;
}

View file

@ -643,11 +643,11 @@ impl AgentView {
modifiers: mouse.modifiers,
};
let _ = self.prompt.handle_mouse(&event);
} else if let Some((scroll_top, scroll_bottom)) = self.question_scroll_region {
if mouse.row >= scroll_top && mouse.row < scroll_bottom {
self.apply_question_scroll(delta);
}
} else {
} else if let Some((scroll_top, scroll_bottom)) = self.question_scroll_region
&& mouse.row >= scroll_top
&& mouse.row < scroll_bottom
{
self.apply_question_scroll(delta);
}
InputOutcome::Changed
}

View file

@ -56,11 +56,17 @@ impl AgentView {
}
});
}
/// Return the URL of the currently highlighted link, if any.
pub fn highlighted_link_url(&self) -> Option<&str> {
/// Return the semantic target of the currently highlighted link, if any.
pub fn highlighted_link_target(&self) -> Option<&crate::render::osc8::LinkTarget> {
self.highlighted_link_idx
.and_then(|idx| self.visible_link_map.links().get(idx))
.map(|link| &*link.url)
.map(|link| &link.target)
}
/// Return the current OSC 8 URL for the highlighted link preview.
pub fn highlighted_link_url(&self) -> Option<std::sync::Arc<str>> {
self.highlighted_link_target()
.and_then(crate::render::osc8::resolve_link_target)
.and_then(|resolved| resolved.osc8_url)
}
/// True when `(x, y)` lies inside an overlay drawn over the scrollback this
/// frame (dropdown, goal detail). Such positions belong to the overlay, not
@ -275,17 +281,33 @@ mod link_click_tests {
agent.active_pane = AgentPane::Scrollback;
}
/// Add a link to the visible_link_map covering (col_start..col_end, row).
fn add_visible_link(agent: &mut AgentView, row: u16, col_start: u16, col_end: u16, url: &str) {
fn add_visible_target(
agent: &mut AgentView,
row: u16,
col_start: u16,
col_end: u16,
target: crate::render::osc8::LinkTarget,
) {
let mut overlay = LinkOverlay::new();
overlay.push(OverlayLink {
screen_row: row,
col_start,
col_end,
url: Arc::from(url),
target,
presentation: crate::render::osc8::LinkPresentation::Opaque,
id: Some(1),
});
agent.visible_link_map.rebuild(1, &overlay, vec![]);
}
fn add_visible_link(agent: &mut AgentView, row: u16, col_start: u16, col_end: u16, url: &str) {
add_visible_target(
agent,
row,
col_start,
col_end,
crate::render::osc8::LinkTarget::Url(Arc::from(url)),
);
}
fn mouse_down(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
@ -1032,7 +1054,11 @@ mod link_click_tests {
let mut agent = make_agent();
let area = Rect::new(0, 0, 80, 24);
setup_scrollback_area(&mut agent, area);
agent.pending_link_click = Some((15, 5, "https://example.com".into()));
agent.pending_link_click = Some((
15,
5,
crate::render::osc8::LinkTarget::Url("https://example.com".into()),
));
agent.left_mouse_down = true;
let outcome = agent.handle_mouse(&mouse_drag(16, 5));
assert!(matches!(
@ -1042,22 +1068,29 @@ mod link_click_tests {
assert!(agent.pending_link_click.is_none());
}
#[test]
fn up_at_same_position_returns_open_url_action() {
fn up_at_same_position_returns_open_link_action() {
let mut agent = make_agent();
let area = Rect::new(0, 0, 80, 24);
setup_scrollback_area(&mut agent, area);
agent.pending_link_click = Some((15, 5, "https://example.com".into()));
agent.pending_link_click = Some((
15,
5,
crate::render::osc8::LinkTarget::Url("https://example.com".into()),
));
agent.left_mouse_down = true;
let outcome = agent.handle_mouse(&mouse_up(15, 5));
match outcome {
InputOutcome::Action(Action::OpenUrl(url)) => {
assert_eq!(url, "https://example.com");
InputOutcome::Action(Action::OpenLink(target)) => {
assert_eq!(
target,
crate::render::osc8::LinkTarget::Url("https://example.com".into())
);
}
other => panic!("expected Action::OpenUrl, got {other:?}"),
other => panic!("expected Action::OpenLink, got {other:?}"),
}
}
/// A modifier+click on a `file://` link dispatches `OpenUrl` (Ctrl on
/// Linux/Windows; macOS polls CoreGraphics so the Down step isn't
/// A modifier+click preserves a filesystem target through app activation
/// (Ctrl on Linux/Windows; macOS polls CoreGraphics so the Down step isn't
/// reproducible in a unit test).
#[test]
#[cfg(not(target_os = "macos"))]
@ -1065,21 +1098,41 @@ mod link_click_tests {
let mut agent = make_agent();
let area = Rect::new(0, 0, 80, 24);
setup_scrollback_area(&mut agent, area);
add_visible_link(&mut agent, 5, 10, 30, "file:///tmp/session/images/1.png");
add_visible_target(
&mut agent,
5,
10,
30,
crate::render::osc8::LinkTarget::File(Arc::from(std::path::Path::new(
"/tmp/session/images/1.png",
))),
);
let mut down = mouse_down(15, 5);
down.modifiers = crossterm::event::KeyModifiers::CONTROL;
assert!(matches!(agent.handle_mouse(&down), InputOutcome::Changed));
match agent.handle_mouse(&mouse_up(15, 5)) {
InputOutcome::Action(Action::OpenUrl(url)) => {
assert_eq!(url, "file:///tmp/session/images/1.png");
InputOutcome::Action(Action::OpenLink(target)) => {
assert_eq!(
target,
crate::render::osc8::LinkTarget::File(Arc::from(std::path::Path::new(
"/tmp/session/images/1.png",
)))
);
}
other => panic!("expected Action::OpenUrl(file://…), got {other:?}"),
other => panic!("expected Action::OpenLink(file), got {other:?}"),
}
}
fn test_link(url: &str, painted_w: u16) -> crate::scrollback::VisibleLink {
crate::scrollback::VisibleLink {
rects: vec![Rect::new(0, 0, painted_w, 1)],
url: std::sync::Arc::from(url),
target: crate::render::osc8::LinkTarget::Url(std::sync::Arc::from(url)),
id: None,
}
}
fn test_file_link(path: &std::path::Path, painted_w: u16) -> crate::scrollback::VisibleLink {
crate::scrollback::VisibleLink {
rects: vec![Rect::new(0, 0, painted_w, 1)],
target: crate::render::osc8::LinkTarget::File(Arc::from(path)),
id: None,
}
}
@ -1115,13 +1168,14 @@ mod link_click_tests {
true,
&test_link(bare, bare_w.saturating_add(40))
));
let file_path = std::path::Path::new("/tmp/session/images/1.png");
assert!(app_should_open_link_on_click_with(
true,
&test_link(file, file_w)
&test_file_link(file_path, file_w)
));
assert!(app_should_open_link_on_click_with(
true,
&test_link(file, 8)
&test_file_link(file_path, 8)
));
}
/// Regression: while the plan preview (line viewer) is open and the
@ -1192,10 +1246,17 @@ mod link_click_tests {
let mut agent = make_agent();
let area = Rect::new(0, 0, 80, 24);
setup_scrollback_area(&mut agent, area);
agent.pending_link_click = Some((15, 5, "https://example.com".into()));
agent.pending_link_click = Some((
15,
5,
crate::render::osc8::LinkTarget::Url("https://example.com".into()),
));
agent.left_mouse_down = true;
let outcome = agent.handle_mouse(&mouse_up(16, 5));
assert!(!matches!(outcome, InputOutcome::Action(Action::OpenUrl(_))));
assert!(!matches!(
outcome,
InputOutcome::Action(Action::OpenLink(_))
));
assert!(agent.pending_link_click.is_none());
}
#[test]
@ -1204,7 +1265,11 @@ mod link_click_tests {
let area = Rect::new(0, 0, 80, 24);
setup_scrollback_area(&mut agent, area);
add_visible_link(&mut agent, 5, 10, 30, "https://example.com");
agent.pending_link_click = Some((15, 5, "https://example.com".into()));
agent.pending_link_click = Some((
15,
5,
crate::render::osc8::LinkTarget::Url("https://example.com".into()),
));
let outcome = agent.handle_mouse(&mouse_down(5, 3));
assert!(matches!(outcome, InputOutcome::Changed));
assert!(agent.pending_link_click.is_none());
@ -1214,7 +1279,11 @@ mod link_click_tests {
let mut agent = make_agent();
let area = Rect::new(0, 0, 80, 24);
setup_scrollback_area(&mut agent, area);
agent.pending_link_click = Some((15, 5, "https://example.com".into()));
agent.pending_link_click = Some((
15,
5,
crate::render::osc8::LinkTarget::Url("https://example.com".into()),
));
agent.left_mouse_down = true;
agent.pending_text_drag = Some(PendingTextDrag {
start_col: 15,
@ -1238,14 +1307,21 @@ mod link_click_tests {
let mut agent = make_agent();
setup_scrollback_area(&mut agent, Rect::new(0, 0, 80, 20));
agent.active_pane = AgentPane::Prompt;
agent.pending_link_click = Some((15, 5, "https://example.com".into()));
agent.pending_link_click = Some((
15,
5,
crate::render::osc8::LinkTarget::Url("https://example.com".into()),
));
agent.left_mouse_down = true;
let outcome = agent.handle_mouse(&mouse_up(15, 5));
match outcome {
InputOutcome::Action(Action::OpenUrl(url)) => {
assert_eq!(url, "https://example.com");
InputOutcome::Action(Action::OpenLink(target)) => {
assert_eq!(
target,
crate::render::osc8::LinkTarget::Url("https://example.com".into())
);
}
other => panic!("expected Action::OpenUrl, got {other:?}"),
other => panic!("expected Action::OpenLink, got {other:?}"),
}
}
/// `/btw` panel links share the pane-agnostic Up path: once Down records
@ -1258,14 +1334,21 @@ mod link_click_tests {
agent.active_pane = AgentPane::Prompt;
agent.btw_focused = true;
add_visible_link(&mut agent, 20, 4, 40, "https://example.com/btw");
agent.pending_link_click = Some((10, 20, "https://example.com/btw".into()));
agent.pending_link_click = Some((
10,
20,
crate::render::osc8::LinkTarget::Url("https://example.com/btw".into()),
));
agent.left_mouse_down = true;
let outcome = agent.handle_mouse(&mouse_up(10, 20));
match outcome {
InputOutcome::Action(Action::OpenUrl(url)) => {
assert_eq!(url, "https://example.com/btw");
InputOutcome::Action(Action::OpenLink(target)) => {
assert_eq!(
target,
crate::render::osc8::LinkTarget::Url("https://example.com/btw".into())
);
}
other => panic!("expected Action::OpenUrl for btw link, got {other:?}"),
other => panic!("expected Action::OpenLink for btw link, got {other:?}"),
}
}
/// On mouse-fallback terminals, Down on a `/btw` link with the link
@ -1292,11 +1375,12 @@ mod link_click_tests {
let outcome = agent.handle_mouse(&down);
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(
agent
.pending_link_click
.as_ref()
.map(|(c, r, u)| (*c, *r, u.as_str())),
Some((10, 20, "https://example.com/btw"))
agent.pending_link_click.as_ref(),
Some(&(
10,
20,
crate::render::osc8::LinkTarget::Url("https://example.com/btw".into())
))
);
}
}
@ -1311,7 +1395,10 @@ mod link_click_tests {
screen_row: 20,
col_start: 4,
col_end: 40,
url: Arc::from("https://example.com/btw"),
target: crate::render::osc8::LinkTarget::Url(Arc::from(
"https://example.com/btw",
)),
presentation: crate::render::osc8::LinkPresentation::Opaque,
id: Some(1),
});
o
@ -1362,7 +1449,8 @@ mod link_click_tests {
screen_row: i as u16,
col_start: 0,
col_end: 10,
url: Arc::from(*url),
target: crate::render::osc8::LinkTarget::Url(Arc::from(*url)),
presentation: crate::render::osc8::LinkPresentation::Opaque,
id: Some(i as u32),
});
}
@ -1374,7 +1462,10 @@ mod link_click_tests {
add_multiple_links(&mut agent);
agent.cycle_highlighted_link(true);
assert_eq!(agent.highlighted_link_idx, Some(0));
assert_eq!(agent.highlighted_link_url(), Some("https://a.com"));
assert_eq!(
agent.highlighted_link_url().as_deref(),
Some("https://a.com")
);
}
#[test]
fn cycle_backward_from_none_selects_last() {
@ -1382,7 +1473,10 @@ mod link_click_tests {
add_multiple_links(&mut agent);
agent.cycle_highlighted_link(false);
assert_eq!(agent.highlighted_link_idx, Some(2));
assert_eq!(agent.highlighted_link_url(), Some("https://c.com"));
assert_eq!(
agent.highlighted_link_url().as_deref(),
Some("https://c.com")
);
}
#[test]
fn cycle_forward_wraps_around() {
@ -1417,10 +1511,13 @@ mod link_click_tests {
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let outcome = agent.handle_scrollback_key(&enter, &registry);
match outcome {
InputOutcome::Action(Action::OpenUrl(url)) => {
assert_eq!(url, "https://b.com");
InputOutcome::Action(Action::OpenLink(target)) => {
assert_eq!(
target,
crate::render::osc8::LinkTarget::Url("https://b.com".into())
);
}
other => panic!("expected Action::OpenUrl, got {other:?}"),
other => panic!("expected Action::OpenLink, got {other:?}"),
}
assert_eq!(agent.highlighted_link_idx, None);
}
@ -1433,7 +1530,10 @@ mod link_click_tests {
let registry = ActionRegistry::defaults();
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let outcome = agent.handle_scrollback_key(&enter, &registry);
assert!(!matches!(outcome, InputOutcome::Action(Action::OpenUrl(_))));
assert!(!matches!(
outcome,
InputOutcome::Action(Action::OpenLink(_))
));
}
/// Enter with a previous user prompt selected enters inline edit mode
/// (edit-and-resubmit) instead of falling through to OpenBlockViewer.
@ -1603,7 +1703,7 @@ mod link_click_tests {
let mut agent = make_agent();
add_multiple_links(&mut agent);
agent.highlighted_link_idx = Some(99);
assert_eq!(agent.highlighted_link_url(), None);
assert!(agent.highlighted_link_url().is_none());
}
fn make_search_agent() -> (AgentView, ActionRegistry) {
use crate::scrollback::block::RenderBlock;

View file

@ -475,8 +475,13 @@ pub(super) fn app_should_open_link_on_click_with(
if !native_plain_url_open {
return true;
}
let Some(url) = crate::render::osc8::resolve_link_target(&link.target)
.and_then(|resolved| resolved.osc8_url)
else {
return true;
};
if !crate::app::link_opener::is_safe_to_open(
&link.url,
&url,
crate::terminal::hyperlinks::SchemeFilter::Standard,
) {
return true;
@ -1000,9 +1005,9 @@ pub struct AgentView {
pub last_btw_area: Rect,
/// Pending plain scrollback click that should dispatch on mouse-up if no drag starts.
pub pending_scrollback_click: Option<(u16, u16)>,
/// Pending link click: (col, row, url). Set on Down(Left) when a link is hit,
/// Pending link click: (col, row, target). Set on Down(Left) when a link is hit,
/// consumed on Up(Left) at the same position, cleared on drag.
pub pending_link_click: Option<(u16, u16, String)>,
pub pending_link_click: Option<(u16, u16, crate::render::osc8::LinkTarget)>,
/// Absolute paths of media generated in this transcript, used to resolve the
/// short relative paths the model prints (`images/1.jpg`) to clickable
/// links. Rebuilt from scrollback only when its generation changes.
@ -2110,7 +2115,7 @@ fn collect_citation_links(
for url in &ws.citations {
links.push(VisibleLink {
rects: vec![block_geom.content_area],
url: Arc::from(url.as_str()),
target: crate::render::osc8::LinkTarget::Url(Arc::from(url.as_str())),
id: None,
});
}
@ -2119,7 +2124,7 @@ fn collect_citation_links(
if !wf.url.is_empty() {
links.push(VisibleLink {
rects: vec![block_geom.content_area],
url: Arc::from(wf.url.as_str()),
target: crate::render::osc8::LinkTarget::Url(Arc::from(wf.url.as_str())),
id: None,
});
}
@ -3133,7 +3138,7 @@ pub(super) mod test_fixtures {
/// the lazy Mermaid glue (which needs a session dir) can be exercised from the
/// `mermaid_worker` test module without duplicating the large `AgentSession`
/// literal.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub(crate) fn test_agent_view(session_id: Option<&str>, cwd: std::path::PathBuf) -> AgentView {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
AgentView::new(

View file

@ -327,6 +327,30 @@ impl AgentView {
.as_mut()
.is_some_and(|m| m.tick_result_notice())
}
/// Open `url` in the system browser. When the opener cannot run (headless
/// Linux VM, missing `xdg-open`, etc.), push a scrollback system message
/// with the full URL so the user can copy it, and best-effort copy to the
/// clipboard (OSC 52 works over SSH even without a local display).
///
/// Unsafe schemes are rejected silently (same as [`open_url_if_safe`]).
pub(crate) fn open_url_or_show(&mut self, url: &str) {
use crate::app::link_opener::{OpenUrlResult, browser_unavailable_message, try_open_url};
use crate::scrollback::block::RenderBlock;
use crate::terminal::hyperlinks::SchemeFilter;
match try_open_url(url, SchemeFilter::Standard) {
OpenUrlResult::Opened | OpenUrlResult::RejectedScheme => {}
OpenUrlResult::BrowserUnavailable => {
self.scrollback
.push_block(RenderBlock::system(browser_unavailable_message(url)));
// Best-effort clipboard so SSH/VM users can paste into a
// browser on another machine without selecting TUI text.
let _ = crate::clipboard::SystemClipboard::try_set(url);
self.show_toast("Browser unavailable - URL shown above");
}
}
}
}
#[cfg(test)]

View file

@ -43,10 +43,10 @@ impl AgentView {
return InputOutcome::Action(Action::FocusPrompt);
}
if key!(Enter).matches(key)
&& let Some(url) = self.highlighted_link_url().map(String::from)
&& let Some(target) = self.highlighted_link_target().cloned()
{
self.highlighted_link_idx = None;
return InputOutcome::Action(Action::OpenUrl(url));
return InputOutcome::Action(Action::OpenLink(target));
}
if key!(Enter).matches(key)
&& !self.scrollback.is_selected_group_header()
@ -602,10 +602,11 @@ impl AgentView {
modifiers: crossterm::event::KeyModifiers::NONE,
};
let _ = self.prompt.handle_mouse(&event);
} else if let Some((scroll_top, scroll_bottom)) = self.question_scroll_region {
if row >= scroll_top && row < scroll_bottom {
self.apply_question_scroll(lines);
}
} else if let Some((scroll_top, scroll_bottom)) = self.question_scroll_region
&& row >= scroll_top
&& row < scroll_bottom
{
self.apply_question_scroll(lines);
}
return;
}

View file

@ -4151,12 +4151,19 @@ impl AgentView {
&& r.x < link.col_end
})
})
.map(|link| xai_ratatui_inline::LinkSpan {
row: link.screen_row,
col_start: link.col_start,
col_end: link.col_end,
url: link.url.clone(),
id: if emit_id { link.id } else { None },
.filter_map(|link| {
crate::render::osc8::resolve_link_target_with_presentation(
&link.target,
link.presentation,
)
.and_then(|resolved| resolved.osc8_url)
.map(|url| xai_ratatui_inline::LinkSpan {
row: link.screen_row,
col_start: link.col_start,
col_end: link.col_end,
url,
id: if emit_id { link.id } else { None },
})
})
.collect();
self.push_promo_cta_link_span(

View file

@ -428,10 +428,10 @@ impl AgentView {
/// load's batch/replay bookkeeping — and defer its results. The window's
/// pending re-init completion later no-ops (generation gone).
pub(crate) fn abort_session_reload(&mut self) {
if let Some(reload) = self.session_reload.take() {
if self.apply_reload_outcome(reload, false) {
crate::memory_release::release_retained_memory_with("reload-abort");
}
if let Some(reload) = self.session_reload.take()
&& self.apply_reload_outcome(reload, false)
{
crate::memory_release::release_retained_memory_with("reload-abort");
}
}
/// Finalize the reload window opened for `generation`.

View file

@ -5653,7 +5653,8 @@ pub(crate) mod tests {
screen_row: 2,
col_start: 0,
col_end: 10,
url: Arc::from("https://example.com"),
target: crate::render::osc8::LinkTarget::Url(Arc::from("https://example.com")),
presentation: crate::render::osc8::LinkPresentation::Opaque,
id: Some(1),
});
agent.visible_link_map.rebuild(1, &overlay, vec![]);

View file

@ -578,7 +578,7 @@ pub(super) fn dispatch_open_supergrok_url(app: &mut AppView) -> Vec<Effect> {
// being correctly configured. If the URL already specifies a
// referrer it's left alone.
let url = crate::app::link_opener::ensure_query_param(url, "referrer", "grok-build");
crate::app::link_opener::open_url(&url);
super::ctx::open_url_or_show(app, &url);
vec![]
}

View file

@ -34,6 +34,31 @@ pub(super) fn with_active_agent(app: &mut AppView, f: impl FnOnce(&mut AgentView
}
}
/// Open `url` via the system browser, falling back to a visible URL when the
/// browser cannot open (headless VM / missing opener). Prefer this over raw
/// `open_url_if_safe` for user-initiated billing/upgrade CTAs.
///
/// When no agent is active (welcome/gate screen), still attempts the open and
/// falls back to clipboard + toast.
pub(super) fn open_url_or_show(app: &mut AppView, url: &str) {
if let Some(agent) = get_active_agent_mut(app) {
agent.open_url_or_show(url);
return;
}
use crate::app::link_opener::{OpenUrlResult, browser_unavailable_message, try_open_url};
use crate::terminal::hyperlinks::SchemeFilter;
match try_open_url(url, SchemeFilter::Standard) {
OpenUrlResult::Opened | OpenUrlResult::RejectedScheme => {}
OpenUrlResult::BrowserUnavailable => {
let _ = crate::clipboard::SystemClipboard::try_set(url);
// No scrollback on the welcome screen — toast carries the URL.
app.show_toast(&browser_unavailable_message(url));
}
}
}
/// Get a shared reference to the active agent view (if any).
pub(super) fn get_active_agent(app: &AppView) -> Option<&AgentView> {
if let ActiveView::Agent(id) = app.active_view

View file

@ -5,7 +5,7 @@ use super::auth::{
};
use super::billing::dispatch_open_supergrok_url;
use super::ctx::{
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection, open_url_or_show,
sync_sleep_inhibitor, with_active_agent, with_scrollback,
};
use super::dashboard::{
@ -583,10 +583,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
surface: xai_grok_telemetry::events::CreditLimitUpsellSurface::InlineCard,
choice,
});
crate::app::link_opener::open_url_if_safe(
&url,
crate::terminal::hyperlinks::SchemeFilter::Standard,
);
open_url_or_show(app, &url);
} else {
dispatch_open_block_viewer(app);
}
@ -849,16 +846,17 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
}
}
Action::AnnouncementsOpenCta(surface) => {
use crate::terminal::hyperlinks::SchemeFilter;
if let Some((promo, url)) = crate::views::announcements::promo_cta_target(
&app.active_announcements,
&app.hidden_announcement_ids,
) {
let url = url.to_owned();
let promo_id = promo.id.clone();
log_event(xai_grok_telemetry::events::AnnouncementCtaClicked {
id: promo.id.clone(),
id: promo_id,
source: surface,
});
crate::app::link_opener::open_url_if_safe(url, SchemeFilter::Standard);
open_url_or_show(app, &url);
}
vec![]
}
@ -962,7 +960,6 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::CheckSubscription => vec![Effect::CheckSubscription { verify: None }],
Action::OpenSupergrokUrl => dispatch_open_supergrok_url(app),
Action::OpenUrl(url) => {
use crate::terminal::hyperlinks::SchemeFilter;
if url.starts_with("file://") {
let opened = url::Url::parse(&url)
.ok()
@ -974,14 +971,31 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
"Could not open file"
});
} else {
crate::app::link_opener::open_url_if_safe(&url, SchemeFilter::Standard);
open_url_or_show(app, &url);
}
vec![]
}
Action::OpenLink(target) => {
use crate::render::osc8::LinkTarget;
match crate::render::osc8::resolve_link_open_target(&target) {
Some(LinkTarget::File(path)) => {
let opened = crate::app::link_opener::open_path(&path);
app.show_toast(if opened {
"Opening in default app\u{2026}"
} else {
"Could not open file"
});
}
Some(LinkTarget::Url(url)) => {
crate::app::link_opener::open_url(&url);
}
None => {}
}
vec![]
}
Action::OpenManagedConnectors => {
use crate::terminal::hyperlinks::SchemeFilter;
let url = crate::views::mcps_modal::managed_connectors_url(app.team_id.as_deref());
crate::app::link_opener::open_url_if_safe(&url, SchemeFilter::Standard);
open_url_or_show(app, &url);
vec![]
}
Action::OpenNextLink => {

View file

@ -1058,3 +1058,135 @@ fn unknown_non_restricted_command_still_passes_through() {
"no upsell for genuinely unknown commands"
);
}
// ── Browser-unavailable URL fallback ────────────────────────────────
/// When the OS browser opener cannot run (simulated via a broken
/// `GROK_TEST_OPEN_URL_FILE` seam), `Action::OpenUrl` for a billing CTA
/// must push a scrollback system message that includes the full URL —
/// the headless-VM fix for silent Upgrade / Buy-more-credits no-ops.
#[serial_test::serial(GROK_TEST_OPEN_URL_FILE)]
#[test]
fn open_url_shows_manual_url_when_browser_unavailable() {
// Point the test seam at a path whose parent dir does not exist so the
// write fails and `open_url` returns false (BrowserUnavailable).
let bad = std::env::temp_dir().join(format!(
"grok-open-url-missing-{}/out.txt",
std::process::id()
));
// SAFETY: serialized via `serial_test` so no other test races the env var.
unsafe { std::env::set_var("GROK_TEST_OPEN_URL_FILE", &bad) };
let mut app = test_app_with_agent();
let before = agent_scrollback_len(&app);
let url = UPSELL_URL_UPGRADE;
let effects = dispatch(Action::OpenUrl(url.to_string()), &mut app);
assert!(effects.is_empty());
assert_eq!(
agent_scrollback_len(&app),
before + 1,
"must push a system message with the URL"
);
let text = last_system_text(&app, AgentId(0));
assert!(
text.contains("Could not open a browser"),
"fallback copy missing: {text}"
);
assert!(
text.contains(url),
"full billing URL must be visible for copy: {text}"
);
let toast = app.agents[&AgentId(0)]
.toast
.as_ref()
.map(|(m, _)| m.as_str());
assert_eq!(toast, Some("Browser unavailable - URL shown above"));
// SAFETY: serialized via `serial_test`; restore the env for other tests.
unsafe { std::env::remove_var("GROK_TEST_OPEN_URL_FILE") };
}
/// Successful open (test seam write OK) must not spam a fallback system message.
#[serial_test::serial(GROK_TEST_OPEN_URL_FILE)]
#[test]
fn open_url_does_not_show_fallback_when_opener_succeeds() {
let url_file =
std::env::temp_dir().join(format!("grok-open-url-ok-{}.txt", std::process::id()));
let _ = std::fs::remove_file(&url_file);
// SAFETY: serialized via `serial_test`.
unsafe { std::env::set_var("GROK_TEST_OPEN_URL_FILE", &url_file) };
let mut app = test_app_with_agent();
let before = agent_scrollback_len(&app);
let url = UPSELL_URL_PAYG;
let _ = dispatch(Action::OpenUrl(url.to_string()), &mut app);
assert_eq!(
agent_scrollback_len(&app),
before,
"successful open must not push a fallback system message"
);
let recorded = std::fs::read_to_string(&url_file).unwrap_or_default();
assert!(
recorded.lines().any(|l| l == url),
"opener seam must record the URL; got {recorded:?}"
);
// SAFETY: serialized via `serial_test`.
unsafe { std::env::remove_var("GROK_TEST_OPEN_URL_FILE") };
let _ = std::fs::remove_file(&url_file);
}
/// Credit-limit upsell Q&A submit routes through OpenUrl; when the browser
/// is unavailable the full option URL must land in scrollback.
#[serial_test::serial(GROK_TEST_OPEN_URL_FILE)]
#[test]
fn credit_limit_upsell_submit_shows_url_when_browser_unavailable() {
use crate::app::agent_view::translate_local_submit_for_test;
use crate::app::app_view::InputOutcome;
use crate::views::question_view::{LocalQuestionKind, QuestionSelection};
let bad = std::env::temp_dir().join(format!(
"grok-open-url-upsell-missing-{}/out.txt",
std::process::id()
));
// SAFETY: serialized via `serial_test`.
unsafe { std::env::set_var("GROK_TEST_OPEN_URL_FILE", &bad) };
let mut app = test_app_with_agent();
open_upsell_qa(&mut app, CreditLimitUpsellMode::UnifiedCredits);
let mut qv = app
.agents
.get_mut(&AgentId(0))
.unwrap()
.question_view
.take()
.expect("expected credit-limit upsell modal");
// Select option 1 = "Buy more credits" (credits / usage URL).
qv.selections[0] = QuestionSelection::Single(Some(1));
let kind = LocalQuestionKind::CreditLimitUpsell {
choices: vec![
xai_grok_telemetry::events::CreditLimitChoice::UpgradeTier,
xai_grok_telemetry::events::CreditLimitChoice::PurchaseCredits,
],
};
let InputOutcome::Action(Action::OpenUrl(url)) =
translate_local_submit_for_test(&qv, kind, false)
else {
panic!("expected OpenUrl from upsell submit");
};
assert_eq!(url, UPSELL_URL_PAYG);
let before = agent_scrollback_len(&app);
let _ = dispatch(Action::OpenUrl(url.clone()), &mut app);
let text = last_system_text(&app, AgentId(0));
assert_eq!(agent_scrollback_len(&app), before + 1);
assert!(
text.contains(&url),
"upsell URL missing from fallback: {text}"
);
// SAFETY: serialized via `serial_test`.
unsafe { std::env::remove_var("GROK_TEST_OPEN_URL_FILE") };
}

View file

@ -781,7 +781,13 @@ pub(crate) async fn run(
crate::notifications::load_notification_config(raw),
);
if let Some(table) = raw.as_table() {
app.voice_config = xai_grok_voice::VoiceConfig::from_config_table(table);
// Voice inherits the same resolved endpoints base as chat
// (config > GROK_XAI_API_BASE_URL env > default).
let endpoints_base =
xai_grok_shell::agent::config::EndpointsConfig::from_config_value(raw)
.xai_api_base_url;
app.voice_config =
xai_grok_voice::VoiceConfig::from_config_table(table, Some(&endpoints_base));
}
}
// Stamp request-identity headers so the STT handshake attributes voice usage

View file

@ -112,7 +112,7 @@ static MINIMAL_SHOW_SWITCH_BACK_TO_FULLSCREEN: AtomicBool = AtomicBool::new(fals
pub fn minimal_show_switch_back_to_fullscreen() -> bool {
MINIMAL_SHOW_SWITCH_BACK_TO_FULLSCREEN.load(Ordering::Acquire)
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn set_minimal_show_switch_back_to_fullscreen_for_test(on: bool) {
MINIMAL_SHOW_SWITCH_BACK_TO_FULLSCREEN.store(on, Ordering::Release);
}

View file

@ -370,7 +370,7 @@ impl AgentView {
&& let Some(link) = self.visible_link_map.link_at(mouse.column, mouse.row)
{
self.pending_link_click = app_should_open_link_on_click(link)
.then(|| (mouse.column, mouse.row, link.url.to_string()));
.then(|| (mouse.column, mouse.row, link.target.clone()));
self.pending_scrollback_click = None;
return InputOutcome::Changed;
}
@ -425,11 +425,9 @@ impl AgentView {
}
if let Some(id) = self.queue.send_now_click(mouse.column, mouse.row)
&& self.session.state.is_turn_running()
&& let InputOutcome::Action(action) = self.force_interject_queue_row(id)
{
if let InputOutcome::Action(action) = self.force_interject_queue_row(id)
{
return InputOutcome::Action(action);
}
return InputOutcome::Action(action);
}
self.set_active_pane(AgentPane::Queue, false);
self.queue.handle_mouse(
@ -618,7 +616,7 @@ impl AgentView {
self.visible_link_map.link_at(mouse.column, mouse.row)
{
self.pending_link_click = app_should_open_link_on_click(link)
.then(|| (mouse.column, mouse.row, link.url.to_string()));
.then(|| (mouse.column, mouse.row, link.target.clone()));
self.pending_scrollback_click = None;
return InputOutcome::Changed;
}
@ -697,11 +695,11 @@ impl AgentView {
}
let had_pending_text_drag = self.pending_text_drag.take().is_some();
let _had_pending_block_drag = self.pending_block_drag.take().is_some();
if let Some((lc, lr, url)) = self.pending_link_click.take()
if let Some((lc, lr, target)) = self.pending_link_click.take()
&& mouse.column == lc
&& mouse.row == lr
{
return InputOutcome::Action(Action::OpenUrl(url));
return InputOutcome::Action(Action::OpenLink(target));
}
if self.active_pane == AgentPane::Scrollback {
if let Some((click_col, click_row)) = self.pending_scrollback_click.take() {
@ -793,10 +791,7 @@ impl AgentView {
surface: xai_grok_telemetry::events::CreditLimitUpsellSurface::InlineCard,
choice,
});
crate::app::link_opener::open_url_if_safe(
&url,
crate::terminal::hyperlinks::SchemeFilter::Standard,
);
self.open_url_or_show(&url);
self.last_click = None;
return InputOutcome::Changed;
}

View file

@ -13,7 +13,7 @@ use clap::ValueEnum;
use tokio_util::sync::CancellationToken;
use agent_client_protocol as acp;
use xai_acp_lib::{AcpAgentTx, AcpClientMessageBox, acp_send};
use xai_acp_lib::{AcpAgentTx, AcpClientMessageBox, AcpClientRx, acp_send};
use xai_grok_shell::agent::auth_method::AuthMethodKind;
use xai_grok_shell::agent::config::Config as AgentConfig;
use xai_grok_shell::extensions::task::{CancelSubagentRequest, KillTaskRequest};
@ -1223,19 +1223,18 @@ pub async fn run_single_turn(
prompt_result = Some(res);
prompt_done_at = Some(Instant::now());
if !options.wait_for_background {
// Drain notifications already queued ahead of the response.
while let Ok(msg) = acp_rx.try_recv() {
handle_headless_acp_message(
msg.boxed(),
&mut emitter,
t_prompt,
&mut ttf_logged,
options.yolo,
options.output_format,
&mut pending_bg,
&mut completed_before_bg,
);
}
drain_acp_with_grace(
&mut acp_rx,
Duration::from_millis(750),
&mut emitter,
t_prompt,
&mut ttf_logged,
options.yolo,
options.output_format,
&mut pending_bg,
&mut completed_before_bg,
)
.await;
break;
}
// With wait_for_background: keep draining ACP for task_completed.
@ -1461,6 +1460,58 @@ fn track_background_lifecycle(
// ── ACP client message handling (select arm + pre-exit drain) ────────────
#[allow(clippy::too_many_arguments)]
async fn drain_acp_with_grace(
acp_rx: &mut AcpClientRx,
grace: Duration,
emitter: &mut HeadlessEmitter,
t_prompt: Instant,
ttf_logged: &mut bool,
yolo: bool,
output_format: OutputFormat,
pending_bg: &mut HashSet<String>,
completed_before_bg: &mut HashSet<String>,
) {
let deadline = Instant::now() + grace;
loop {
while let Ok(msg) = acp_rx.try_recv() {
handle_headless_acp_message(
msg.boxed(),
emitter,
t_prompt,
ttf_logged,
yolo,
output_format,
pending_bg,
completed_before_bg,
);
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
tokio::select! {
biased;
msg = acp_rx.recv() => {
let Some(msg) = msg else { break; };
handle_headless_acp_message(
msg.boxed(),
emitter,
t_prompt,
ttf_logged,
yolo,
output_format,
pending_bg,
completed_before_bg,
);
}
_ = tokio::time::sleep(remaining) => {
break;
}
}
}
}
/// Process one inbound ACP client message. Used by both `acp_rx.recv()` and
/// `try_recv()` so buffered `task_backgrounded` is not dropped when
/// `PromptResponse` completes first.

View file

@ -32,7 +32,7 @@ use ratatui::style::Color;
use crate::acp::tracker::TurnActivity;
// Only the test-only setters below reference `AgentSession`.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
use crate::app::agent::AgentSession;
use crate::app::agent_view::{AgentView, McpInitProgress};
use crate::app::app_view::{ActiveView, AppView, SessionPickerEntry};
@ -581,49 +581,49 @@ pub fn record_committed_for_expand(sb: &mut ScrollbackState, id: EntryId) {
// ── Test-only surface (minimal's unit tests, via the test-only helpers) ──
/// [`crate::app::agent_view::test_agent_view`].
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn test_agent_view(session_id: Option<&str>, cwd: std::path::PathBuf) -> AgentView {
crate::app::agent_view::test_agent_view(session_id, cwd)
}
/// Test-only setter for `AgentView::extensions_modal`.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn set_extensions_modal(v: &mut AgentView, val: Option<ExtensionsModalState>) {
v.extensions_modal = val;
}
/// Test-only setter for `AgentView::question_view`.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn set_question_view(v: &mut AgentView, val: Option<QuestionViewState>) {
v.question_view = val;
}
/// Test-only setter for `AgentView::plan_mode_active`.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn set_plan_mode_active(v: &mut AgentView, on: bool) {
v.plan_mode_active = on;
}
/// Test-only setter for `AgentView::plan_mode_pending`.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn set_plan_mode_pending(v: &mut AgentView, val: Option<bool>) {
v.plan_mode_pending = val;
}
/// Test-only mutable access to `PromptWidget::suggestions`.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn prompt_suggestions_mut(pw: &mut PromptWidget) -> &mut SuggestionController {
&mut pw.suggestions
}
/// Test-only setter for `AgentSession`'s yolo mode.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn set_yolo_mode_for_test(session: &mut AgentSession, on: bool) {
session.set_yolo_mode_for_test(on);
}
/// Test-only setter for `AgentSession`'s auto mode.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn set_auto_mode_for_test(session: &mut AgentSession, on: bool) {
session.set_auto_mode_for_test(on);
}
@ -632,7 +632,7 @@ pub fn set_auto_mode_for_test(session: &mut AgentSession, on: bool) {
/// toggle. Thinking blocks render zero rows when this is off (the default), so
/// minimal's commit-height tests must force it on to exercise a thinking
/// block's committed height instead of getting an order-dependent 0.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn set_show_thinking_blocks(enabled: bool) {
crate::appearance::cache::set_show_thinking_blocks(enabled);
}

View file

@ -938,9 +938,8 @@ impl EditToolCallBlock {
Line::from(spans)
}
/// Absolute `file://` for OSC8 regardless of painted path surface.
fn path_link_url(&self, cwd: Option<&Path>) -> Option<Arc<str>> {
crate::render::osc8::tool_path_file_url(&self.path, cwd)
fn path_link_target(&self, cwd: Option<&Path>) -> Option<crate::render::osc8::LinkTarget> {
crate::render::osc8::tool_path_file_target(&self.path, cwd)
}
/// Render this block's hunks for its current highlight phase — the single
@ -1152,7 +1151,7 @@ impl EditToolCallBlock {
edit_cfg.effective_line_summary(crate::appearance::cache::load_collapsed_edit_blocks());
let cwd = ctx.cwd.as_deref();
let link_url = self.path_link_url(cwd);
let link_target = self.path_link_target(cwd);
match ctx.mode {
DisplayMode::Collapsed => {
@ -1179,7 +1178,7 @@ impl EditToolCallBlock {
selection_range: Some(TOOL_HEADER_RANGE),
// Copy the painted path span (basename when collapsed).
content: line,
link_url,
link_target,
..Default::default()
}],
})
@ -1232,7 +1231,7 @@ impl EditToolCallBlock {
selection_text: line.selection_text,
joiner: line.joiner,
content: line.content,
link_url: if has_path { link_url.clone() } else { None },
link_target: if has_path { link_target.clone() } else { None },
..Default::default()
});
}
@ -1656,13 +1655,23 @@ mod tests {
}
#[test]
fn header_link_url_is_absolute_file_url_for_all_surfaces() {
fn header_link_target_is_absolute_file_for_all_surfaces() {
let abs = "/Users/me/project/src/foo.rs";
let cwd = Path::new("/Users/me/project");
let block = EditToolCallBlock::new(abs, vec![]);
let url = block.path_link_url(Some(cwd)).expect("file url");
assert!(url.starts_with("file://"), "got {url}");
assert!(url.contains("foo.rs"), "got {url}");
let target = block.path_link_target(Some(cwd)).expect("file target");
assert_eq!(
target,
crate::render::osc8::LinkTarget::File(Arc::from(Path::new(abs)))
);
assert_eq!(
crate::render::osc8::resolve_link_target(&target)
.unwrap()
.osc8_url
.unwrap()
.as_ref(),
"file:///Users/me/project/src/foo.rs"
);
let mut ctx = test_ctx();
ctx.cwd = Some(cwd.to_path_buf());
@ -1672,7 +1681,7 @@ mod tests {
collapsed.lines[0].content.spans[1].content.as_ref(),
"foo.rs"
);
assert_eq!(collapsed.lines[0].link_url.as_deref(), Some(url.as_ref()));
assert_eq!(collapsed.lines[0].link_target.as_ref(), Some(&target));
ctx.mode = DisplayMode::Expanded;
let expanded = block.output(&ctx);
@ -1680,7 +1689,7 @@ mod tests {
expanded.lines[0].content.spans[1].content.as_ref(),
"src/foo.rs"
);
assert_eq!(expanded.lines[0].link_url.as_deref(), Some(url.as_ref()));
assert_eq!(expanded.lines[0].link_target.as_ref(), Some(&target));
}
#[test]

View file

@ -233,19 +233,19 @@ impl ReadToolCallBlock {
///
/// Spans: `["Read ", path, optional_range_suffix, optional_extra_suffix]`
/// or `["Skill ", skill_name]`. Prefix/suffixes excluded (no `selection_text`
/// override). Sets absolute `file://` `link_url` for non-skill paths.
/// override). Attaches a semantic filesystem target for non-skill paths.
fn header_block_line(&self, line: Line<'static>, cwd: Option<&std::path::Path>) -> BlockLine {
let path_end = 2.min(line.spans.len()).max(1);
let link_url = if self.skill_name().is_some() {
let link_target = if self.skill_name().is_some() {
None
} else {
crate::render::osc8::tool_path_file_url(&self.path, cwd)
crate::render::osc8::tool_path_file_target(&self.path, cwd)
};
BlockLine {
selectable: Selectable::Spans(1..path_end),
selection_range: Some(TOOL_HEADER_RANGE),
content: line,
link_url,
link_target,
..Default::default()
}
}
@ -615,16 +615,31 @@ mod tests {
}
#[test]
fn header_link_url_is_absolute_for_collapsed_and_expanded() {
fn header_link_target_is_absolute_file_for_collapsed_and_expanded() {
let abs = "/Users/me/project/src/main.rs";
let block = ReadToolCallBlock::new(abs);
let mut ctx = make_ctx();
ctx.cwd = Some(std::path::PathBuf::from("/Users/me/project"));
let collapsed = block.output(&ctx);
let url = collapsed.lines[0].link_url.as_ref().expect("link_url");
assert!(url.starts_with("file://"), "got {url}");
assert!(url.contains("main.rs"), "got {url}");
let target = collapsed.lines[0]
.link_target
.as_ref()
.expect("link target");
assert_eq!(
target,
&crate::render::osc8::LinkTarget::File(
std::sync::Arc::from(std::path::Path::new(abs),)
)
);
assert_eq!(
crate::render::osc8::resolve_link_target(target)
.unwrap()
.osc8_url
.unwrap()
.as_ref(),
"file:///Users/me/project/src/main.rs"
);
assert_eq!(
collapsed.lines[0].content.spans[1].content.as_ref(),
"main.rs"
@ -636,7 +651,7 @@ mod tests {
expanded.lines[0].content.spans[1].content.as_ref(),
"src/main.rs"
);
assert_eq!(expanded.lines[0].link_url.as_deref(), Some(url.as_ref()));
assert_eq!(expanded.lines[0].link_target.as_ref(), Some(target));
}
#[test]

View file

@ -5,9 +5,8 @@
//! tool blocks. Used by the mouse handler for click-to-open.
use ratatui::layout::Rect;
use std::sync::Arc;
use crate::render::osc8::LinkOverlay;
use crate::render::osc8::{LinkOverlay, LinkTarget};
/// A clickable link region on screen.
///
@ -16,7 +15,7 @@ use crate::render::osc8::LinkOverlay;
#[derive(Debug, Clone)]
pub struct VisibleLink {
pub rects: Vec<Rect>,
pub url: Arc<str>,
pub target: LinkTarget,
pub id: Option<u32>,
}
@ -32,8 +31,11 @@ impl VisibleLink {
/// True when painted cell width equals the URL's display width (bare URL
/// text on screen, not a short label or wide citation block).
pub fn looks_like_bare_url_text(&self) -> bool {
let LinkTarget::Url(url) = &self.target else {
return false;
};
let painted: usize = self.rects.iter().map(|r| usize::from(r.width)).sum();
painted == unicode_width::UnicodeWidthStr::width(self.url.as_ref())
painted == unicode_width::UnicodeWidthStr::width(url.as_ref())
}
}
@ -65,13 +67,37 @@ impl VisibleLinkMap {
generation: u64,
overlay: &LinkOverlay,
citation_links: Vec<VisibleLink>,
) {
self.rebuild_for_context(
generation,
overlay,
citation_links,
crate::terminal::terminal_context(),
);
}
fn rebuild_for_context(
&mut self,
generation: u64,
overlay: &LinkOverlay,
citation_links: Vec<VisibleLink>,
terminal: &crate::terminal::TerminalContext,
) {
self.links.clear();
self.generation = generation;
self.links
.reserve(overlay.links().len() + citation_links.len());
self.push_overlay_links(overlay, /* merge_from */ 0);
self.links.extend(citation_links);
self.push_overlay_links(overlay, /* merge_from */ 0, terminal);
self.links
.extend(citation_links.into_iter().filter_map(|mut link| {
link.target = crate::render::osc8::resolve_link_target_for_context(
&link.target,
crate::render::osc8::LinkPresentation::Opaque,
terminal,
)?
.open_target?;
Some(link)
}));
}
/// Append overlay links (e.g. `/btw`) without changing generation.
@ -88,14 +114,27 @@ impl VisibleLinkMap {
/// each frame's links will accumulate.
pub fn append_from_overlay(&mut self, overlay: &LinkOverlay) {
let start_len = self.links.len();
self.push_overlay_links(overlay, start_len);
self.push_overlay_links(overlay, start_len, crate::terminal::terminal_context());
}
/// Push overlay segments, merging same-`id` only with entries at
/// indices `>= merge_from` (0 for rebuild; map length for append).
fn push_overlay_links(&mut self, overlay: &LinkOverlay, merge_from: usize) {
fn push_overlay_links(
&mut self,
overlay: &LinkOverlay,
merge_from: usize,
terminal: &crate::terminal::TerminalContext,
) {
self.links.reserve(overlay.links().len());
for link in overlay.links() {
let Some(target) = crate::render::osc8::resolve_link_target_for_context(
&link.target,
link.presentation,
terminal,
)
.and_then(|resolved| resolved.open_target) else {
continue;
};
let width = link.col_end.saturating_sub(link.col_start);
if width == 0 {
continue;
@ -110,7 +149,7 @@ impl VisibleLinkMap {
} else {
self.links.push(VisibleLink {
rects: vec![rect],
url: Arc::clone(&link.url),
target,
id: link.id,
});
}
@ -144,7 +183,8 @@ impl VisibleLinkMap {
#[cfg(test)]
mod tests {
use super::*;
use crate::render::osc8::{LinkOverlay, OverlayLink};
use crate::render::osc8::{LinkOverlay, LinkPresentation, OverlayLink, resolve_link_target};
use crate::terminal::{TerminalContext, TerminalName};
use std::sync::Arc;
fn make_overlay(links: Vec<(u16, u16, u16, &str, Option<u32>)>) -> LinkOverlay {
@ -154,7 +194,8 @@ mod tests {
screen_row: row,
col_start,
col_end,
url: Arc::from(url),
target: LinkTarget::Url(Arc::from(url)),
presentation: LinkPresentation::Opaque,
id,
});
}
@ -168,7 +209,7 @@ mod tests {
.enumerate()
.map(|(i, w)| Rect::new(0, i as u16, *w, 1))
.collect(),
url: Arc::from(url),
target: LinkTarget::Url(Arc::from(url)),
id: None,
}
}
@ -193,6 +234,120 @@ mod tests {
assert!(!link(url, &[url_w.saturating_add(40)]).looks_like_bare_url_text());
}
#[test]
fn file_target_provenance_survives_overlay_to_visible_map() {
let path = Arc::<std::path::Path>::from(std::path::Path::new(
"/tmp/non-display-target/file name.rs",
));
let mut overlay = LinkOverlay::new();
overlay.push(OverlayLink {
screen_row: 3,
col_start: 4,
col_end: 10,
target: LinkTarget::File(Arc::clone(&path)),
presentation: crate::render::osc8::LinkPresentation::Opaque,
id: None,
});
let mut map = VisibleLinkMap::default();
map.rebuild(1, &overlay, vec![]);
assert_eq!(map.links()[0].target, LinkTarget::File(Arc::clone(&path)));
let resolved = resolve_link_target(&map.links()[0].target).expect("resolved file target");
assert_eq!(resolved.open_target, Some(LinkTarget::File(path)));
assert_eq!(
resolved.osc8_url.unwrap().as_ref(),
"file:///tmp/non-display-target/file%20name.rs"
);
assert!(!map.links()[0].looks_like_bare_url_text());
}
#[test]
fn official_vscode_remote_file_is_excluded_from_activation_map() {
let file = LinkTarget::File(Arc::from(std::path::Path::new("/worktree/src/main.rs")));
let web = LinkTarget::Url(Arc::from("https://example.com/docs"));
let mut overlay = LinkOverlay::new();
for (row, target, presentation) in [
(3, file, LinkPresentation::SelfResolvingPath),
(4, web.clone(), LinkPresentation::Opaque),
] {
overlay.push(OverlayLink {
screen_row: row,
col_start: 4,
col_end: 20,
target,
presentation,
id: None,
});
}
let terminal = TerminalContext {
brand: TerminalName::VsCode,
is_ssh: true,
is_official_vscode_remote: true,
..Default::default()
};
let mut map = VisibleLinkMap::default();
map.rebuild_for_context(1, &overlay, vec![], &terminal);
assert_eq!(map.links().len(), 1);
assert_eq!(map.links()[0].target, web);
assert!(map.link_at(5, 3).is_none());
assert!(map.link_at(5, 4).is_some());
}
#[test]
fn cwd_change_stales_map_before_presentation_ownership_flip() {
let target = LinkTarget::File(Arc::from(std::path::Path::new("/worktree/src/main.rs")));
let painted = "src/main.rs";
let mut state = crate::scrollback::ScrollbackState::new();
let terminal = TerminalContext {
brand: TerminalName::VsCode,
is_ssh: true,
is_official_vscode_remote: true,
..Default::default()
};
let overlay_for = |cwd: Option<&std::path::Path>| {
let mut overlay = LinkOverlay::new();
overlay.push(OverlayLink {
screen_row: 3,
col_start: 4,
col_end: 15,
target: target.clone(),
presentation: crate::render::osc8::file_link_presentation(painted, &target, cwd),
id: None,
});
overlay
};
state.set_cwd(Some(std::path::PathBuf::from("/other")));
let mut map = VisibleLinkMap::default();
map.rebuild_for_context(
state.generation(),
&overlay_for(state.cwd()),
vec![],
&terminal,
);
assert_eq!(map.len(), 1, "opaque relative paint stays Grok-owned");
assert!(!map.is_stale(state.generation()));
let new_cwd = std::path::PathBuf::from("/worktree");
state.set_cwd(Some(new_cwd.clone()));
assert!(map.is_stale(state.generation()));
map.rebuild_for_context(
state.generation(),
&overlay_for(state.cwd()),
vec![],
&terminal,
);
assert!(map.is_empty(), "self-resolving paint delegates to VS Code");
let generation = state.generation();
state.set_cwd(Some(new_cwd));
assert_eq!(state.generation(), generation);
assert!(!map.is_stale(state.generation()));
}
#[test]
fn link_at_hit_and_miss() {
let mut map = VisibleLinkMap::default();
@ -203,7 +358,13 @@ mod tests {
// Hit inside the link
let hit = map.link_at(15, 5);
assert!(hit.is_some());
assert_eq!(&*hit.unwrap().url, "https://example.com");
assert_eq!(
&*resolve_link_target(&hit.unwrap().target)
.unwrap()
.osc8_url
.unwrap(),
"https://example.com"
);
// Miss: wrong row
assert!(map.link_at(15, 6).is_none());
// Miss: before start col
@ -240,7 +401,13 @@ mod tests {
]);
map.rebuild(2, &overlay2, vec![]);
assert_eq!(map.links().len(), 2);
assert_eq!(&*map.links()[0].url, "https://second.com");
assert_eq!(
&*resolve_link_target(&map.links()[0].target)
.unwrap()
.osc8_url
.unwrap(),
"https://second.com"
);
}
#[test]
@ -252,7 +419,13 @@ mod tests {
]);
map.rebuild(1, &overlay, vec![]);
assert_eq!(map.links().len(), 1);
assert_eq!(&*map.links()[0].url, "https://valid.com");
assert_eq!(
&*resolve_link_target(&map.links()[0].target)
.unwrap()
.osc8_url
.unwrap(),
"https://valid.com"
);
}
#[test]
@ -261,7 +434,7 @@ mod tests {
let overlay = make_overlay(vec![(0, 0, 5, "https://md-link.com", Some(1))]);
let citations = vec![VisibleLink {
rects: vec![Rect::new(2, 10, 30, 1)],
url: Arc::from("https://citation.com"),
target: LinkTarget::Url(Arc::from("https://citation.com")),
id: None,
}];
map.rebuild(1, &overlay, citations);
@ -270,12 +443,24 @@ mod tests {
// Markdown link
let hit = map.link_at(3, 0);
assert!(hit.is_some());
assert_eq!(&*hit.unwrap().url, "https://md-link.com");
assert_eq!(
&*resolve_link_target(&hit.unwrap().target)
.unwrap()
.osc8_url
.unwrap(),
"https://md-link.com"
);
// Citation link
let hit = map.link_at(15, 10);
assert!(hit.is_some());
assert_eq!(&*hit.unwrap().url, "https://citation.com");
assert_eq!(
&*resolve_link_target(&hit.unwrap().target)
.unwrap()
.osc8_url
.unwrap(),
"https://citation.com"
);
}
#[test]
@ -291,7 +476,13 @@ mod tests {
// Position 5 is in both links; first match wins (iter order)
let hit = map.link_at(5, 5);
assert!(hit.is_some());
assert_eq!(&*hit.unwrap().url, "https://first.com");
assert_eq!(
&*resolve_link_target(&hit.unwrap().target)
.unwrap()
.osc8_url
.unwrap(),
"https://first.com"
);
}
#[test]
@ -316,7 +507,13 @@ mod tests {
// Should be 1 logical link with 2 rects
assert_eq!(map.links().len(), 1);
assert_eq!(map.links()[0].rects.len(), 2);
assert_eq!(&*map.links()[0].url, "https://wrapped.com");
assert_eq!(
&*resolve_link_target(&map.links()[0].target)
.unwrap()
.osc8_url
.unwrap(),
"https://wrapped.com"
);
// Hit on first row segment
assert!(map.link_at(15, 3).is_some());
@ -364,8 +561,20 @@ mod tests {
2,
"colliding per-doc ids must not merge across append"
);
assert_eq!(&*map.link_at(5, 0).unwrap().url, "https://scrollback.com");
assert_eq!(&*map.link_at(5, 5).unwrap().url, "https://btw.com");
assert_eq!(
&*resolve_link_target(&map.link_at(5, 0).unwrap().target)
.unwrap()
.osc8_url
.unwrap(),
"https://scrollback.com"
);
assert_eq!(
&*resolve_link_target(&map.link_at(5, 5).unwrap().target)
.unwrap()
.osc8_url
.unwrap(),
"https://btw.com"
);
}
#[test]
@ -397,6 +606,12 @@ mod tests {
map.append_from_overlay(&make_overlay(vec![(2, 0, 5, "https://new-btw.com", None)]));
assert_eq!(map.len(), 2);
assert!(map.link_at(1, 1).is_none());
assert_eq!(&*map.link_at(1, 2).unwrap().url, "https://new-btw.com");
assert_eq!(
&*resolve_link_target(&map.link_at(1, 2).unwrap().target)
.unwrap()
.osc8_url
.unwrap(),
"https://new-btw.com"
);
}
}

View file

@ -629,9 +629,8 @@ pub(crate) fn render_scrolled_entries_with_selection_boundaries(
}
});
// Tool-header link_url overlays before plain-text scan (basename/relative
// paint still needs absolute file://). Hit box = selectable path span
// (respects bullet prepend + Selectable shift).
// Basename/relative tool headers need the stored absolute target.
// Hit box = selectable path span (respects bullet prepend + Selectable shift).
{
for (idx, bl) in cached_output.lines.iter().enumerate().skip(content_skip) {
let visible_offset = (idx - content_skip) as u16;
@ -639,7 +638,7 @@ pub(crate) fn render_scrolled_entries_with_selection_boundaries(
if screen_row >= max_y {
break;
}
let Some(url) = bl.link_url.as_ref() else {
let Some(target) = bl.link_target.as_ref() else {
continue;
};
let Some(cols) = selectable_cols_usize(&bl.content, &bl.selectable) else {
@ -664,11 +663,18 @@ pub(crate) fn render_scrolled_entries_with_selection_boundaries(
if result.link_overlay.overlaps(screen_row, col_start, col_end) {
continue;
}
let painted = derive_selection_text(bl);
let fully_visible = cols.end <= visible_width;
result.link_overlay.push(OverlayLink {
screen_row,
col_start,
col_end,
url: Arc::clone(url),
target: target.clone(),
presentation: if fully_visible {
crate::render::osc8::file_link_presentation(&painted, target, cwd)
} else {
crate::render::osc8::LinkPresentation::Opaque
},
id: None,
});
}
@ -683,7 +689,7 @@ pub(crate) fn render_scrolled_entries_with_selection_boundaries(
.iter()
.enumerate()
.skip(content_skip)
.filter(|(_, bl)| bl.link_url.is_none())
.filter(|(_, bl)| bl.link_target.is_none())
.map(|(idx, bl)| {
let visible_offset = (idx - content_skip) as u16;
let screen_row = first_visible_content_y + visible_offset;
@ -942,18 +948,16 @@ pub(crate) fn map_hyperlinks_to_overlay(
// Map each hyperlink to screen-space OverlayLinks. Unsafe schemes
// (javascript:, data:, …) are dropped since OSC 8 URLs reach the terminal
// without the link_opener scheme filter. A non-web destination may still be
// a local file the model linked (`[videos/1.mp4](videos/1.mp4)`): resolve it
// to a `file://` URL (relative paths matched against this transcript's
// generated media) so it opens like an absolute path.
// without the link_opener scheme filter. Local-file destinations such as
// `[videos/1.mp4](videos/1.mp4)` resolve against generated media.
let scheme_filter = crate::terminal::hyperlinks::SchemeFilter::Standard;
for h in hyperlinks {
let url: Arc<str> = if crate::app::link_opener::is_safe_to_open(&h.url, scheme_filter) {
Arc::from(h.url.as_str())
} else if let Some(file_url) =
crate::render::osc8::local_link_to_file_url(&h.url, media_paths)
let target = if crate::app::link_opener::is_safe_to_open(&h.url, scheme_filter) {
crate::render::osc8::LinkTarget::Url(Arc::from(h.url.as_str()))
} else if let Some(file_target) =
crate::render::osc8::local_link_to_file_target(&h.url, media_paths)
{
file_url
file_target
} else {
continue;
};
@ -987,7 +991,8 @@ pub(crate) fn map_hyperlinks_to_overlay(
screen_row,
col_start: content_x + local_col_start,
col_end: content_x + local_col_end,
url: Arc::clone(&url),
target: target.clone(),
presentation: crate::render::osc8::LinkPresentation::Opaque,
id: Some(h.id),
});
}
@ -998,6 +1003,9 @@ pub(crate) fn map_hyperlinks_to_overlay(
mod tests {
use super::*;
use crate::appearance::AppearanceConfig;
use crate::render::osc8::{
LinkPresentation, resolve_link_target, resolve_link_target_for_context,
};
use crate::scrollback::RenderBlock;
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::DisplayMode;
@ -1071,13 +1079,38 @@ mod tests {
scroll_offset: usize,
selected_idx: Option<usize>,
) -> ScrollRenderResult {
render_with_scratch_and_buffer(entries, viewport, scroll_offset, selected_idx).0
}
fn render_with_scratch_and_buffer(
entries: &[ScrollbackEntry],
viewport: Rect,
scroll_offset: usize,
selected_idx: Option<usize>,
) -> (ScrollRenderResult, Buffer) {
render_with_scratch_and_buffer_with_cwd(
entries,
viewport,
scroll_offset,
selected_idx,
None,
)
}
fn render_with_scratch_and_buffer_with_cwd(
entries: &[ScrollbackEntry],
viewport: Rect,
scroll_offset: usize,
selected_idx: Option<usize>,
cwd: Option<&std::path::Path>,
) -> (ScrollRenderResult, Buffer) {
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let layouts = compute_layouts(entries, viewport.width, &appearance);
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
let mut buf = Buffer::empty(viewport);
render_scrolled_entries_with_scratch(
let result = render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
@ -1094,8 +1127,9 @@ mod tests {
0,
&[],
None,
None,
)
cwd,
);
(result, buf)
}
fn render_with_selection_boundaries(
@ -2330,7 +2364,12 @@ mod tests {
assert_eq!(link.screen_row, 10);
assert_eq!(link.col_start, 4);
assert_eq!(link.col_end, 9);
assert_eq!(&*link.url, "https://a.com");
assert_eq!(
&*resolve_link_target(&link.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"https://a.com"
);
assert_eq!(link.id, Some(1));
}
@ -2374,11 +2413,15 @@ mod tests {
None,
);
let links: Vec<&str> = result
let links: Vec<Arc<str>> = result
.link_overlay
.links()
.iter()
.map(|l| &*l.url)
.map(|l| {
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url")
})
.collect();
assert!(
links
@ -2409,7 +2452,14 @@ mod tests {
assert_eq!(overlay.links()[1].screen_row, 1);
assert_eq!(overlay.links()[1].col_start, 0);
assert_eq!(overlay.links()[1].col_end, 5);
assert_eq!(&*overlay.links()[0].url, &*overlay.links()[1].url);
assert_eq!(
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
&*resolve_link_target(&overlay.links()[1].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url")
);
}
#[test]
@ -2425,7 +2475,12 @@ mod tests {
map_hyperlinks_to_overlay(&links, &output, 2, 0, 10, 0, 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 1);
assert_eq!(&*overlay.links()[0].url, "https://visible.com");
assert_eq!(
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"https://visible.com"
);
assert_eq!(overlay.links()[0].screen_row, 0);
}
@ -2441,7 +2496,12 @@ mod tests {
map_hyperlinks_to_overlay(&links, &output, 0, 0, 2, 0, 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 1);
assert_eq!(&*overlay.links()[0].url, "https://visible.com");
assert_eq!(
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"https://visible.com"
);
}
#[test]
@ -2521,14 +2581,19 @@ mod tests {
!result.link_overlay.is_empty(),
"execute block output should have linkified URLs"
);
let urls: Vec<&str> = result
let urls: Vec<Arc<str>> = result
.link_overlay
.links()
.iter()
.map(|l| &*l.url)
.map(|l| {
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url")
})
.collect();
assert!(
urls.contains(&"https://docs.example.com/api"),
urls.iter()
.any(|url| url.as_ref() == "https://docs.example.com/api"),
"expected URL from stdout in overlay, got: {:?}",
urls,
);
@ -2554,7 +2619,11 @@ mod tests {
.link_overlay
.links()
.iter()
.filter(|l| &*l.url == expected_url.as_str())
.filter(|l| {
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.is_some_and(|url| url.as_ref() == expected_url.as_str())
})
.collect();
assert!(
path_links.len() >= 2,
@ -2563,7 +2632,14 @@ mod tests {
.link_overlay
.links()
.iter()
.map(|l| (&*l.url, l.screen_row, l.col_start, l.col_end))
.map(|l| (
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
l.screen_row,
l.col_start,
l.col_end
))
.collect::<Vec<_>>()
);
// Regions land on consecutive distinct rows.
@ -2589,7 +2665,11 @@ mod tests {
.link_overlay
.links()
.iter()
.filter(|l| &*l.url == "https://example.com")
.filter(|l| {
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.is_some_and(|url| url.as_ref() == "https://example.com")
})
.count();
assert_eq!(
url_count, 1,
@ -2632,11 +2712,15 @@ mod tests {
let viewport = Rect::new(0, 0, 80, 10);
let result = render_with_scratch(&entries, viewport, 0, None);
let urls: Vec<&str> = result
let urls: Vec<Arc<str>> = result
.link_overlay
.links()
.iter()
.map(|l| &*l.url)
.map(|l| {
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url")
})
.collect();
assert!(
urls.iter()
@ -2720,7 +2804,17 @@ mod tests {
.link_overlay
.links()
.iter()
.map(|l| (l.screen_row, l.col_start, l.col_end, l.url.to_string()))
.map(|l| {
(
l.screen_row,
l.col_start,
l.col_end,
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url")
.to_string(),
)
})
.collect();
assert!(
links
@ -2811,7 +2905,15 @@ mod tests {
.link_overlay
.links()
.iter()
.map(|l| (l.screen_row, l.url.to_string()))
.map(|l| {
(
l.screen_row,
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url")
.to_string(),
)
})
.collect();
assert!(
links
@ -3072,7 +3174,11 @@ mod tests {
.link_overlay
.links()
.iter()
.filter(|l| l.url.contains("a1.rs"))
.filter(|l| {
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.is_some_and(|url| url.contains("a1.rs"))
})
.map(|l| l.screen_row)
.collect::<Vec<_>>()
};
@ -3386,18 +3492,24 @@ mod tests {
let viewport = Rect::new(0, 0, 80, 30);
let result = render_with_scratch(&entries, viewport, 0, None);
let urls: Vec<&str> = result
let urls: Vec<Arc<str>> = result
.link_overlay
.links()
.iter()
.map(|l| &*l.url)
.map(|l| {
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url")
})
.collect();
assert!(
urls.contains(&"https://head.example.com/first"),
urls.iter()
.any(|url| url.as_ref() == "https://head.example.com/first"),
"URL in head section should be detected, got: {urls:?}",
);
assert!(
urls.contains(&"https://tail.example.com/last"),
urls.iter()
.any(|url| url.as_ref() == "https://tail.example.com/last"),
"URL in tail section should be detected, got: {urls:?}",
);
// The ellipsis separator line should not produce spurious links.
@ -3411,7 +3523,7 @@ mod tests {
/// Collapsed Edit header: after bullet prepend the path is span 2, and the
/// OSC8 overlay must cover path cols only (not the verb or bullet).
#[test]
fn tool_header_link_url_overlay_covers_path_after_bullet() {
fn tool_header_link_target_overlay_covers_path_after_bullet() {
use crate::appearance::ToolBullet;
use crate::scrollback::types::{BlockContext, selectable_cols};
use unicode_width::UnicodeWidthStr;
@ -3443,9 +3555,11 @@ mod tests {
);
let path_span = header.content.spans[2].content.as_ref();
assert_eq!(path_span, "foo.rs");
let url = header.link_url.as_ref().expect("link_url on header");
assert!(url.starts_with("file://"), "got {url}");
assert!(url.contains("foo.rs"), "got {url}");
let target = header.link_target.as_ref().expect("link target on header");
assert_eq!(
target,
&crate::render::osc8::LinkTarget::File(Arc::from(std::path::Path::new(abs)))
);
let cols = selectable_cols(&header.content, &header.selectable)
.expect("path span should be selectable");
@ -3487,7 +3601,11 @@ mod tests {
.link_overlay
.links()
.iter()
.filter(|l| l.url.contains("foo.rs") && l.url.starts_with("file://"))
.filter(|l| {
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.is_some_and(|url| url.contains("foo.rs") && url.starts_with("file://"))
})
.collect();
assert_eq!(
file_links.len(),
@ -3508,6 +3626,202 @@ mod tests {
);
}
fn official_vscode_remote_context() -> crate::terminal::TerminalContext {
crate::terminal::TerminalContext {
brand: crate::terminal::TerminalName::VsCode,
is_ssh: true,
is_official_vscode_remote: true,
..Default::default()
}
}
fn file_link_policy(
link: &OverlayLink,
terminal: &crate::terminal::TerminalContext,
) -> crate::render::osc8::ResolvedLinkTarget {
resolve_link_target_for_context(&link.target, link.presentation, terminal)
.expect("file target policy")
}
#[test]
fn official_vscode_remote_delegates_scanned_absolute_path() {
let path = "/worktree/src/main.rs";
let entry = make_markdown_entry(path);
let viewport = Rect::new(0, 0, 80, 5);
let (result, buf) =
render_with_scratch_and_buffer(std::slice::from_ref(&entry), viewport, 0, None);
let link = result
.link_overlay
.links()
.iter()
.find(|link| matches!(&link.target, crate::render::osc8::LinkTarget::File(_)))
.expect("scanned file target");
assert!((0..viewport.height).any(|row| buffer_row_text(&buf, row).contains(path)));
assert_eq!(link.presentation, LinkPresentation::SelfResolvingPath);
assert_eq!(
file_link_policy(link, &official_vscode_remote_context()),
crate::render::osc8::ResolvedLinkTarget {
osc8_url: None,
open_target: None,
}
);
}
#[test]
fn official_vscode_remote_tool_headers_delegate_only_self_resolving_paint() {
let cwd = std::path::PathBuf::from("/worktree");
let target = "/worktree/src/nested/main.rs";
let terminal = official_vscode_remote_context();
for (name, block) in [
("Read", RenderBlock::read(target, None)),
("Edit", RenderBlock::edit(target, None)),
] {
for (mode, width, expected_paint, expected_presentation) in [
(
DisplayMode::Collapsed,
80,
"main.rs",
LinkPresentation::Opaque,
),
(
DisplayMode::Collapsed,
16,
"\u{2026}",
LinkPresentation::Opaque,
),
(
DisplayMode::Expanded,
80,
"src/nested/main.rs",
LinkPresentation::SelfResolvingPath,
),
] {
let mut entry = ScrollbackEntry::new(block.clone());
entry.display_mode = mode;
let viewport = Rect::new(0, 0, width, 8);
let (result, buf) = render_with_scratch_and_buffer_with_cwd(
std::slice::from_ref(&entry),
viewport,
0,
None,
Some(&cwd),
);
let path_links: Vec<_> = result
.link_overlay
.links()
.iter()
.filter(|link| matches!(&link.target, crate::render::osc8::LinkTarget::File(_)))
.collect();
let painted_rows = (0..viewport.height)
.map(|row| buffer_row_text(&buf, row).trim_end().to_owned())
.filter(|row| !row.is_empty())
.collect::<Vec<_>>();
assert!(
painted_rows.iter().any(|row| row.contains(expected_paint)),
"{name} {mode:?} width={width}: {painted_rows:?}"
);
assert!(!path_links.is_empty(), "{name} {mode:?} width={width}");
assert!(
path_links
.iter()
.all(|link| link.presentation == expected_presentation),
"{name} {mode:?} width={width}: {path_links:?}"
);
let expected_owned = expected_presentation == LinkPresentation::Opaque;
assert!(path_links.iter().all(|link| {
assert_eq!(
link.target,
crate::render::osc8::LinkTarget::File(Arc::from(std::path::Path::new(
target
)))
);
let policy = file_link_policy(link, &terminal);
policy.osc8_url.is_some() == expected_owned
&& policy.open_target.is_some() == expected_owned
}));
}
let mut entry = ScrollbackEntry::new(block);
entry.display_mode = DisplayMode::Expanded;
let viewport = Rect::new(0, 0, 16, 8);
let (result, _) = render_with_scratch_and_buffer_with_cwd(
std::slice::from_ref(&entry),
viewport,
0,
None,
Some(&cwd),
);
let path_links: Vec<_> = result
.link_overlay
.links()
.iter()
.filter(|link| matches!(&link.target, crate::render::osc8::LinkTarget::File(_)))
.collect();
assert!(!path_links.is_empty(), "{name} narrow expanded header");
assert!(
path_links
.iter()
.all(|link| link.presentation == LinkPresentation::Opaque)
);
assert!(path_links.iter().all(|link| {
let policy = file_link_policy(link, &terminal);
policy.osc8_url.is_some() && policy.open_target.is_some()
}));
}
}
#[test]
fn basename_headers_stay_grok_owned_for_duplicate_and_outside_targets() {
let cwd = std::path::PathBuf::from("/worktree");
let terminal = official_vscode_remote_context();
let cases = [
("duplicate-a", "/worktree/src/a/main.rs"),
("duplicate-b", "/worktree/src/b/main.rs"),
("outside", "/opt/service/main.rs"),
];
for (name, target) in cases {
for (tool, block) in [
("Read", RenderBlock::read(target, None)),
("Edit", RenderBlock::edit(target, None)),
] {
let entry = ScrollbackEntry::new(block);
let viewport = Rect::new(0, 0, 80, 5);
let (result, buf) = render_with_scratch_and_buffer_with_cwd(
std::slice::from_ref(&entry),
viewport,
0,
None,
Some(&cwd),
);
let link = result
.link_overlay
.links()
.iter()
.find(|link| matches!(&link.target, crate::render::osc8::LinkTarget::File(_)))
.unwrap_or_else(|| panic!("{tool} {name} file target"));
let painted = (0..viewport.height)
.map(|row| buffer_row_text(&buf, row).trim_end().to_owned())
.find(|row| row.contains("main.rs"))
.unwrap_or_else(|| panic!("{tool} {name} painted basename"));
assert!(!painted.contains('/'), "{tool} {name}: {painted}");
assert_eq!(
link.target,
crate::render::osc8::LinkTarget::File(Arc::from(std::path::Path::new(target))),
"{tool} {name} semantic target"
);
assert_eq!(link.presentation, LinkPresentation::Opaque, "{tool} {name}");
let policy = file_link_policy(link, &terminal);
assert!(policy.osc8_url.is_some(), "{tool} {name}");
assert!(policy.open_target.is_some(), "{tool} {name}");
}
}
}
#[test]
fn long_read_header_link_is_clipped_to_offset_content_area() {
let path = "/outside/a/very/long/path/that/is/clipped/main.rs";
@ -3520,7 +3834,11 @@ mod tests {
.link_overlay
.links()
.iter()
.find(|link| link.url.contains("main.rs"))
.find(|link| {
resolve_link_target(&link.target)
.and_then(|resolved| resolved.osc8_url)
.is_some_and(|url| url.contains("main.rs"))
})
.expect("read header file link");
let content =
HorizontalLayout::new(viewport, &AppearanceConfig::default().scrollback.layout).content;
@ -3540,7 +3858,11 @@ mod tests {
.link_overlay
.links()
.iter()
.find(|link| link.url.ends_with(".rs"))
.find(|link| {
resolve_link_target(&link.target)
.and_then(|resolved| resolved.osc8_url)
.is_some_and(|url| url.ends_with(".rs"))
})
.expect("long Read header file link");
let content =
HorizontalLayout::new(viewport, &AppearanceConfig::default().scrollback.layout).content;
@ -3595,11 +3917,11 @@ mod tests {
let result = render_with_scratch(std::slice::from_ref(&entry), viewport, 0, None);
assert!(
result
.link_overlay
.links()
.iter()
.all(|link| !link.url.contains("/outside/long-file-name.rs")),
result.link_overlay.links().iter().all(|link| {
!resolve_link_target(&link.target)
.and_then(|resolved| resolved.osc8_url)
.is_some_and(|url| url.contains("/outside/long-file-name.rs"))
}),
"off-row path cells must not be clickable: {:?}",
result.link_overlay.links()
);
@ -3628,7 +3950,9 @@ mod tests {
let mut by_id: std::collections::BTreeMap<u32, Vec<&OverlayLink>> =
std::collections::BTreeMap::new();
for l in result.link_overlay.links() {
if &*l.url == url
if resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.is_some_and(|target| target.as_ref() == url)
&& let Some(id) = l.id
{
by_id.entry(id).or_default().push(l);
@ -3641,7 +3965,15 @@ mod tests {
.link_overlay
.links()
.iter()
.map(|l| (l.screen_row, l.col_start, l.col_end, &*l.url, l.id))
.map(|l| (
l.screen_row,
l.col_start,
l.col_end,
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
l.id
))
.collect::<Vec<_>>(),
);
}
@ -4023,7 +4355,11 @@ mod tests {
.link_overlay
.links()
.iter()
.filter(|l| &*l.url == url_a || &*l.url == url_b)
.filter(|l| {
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.is_some_and(|url| url.as_ref() == url_a || url.as_ref() == url_b)
})
.filter_map(|l| l.id)
.collect();
assert!(

View file

@ -181,8 +181,8 @@ pub struct ScrollbackState {
expanded_groups: HashSet<EntryId>,
// Link map
/// Monotonically increasing counter, bumped on scroll, viewport, or
/// content changes. Used by `VisibleLinkMap::is_stale()` to skip rebuilds.
/// Monotonically increasing counter, bumped when visible link positions or
/// policy inputs change. Used by `VisibleLinkMap::is_stale()` to skip rebuilds.
generation: u64,
/// Bumped only when entries are added/removed or an entry's content changes
@ -255,7 +255,7 @@ impl ScrollbackState {
self.cwd.as_deref()
}
/// Update session cwd; invalidates entry paint caches when it changes.
/// Update session cwd; invalidates cwd-dependent paint, layout, and link maps.
pub fn set_cwd(&mut self, cwd: Option<std::path::PathBuf>) {
if self.cwd == cwd {
return;
@ -267,6 +267,7 @@ impl ScrollbackState {
self.dirty_heights = self.entries.keys().copied().collect();
self.layout_cache = None;
self.gaps_may_be_dirty = true;
self.bump_generation();
}
/// Create an empty state that continues this one's identity: same
@ -502,8 +503,8 @@ impl ScrollbackState {
// Link map generation
/// Current link-map generation. Incremented whenever content, scroll,
/// or viewport changes invalidate the visible link positions.
/// Current link-map generation. Incremented when positions or link-policy
/// inputs change and invalidate the visible link map.
pub fn generation(&self) -> u64 {
self.generation
}

View file

@ -167,8 +167,8 @@ pub struct BlockLine {
///
/// The first line of a block should always have `None`.
pub joiner: Option<String>,
/// OSC 8 URL when paint text is not a scannable absolute path (tool headers).
pub link_url: Option<Arc<str>>,
/// Semantic link target when paint text cannot recover it (tool headers).
pub link_target: Option<crate::render::osc8::LinkTarget>,
}
impl Default for BlockLine {
@ -183,7 +183,7 @@ impl Default for BlockLine {
selection_range: None,
selection_text: None,
joiner: None,
link_url: None,
link_target: None,
}
}
}
@ -273,11 +273,6 @@ impl BlockLine {
self.joiner = joiner;
self
}
pub fn with_link_url(mut self, url: Option<Arc<str>>) -> Self {
self.link_url = url;
self
}
}
/// Flatten a rendered line's spans into the plain text drawn on that row.
@ -610,7 +605,7 @@ mod tests {
selection_range: None,
selection_text: None,
joiner: None,
link_url: None,
link_target: None,
};
}

View file

@ -10,9 +10,23 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use xai_grok_tools::implementations::skills::types::SkillScope;
use super::acp_command::AcpSlashCommand;
use super::command::SlashCommand;
fn client_collision_qualified_name(
cmd: &agent_client_protocol::AvailableCommand,
) -> Option<String> {
let meta = cmd.meta.as_ref()?;
meta.get("path").and_then(|v| v.as_str())?;
let scope: SkillScope = serde_json::from_value(meta.get("scope")?.clone()).ok()?;
if scope == SkillScope::Plugin {
return None;
}
Some(format!("{}:{}", scope.as_ref(), cmd.name))
}
/// Source of a command in the registry. Used for precedence and replacement.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandSource {
@ -487,16 +501,20 @@ impl CommandRegistry {
"reload-plugins",
];
// Add new ACP commands, skipping collisions with builtins and blocked names.
for acp_cmd in commands {
let name_lower = acp_cmd.name.to_lowercase();
if builtin_keys.contains(&name_lower) {
continue;
}
if BLOCKED_NAMES
.iter()
.any(|b| b.eq_ignore_ascii_case(&name_lower))
{
let name_reserved = builtin_keys.contains(&name_lower)
|| BLOCKED_NAMES
.iter()
.any(|b| b.eq_ignore_ascii_case(&name_lower));
if name_reserved {
if let Some(qualified) = client_collision_qualified_name(acp_cmd) {
let mut renamed = acp_cmd.clone();
renamed.name = qualified;
self.commands
.push(Arc::new(AcpSlashCommand::from(&renamed)));
self.sources.push(CommandSource::Acp);
}
continue;
}
self.commands.push(Arc::new(AcpSlashCommand::from(acp_cmd)));
@ -945,6 +963,108 @@ mod tests {
assert_eq!(registry.command_count(), 1);
}
fn acp_skill(name: &str, scope: &str) -> agent_client_protocol::AvailableCommand {
let meta = serde_json::json!({ "scope": scope, "path": "/x/SKILL.md" })
.as_object()
.cloned()
.unwrap();
agent_client_protocol::AvailableCommand::new(name.to_string(), format!("{name} skill"))
.meta(meta)
}
#[test]
fn acp_nonplugin_skill_colliding_with_builtin_is_requalified() {
let builtin: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "login",
aliases: &[],
});
let mut registry = CommandRegistry::new(vec![builtin]);
registry.set_acp_commands(&[acp_skill("login", "local")]);
assert!(registry.get("login").is_some());
assert!(registry.is_builtin("login"));
assert!(registry.get("local:login").is_some());
assert!(!registry.is_builtin("local:login"));
assert_eq!(registry.command_count(), 2, "builtin + re-homed skill");
assert!(
registry
.triggers()
.iter()
.any(|t| t.canonical == "local:login"),
"re-homed skill should have a dropdown trigger"
);
}
#[test]
fn acp_malformed_skill_meta_colliding_with_builtin_is_dropped() {
let builtin: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "login",
aliases: &[],
});
let mut registry = CommandRegistry::new(vec![builtin]);
let meta = serde_json::json!({ "scope": "local" })
.as_object()
.cloned()
.unwrap();
let cmd = agent_client_protocol::AvailableCommand::new(
"login".to_string(),
"malformed".to_string(),
)
.meta(meta);
registry.set_acp_commands(&[cmd]);
assert_eq!(
registry.command_count(),
1,
"malformed-meta collision drops"
);
assert!(registry.get("local:login").is_none());
}
#[test]
fn acp_skill_named_after_blocked_name_is_requalified() {
let builtin: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "exit",
aliases: &[],
});
let mut registry = CommandRegistry::new(vec![builtin]);
registry.set_acp_commands(&[acp_skill("hooks-add", "local")]);
assert!(registry.get("local:hooks-add").is_some());
assert!(registry.get("hooks-add").is_none());
}
#[test]
fn acp_plugin_skill_colliding_with_builtin_is_dropped_not_requalified() {
let builtin: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "login",
aliases: &[],
});
let mut registry = CommandRegistry::new(vec![builtin]);
registry.set_acp_commands(&[acp_skill("login", "plugin")]);
assert!(registry.get("login").is_some());
assert!(registry.is_builtin("login"));
assert!(
registry.get("plugin:login").is_none(),
"pager must not fabricate a plugin-qualified name"
);
assert_eq!(registry.command_count(), 1, "only the builtin remains");
}
#[test]
fn acp_nonskill_colliding_with_builtin_is_dropped() {
let builtin: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "login",
aliases: &[],
});
let mut registry = CommandRegistry::new(vec![builtin]);
registry.set_acp_commands(&[agent_client_protocol::AvailableCommand::new(
"login".to_string(),
"shell login".to_string(),
)]);
assert_eq!(registry.command_count(), 1);
assert!(registry.is_builtin("login"));
}
#[test]
fn command_without_required_tools_is_always_visible() {
let plain: Arc<dyn SlashCommand> = Arc::new(DummyCommand {

View file

@ -412,7 +412,7 @@ pub fn init_tracing() -> TracingHandle {
};
use xai_grok_telemetry::debug_log::RMCP_SSE_NOISE_TARGET;
let (make_writer, rx) = TracingChannelMakeWriter::new();
let payload_level = if false { "debug" } else { "off" };
let payload_level = "off";
let directives = format!(
"xai_grok_shell=info,xai_grok_pager=trace,xai_grok_tools=info,xai_acp_lib=info,{RMCP_SSE_NOISE_TARGET}=error,sampling_log=off,{ACP_UPDATE_TARGET}=debug,{ACP_UPDATE_PAYLOAD_TARGET}={payload_level}"
);

View file

@ -463,6 +463,7 @@ pub fn render_btw_panel(
#[cfg(test)]
mod tests {
use super::*;
use crate::render::osc8::resolve_link_target;
fn render_with_model(
state: &BtwOverlayState,
@ -693,14 +694,22 @@ mod tests {
!overlay.is_empty(),
"expected at least one overlay link for markdown href"
);
let found = overlay.links().iter().any(|l| l.url.as_ref() == url);
let found = overlay.links().iter().any(|l| {
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.as_deref()
.unwrap_or("")
== url
});
assert!(
found,
"overlay should contain {url}, got: {:?}",
overlay
.links()
.iter()
.map(|l| l.url.as_ref())
.filter_map(
|l| resolve_link_target(&l.target).and_then(|resolved| resolved.osc8_url)
)
.collect::<Vec<_>>()
);
// Links live in the body (row >= 1), not the title border.
@ -720,12 +729,21 @@ mod tests {
let state = BtwOverlayState::done("q".to_string(), format!("Visit {url} please."));
let (_model, overlay) = render_with_links(&state, 60, 8);
assert!(
overlay.links().iter().any(|l| l.url.as_ref() == url),
overlay
.links()
.iter()
.any(|l| resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.as_deref()
.unwrap_or("")
== url),
"plain URL should become an overlay link, got: {:?}",
overlay
.links()
.iter()
.map(|l| l.url.as_ref())
.filter_map(
|l| resolve_link_target(&l.target).and_then(|resolved| resolved.osc8_url)
)
.collect::<Vec<_>>()
);
}
@ -737,7 +755,11 @@ mod tests {
let path = "/Users/test/project/src/main.rs";
let state = BtwOverlayState::done("q".to_string(), format!("See {path} for details."));
let (_model, overlay) = render_with_links(&state, 80, 8);
let urls: Vec<&str> = overlay.links().iter().map(|l| l.url.as_ref()).collect();
let urls: Vec<_> = overlay
.links()
.iter()
.filter_map(|l| resolve_link_target(&l.target).and_then(|resolved| resolved.osc8_url))
.collect();
assert!(
urls.iter()
.any(|u| u.contains("main.rs") && u.starts_with("file://")),
@ -767,7 +789,13 @@ mod tests {
let link = overlay
.links()
.iter()
.find(|l| l.url.as_ref() == url)
.find(|l| {
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.as_deref()
.unwrap_or("")
== url
})
.expect("scrolled link should still map when visible");
// Body starts at row 1; clamped offset 17 + 4 visible rows → link at
// visible index 3 → screen_row = 1 + 3 = 4.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,37 @@
//! Settings modal — opens via F2, `/settings`, command palette, and
//! shortcuts-help.
//!
//! ## State machine
//!
//! `SettingsModalState` carries a `UiConfig` snapshot plus a mode machine:
//!
//! - `Browse` — j/k navigates rows; Space toggles Bool; Enter opens
//! a chooser/editor for Enum/String/Int.
//! - `FilterFocused` — `/` enters filter mode; `invalidate_filter`
//! recomputes `filtered_cache` on every mutation.
//! - `PickingEnum { ... }` — enum chooser sub-pane.
//! - `EditingValue { ... }` — inline string/int editor.
//!
//! ## Keyboard ↔ mouse parity
//!
//! Every keyboard interaction has a mouse equivalent via `handle_mouse`.
//!
//! ## Close-key interception
//!
//! F2/Ctrl+,/Cmd+, are intercepted before mode-specific routing.
//! Esc-in-Browse is handled by the `ModalWindow` chrome (so
//! `is_close_key` does NOT match Esc); Esc-in-FilterFocused exits
//! filter mode without closing.
mod input;
mod render;
mod state;
#[cfg(test)]
mod tests;
pub use input::{handle_settings_key, handle_settings_mouse};
pub use render::{ResetConfirmOverlay, render_settings_modal};
#[allow(unused_imports)] // re-export for crate path; used by settings/registry tests
pub(crate) use state::MAX_PICKER_CHOICES;
pub use state::{MODAL_TITLE, RowEntry, SettingsKeyOutcome, SettingsModalMode, SettingsModalState};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,915 @@
//! Settings modal state, types, and filter cache.
use std::sync::Arc;
use ratatui::layout::Rect;
use crate::app::actions::Action;
use crate::settings::{
EnumChoice, OwnedEnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey, SettingKind,
SettingMeta, SettingValue, SettingsRegistry, StringValidator, current_value_for,
dynamic_enum_choices,
};
use crate::views::modal_window::ModalWindowState;
use xai_grok_shell::agent::config::UiConfig;
// ---------------------------------------------------------------------------
// Public constants
// ---------------------------------------------------------------------------
/// Public display title of the modal — also used by
/// `views/modal.rs::ActiveModal::message` so renames stay in one place.
pub const MODAL_TITLE: &str = "Settings";
/// Width of the `"─ "` leading decoration before the title in the
/// modal's top border. Used to compute the breadcrumb hit-rect x offset.
pub(super) const TITLE_LEADING_DECORATION_W: u16 = 2; // `─ `: 1 cell box-drawing + 1 cell space.
// Descriptions are now expand-on-demand via Right/Left arrows;
// see `render_expanded_description`.
/// Below this width the row list is skipped (chrome renders empty).
pub(super) const CONTENT_MIN_WIDTH: u16 = 10;
/// Default max width for the modal. Keeps the row list compact on wide terminals.
pub(super) const STANDARD_MAX_WIDTH: u16 = 110;
/// Per-side margin when editing `max_thoughts_width` (modal widens
/// to `terminal_width - 2*margin` so the wrap preview is useful).
pub(super) const MAX_THOUGHTS_WIDTH_WIDENED_MARGIN: u16 = 8;
/// Outcome of a key or mouse event. Separate from `InputOutcome`
/// because the modal doesn't own `agent.active_modal` — close is
/// the caller's responsibility.
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum SettingsKeyOutcome {
/// Close the modal.
Close,
/// Forward to dispatch.
Action(Action),
/// Forward two actions in order (first must resolve before second).
/// Used by `d`-reset-in-picker to revert preview before opening
/// the reset-confirm overlay.
ActionPair(Action, Action),
/// Internal state mutation, no action.
Changed,
/// No-op.
Unchanged,
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/// One row in the visible flat list — either a category header (non-
/// selectable) or a setting row (selectable, dispatchable).
#[derive(Debug, Clone)]
pub enum RowEntry {
Header { category: SettingCategory },
Setting { key: SettingKey, meta_index: usize },
}
/// Mode state for the modal.
#[derive(Debug, Clone)]
pub enum SettingsModalMode {
Browse,
/// `/` was pressed; chars filter the visible rows.
FilterFocused,
/// Enum chooser sub-pane. `supports_preview` is cached at open
/// time to avoid per-keystroke registry lookups.
PickingEnum {
key: SettingKey,
choices_idx: usize,
original_value: SettingValue,
supports_preview: bool,
},
/// Group sub-sheet: a list of the group's child Bool toggles. `child_idx`
/// is the focused child within the group. Space/Enter toggles in place
/// (the sheet stays open); Esc returns to Browse. Mirrors `PickingEnum`'s
/// open/render/commit flow but for independent toggles.
PickingGroup {
key: SettingKey,
child_idx: usize,
},
/// Inline string/int editor. `cursor_byte` is always on a char
/// boundary. `validation_error` shows live feedback; commit
/// re-validates before dispatching. No `original_value` — these
/// settings have no live preview, so Esc is a pure cancel.
EditingValue {
key: SettingKey,
buffer: String,
cursor_byte: usize,
validation_error: Option<String>,
},
}
/// Settings modal state. Boxed inside `ActiveModal::Settings` to
/// avoid clippy `large_enum_variant`.
pub struct SettingsModalState {
pub window: ModalWindowState,
pub registry: Arc<SettingsRegistry>,
/// `UiConfig` snapshot, refreshed by the dispatcher on mutations.
pub ui_snapshot: UiConfig,
pub pager_snapshot: PagerLocalSnapshot,
/// Computed row layout (headers + settings, in render order).
pub rows: Vec<RowEntry>,
/// Index into `rows` of the focused row.
pub selected: usize,
/// Vertical scroll offset (line-granular).
pub scroll_offset: usize,
pub mode: SettingsModalMode,
/// Filter query. Persists across FilterFocused→Browse on Enter; cleared by Esc.
pub query: String,
/// Byte offset of the editing cursor within `query`.
pub query_cursor: usize,
/// Row indices matching `query`, recomputed per mutation (not per frame).
pub(super) filtered_cache: Vec<usize>,
// -- Mouse hit-test rects (populated by render) --
pub list_area: Rect,
/// Click-hit rect per row, parallel to `rows`.
pub row_rects: Vec<Rect>,
/// Click-hit rect for the value column on each row. Bool rows
/// toggle on click; Enum/String/Int rows open the sub-pane.
pub value_hit_rects: Vec<Rect>,
/// `(decrement_rect, increment_rect)` for the Int stepper's
/// ``/`` glyphs. Zero-sized when not in Int editing mode.
pub editor_adornment_rects: (Rect, Rect),
/// Click-hit rect per choice in `PickingEnum`. Each rect spans the
/// full height of a choice (including wrapped description lines).
pub picker_choice_rects: Vec<Rect>,
/// Hit-rect for the breadcrumb title in sub-pane modes
/// (`PickingEnum`/`EditingValue`). Clicking anywhere on
/// `Settings <label>` cancels back to Browse. `None` in
/// Browse/FilterFocused. Cleared on mode transitions.
pub settings_breadcrumb_rect: Option<Rect>,
/// Hover flag for the breadcrumb — adds underline affordance.
pub breadcrumb_hovered: bool,
/// Keys whose description is expanded (Right/l to expand, Left/h
/// to collapse). Multiple rows can be expanded simultaneously.
pub expanded_keys: std::collections::HashSet<&'static str>,
/// Row under the mouse cursor for hover highlighting. Indexes
/// `rows` in Browse, `picker_choice_rects` in PickingEnum,
/// always `None` in EditingValue.
pub hover_row: Option<usize>,
}
impl SettingsModalState {
/// Construct a new modal state from a registry + snapshots.
pub fn new(
registry: Arc<SettingsRegistry>,
ui_snapshot: UiConfig,
pager_snapshot: PagerLocalSnapshot,
) -> Self {
let rows = build_rows(&registry);
// Start on the first selectable (non-header) row.
let selected = rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { .. }))
.unwrap_or(0);
let filtered_cache = compute_filtered(&rows, &registry, "");
Self {
window: ModalWindowState::new(),
registry,
ui_snapshot,
pager_snapshot,
rows,
selected,
scroll_offset: 0,
mode: SettingsModalMode::Browse,
query: String::new(),
query_cursor: 0,
filtered_cache,
list_area: Rect::default(),
row_rects: Vec::new(),
value_hit_rects: Vec::new(),
editor_adornment_rects: (Rect::default(), Rect::default()),
picker_choice_rects: Vec::new(),
settings_breadcrumb_rect: None,
breadcrumb_hovered: false,
expanded_keys: std::collections::HashSet::new(),
hover_row: None,
}
}
/// The currently-focused setting row, if any.
pub fn focused_setting(&self) -> Option<(SettingKey, &SettingMeta)> {
match self.rows.get(self.selected)? {
RowEntry::Setting { key, meta_index } => {
let meta = self.registry.all().get(*meta_index)?;
Some((*key, meta))
}
RowEntry::Header { .. } => None,
}
}
/// Filtered row indices in render order.
pub fn filtered_indices(&self) -> &[usize] {
&self.filtered_cache
}
/// Rebuild rows from current process gates (voice / kitty / minimal).
/// Keeps focus on the same key when possible; exits sub-panes if the key vanished.
pub fn rebuild_rows(&mut self) {
let prev_key = self.focused_setting().map(|(k, _)| k);
let subpane_key = match &self.mode {
SettingsModalMode::PickingEnum { key, .. }
| SettingsModalMode::PickingGroup { key, .. }
| SettingsModalMode::EditingValue { key, .. } => Some(*key),
SettingsModalMode::Browse | SettingsModalMode::FilterFocused => None,
};
self.rows = build_rows(&self.registry);
self.invalidate_filter();
if let Some(key) = subpane_key {
let still_visible = self
.rows
.iter()
.any(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key));
if !still_visible {
self.mode = SettingsModalMode::Browse;
self.settings_breadcrumb_rect = None;
self.picker_choice_rects.clear();
}
}
if let Some(key) = prev_key {
if let Some(idx) = self
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key))
{
self.selected = idx;
} else {
self.selected = self
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { .. }))
.unwrap_or(0);
}
} else {
self.clamp_selected_to_visible();
}
}
/// Recompute `filtered_cache` from the current `query`.
pub(super) fn invalidate_filter(&mut self) {
self.filtered_cache = compute_filtered(&self.rows, &self.registry, &self.query);
}
/// Snap `selected` to the first visible setting if filtered out.
pub(super) fn clamp_selected_to_visible(&mut self) {
if self.filtered_cache.is_empty() {
return;
}
if self.filtered_cache.contains(&self.selected) {
return;
}
// Snap to first selectable row in the visible filter.
for &row_idx in &self.filtered_cache {
if matches!(self.rows[row_idx], RowEntry::Setting { .. }) {
self.selected = row_idx;
return;
}
}
}
/// Read the current value for a setting key.
pub fn value_for(&self, key: SettingKey) -> Option<SettingValue> {
current_value_for(key, &self.ui_snapshot, &self.pager_snapshot)
}
/// Move `selected` forward, skipping headers and filtered-out rows.
pub(super) fn advance_next(&mut self) -> bool {
let cur_pos = self.filtered_cache.iter().position(|&i| i == self.selected);
let mut next = match cur_pos {
Some(p) => p + 1,
// Defensive: resume from top if `selected` is hidden.
None => 0,
};
while next < self.filtered_cache.len() {
let row_idx = self.filtered_cache[next];
if matches!(self.rows[row_idx], RowEntry::Setting { .. }) {
self.selected = row_idx;
return true;
}
next += 1;
}
false
}
/// Move `selected` backward, skipping headers and filtered-out rows.
pub(super) fn advance_prev(&mut self) -> bool {
if self.filtered_cache.is_empty() {
return false;
}
let cur_pos = self.filtered_cache.iter().position(|&i| i == self.selected);
let mut prev = match cur_pos {
Some(p) if p > 0 => p - 1,
Some(_) => return false,
// Defensive: resume from bottom if `selected` is hidden.
None => self.filtered_cache.len() - 1,
};
loop {
let row_idx = self.filtered_cache[prev];
if matches!(self.rows[row_idx], RowEntry::Setting { .. }) {
self.selected = row_idx;
return true;
}
if prev == 0 {
break;
}
prev -= 1;
}
false
}
/// Set selection to `idx` if it's a selectable row.
pub fn select_at(&mut self, idx: usize) -> bool {
if idx >= self.rows.len() {
return false;
}
if !matches!(self.rows[idx], RowEntry::Setting { .. }) {
return false;
}
if self.selected == idx {
return false;
}
self.selected = idx;
true
}
/// Reset hit-test geometry so mouse handlers degrade gracefully
/// when render is aborted. Does NOT clear `hover_row` — that's
/// cleared on mode transitions instead to avoid per-frame flicker.
pub(crate) fn reset_hit_rects(&mut self) {
self.list_area = Rect::default();
self.row_rects.clear();
self.value_hit_rects.clear();
self.editor_adornment_rects = (Rect::default(), Rect::default());
self.picker_choice_rects.clear();
self.settings_breadcrumb_rect = None;
self.breadcrumb_hovered = false;
}
/// Transition to Browse, clearing sub-pane hover/breadcrumb state
/// to prevent stale hit-rects across mode changes.
pub(crate) fn transition_to_browse(&mut self) {
self.mode = SettingsModalMode::Browse;
self.hover_row = None;
self.settings_breadcrumb_rect = None;
self.breadcrumb_hovered = false;
}
/// Transition to `PickingEnum` if the focused row is Enum/DynamicEnum.
/// Returns `false` if the focused row is another kind.
pub fn try_enter_picking_enum(&mut self) -> bool {
let (key, first_canonical, current_value, supports_preview, resolved_choices) = {
let Some((key, meta)) = self.focused_setting() else {
return false;
};
// Handles both static `Enum` and `DynamicEnum` catalogs.
let (supports_preview, resolved): (bool, Vec<OwnedEnumChoice>) = match &meta.kind {
SettingKind::Enum {
choices,
supports_preview,
..
} => (
*supports_preview,
effective_enum_choices(key, choices, &self.pager_snapshot)
.into_iter()
.map(|c| OwnedEnumChoice {
canonical: c.canonical.to_string(),
display: c.display.to_string(),
description: c.description.to_string(),
})
.collect(),
),
SettingKind::DynamicEnum {
source,
supports_preview,
..
} => (
*supports_preview,
dynamic_enum_choices(*source, &self.pager_snapshot),
),
_ => return false,
};
// Soft-fail if a static catalog exceeds the product cap. DynamicEnum
// (e.g. models) is exempt — those lists are runtime-sized and always
// scroll. The chooser itself scrolls static lists too; this assert is
// a design guard, not a render requirement.
debug_assert!(
resolved.len() <= MAX_PICKER_CHOICES
|| matches!(meta.kind, SettingKind::DynamicEnum { .. }),
"Static Enum setting `{}` has {} choices, exceeds MAX_PICKER_CHOICES ({}). \
Raise the cap deliberately if a larger curated catalog is required.",
key,
resolved.len(),
MAX_PICKER_CHOICES,
);
let first = resolved
.first()
.map(|c| c.canonical.clone())
.unwrap_or_default();
let cur = self.value_for(key);
(key, first, cur, supports_preview, resolved)
};
// Resolve choices_idx from current value. For DynamicEnum,
// if the current value no longer exists in the catalog,
// fall back to index 1 (first real entry past sentinel)
// to avoid accidentally wiping the user's preference.
let is_dynamic_enum = matches!(
self.registry.find(key).map(|m| &m.kind),
Some(SettingKind::DynamicEnum { .. })
);
let unknown_dynamic_fallback_idx = if is_dynamic_enum && resolved_choices.len() > 1 {
1
} else {
0
};
let choices_idx = match &current_value {
Some(SettingValue::Enum(cur)) => resolved_choices
.iter()
.position(|c| c.canonical == *cur)
.unwrap_or(0),
Some(SettingValue::String(cur)) if !cur.is_empty() => resolved_choices
.iter()
.position(|c| c.canonical == *cur)
.unwrap_or(unknown_dynamic_fallback_idx),
Some(SettingValue::String(_)) => 0,
_ => 0,
};
if is_dynamic_enum
&& choices_idx == unknown_dynamic_fallback_idx
&& unknown_dynamic_fallback_idx != 0
{
// Telemetry: log when a DynamicEnum value is stale.
tracing::warn!(
target: "settings",
key = key,
?current_value,
"DynamicEnum picker entered with a current value that no longer resolves \
in the live catalog focusing first real choice instead of the \
(no override) sentinel to defend against accidental destructive Enter",
);
}
let original_value = current_value.unwrap_or_else(|| {
// Fallback to first choice, using the right value carrier.
match self.registry.find(key).map(|m| &m.kind) {
Some(SettingKind::DynamicEnum { .. }) => SettingValue::String(first_canonical),
Some(SettingKind::Enum { choices, .. }) => {
let first_static = choices.first().map(|c| c.canonical).unwrap_or("");
SettingValue::Enum(first_static)
}
_ => SettingValue::Enum(""),
}
});
self.mode = SettingsModalMode::PickingEnum {
key,
choices_idx,
supports_preview,
original_value,
};
self.hover_row = None;
true
}
/// Transition to `PickingGroup` if the focused row is a `Group`. Returns
/// `false` for any other kind so the caller can fall through to the
/// enum/editor entry points.
pub fn try_enter_picking_group(&mut self) -> bool {
let Some((key, meta)) = self.focused_setting() else {
return false;
};
if !matches!(meta.kind, SettingKind::Group { .. }) {
return false;
}
self.mode = SettingsModalMode::PickingGroup { key, child_idx: 0 };
self.hover_row = None;
true
}
/// Transition to `EditingValue` if the focused row is String or Int.
pub fn try_enter_editing_value(&mut self) -> bool {
let Some((key, meta)) = self.focused_setting() else {
return false;
};
let buffer = match (&meta.kind, self.value_for(key)) {
(SettingKind::String { .. }, Some(SettingValue::String(s))) => s,
(SettingKind::Int { .. }, Some(SettingValue::Int(i))) => i.to_string(),
// Fallback for registry skew — seed from default.
(SettingKind::String { default, .. }, _) => default.to_string(),
(SettingKind::Int { default, .. }, _) => default.to_string(),
_ => return false,
};
let cursor_byte = buffer.len();
// Validate the seed value upfront.
let validation_error = match &meta.kind {
SettingKind::String { validator, .. } => {
validate_string(*validator, &buffer, &self.pager_snapshot.available_models)
}
SettingKind::Int { min, max, .. } => validate_int(&buffer, *min, *max),
_ => None,
};
self.mode = SettingsModalMode::EditingValue {
key,
buffer,
cursor_byte,
validation_error,
};
self.hover_row = None;
true
}
/// Build the Action that toggles the focused Bool row. Returns
/// `None` with an error log on registry skew (caught by CI tests).
pub fn toggle_focused_bool(&self) -> Option<Action> {
let (key, meta) = self.focused_setting()?;
if !matches!(meta.kind, SettingKind::Bool { .. }) {
return None;
}
let cur = match self.value_for(key) {
Some(SettingValue::Bool(b)) => b,
Some(other) => {
tracing::error!(
target: "settings",
?key,
?other,
"Bool-kind setting resolved to non-Bool value — registry skew",
);
return None;
}
None => {
tracing::error!(
target: "settings",
?key,
"Bool-kind setting has no current_value_for arm — registry skew",
);
return None;
}
};
let action = action_for_bool(key, !cur);
if action.is_none() {
tracing::error!(
target: "settings",
?key,
"Bool-kind setting has no action_for_bool arm — registry skew",
);
}
action
}
}
/// Compute filtered row indices for a query. Headers are emitted only
/// when ≥1 setting in their section matches. Returns all indices when
/// `query` is empty.
pub(super) fn compute_filtered(
rows: &[RowEntry],
registry: &SettingsRegistry,
query: &str,
) -> Vec<usize> {
if query.is_empty() {
return (0..rows.len()).collect();
}
let matched_keys: Vec<SettingKey> = registry.search(query).iter().map(|m| m.key).collect();
let mut result = Vec::new();
let mut pending_header: Option<usize> = None;
for (i, row) in rows.iter().enumerate() {
match row {
RowEntry::Header { .. } => {
// Emit header only when section has a match.
pending_header = Some(i);
}
RowEntry::Setting { key, .. } => {
if matched_keys.contains(key) {
if let Some(h) = pending_header.take() {
result.push(h);
}
result.push(i);
}
}
}
}
result
}
/// Row visibility: voice rows need the voice gate; capture needs key releases;
/// `hidden_in_minimal` rows are dropped in minimal mode. Pure for unit tests.
pub(super) fn setting_row_visible(
meta: &SettingMeta,
kitty_releases: bool,
minimal: bool,
voice_mode: bool,
) -> bool {
if !voice_mode && matches!(meta.key, "voice_capture_mode" | "voice_stt_language") {
return false;
}
if meta.key == "voice_capture_mode" && !kitty_releases {
return false;
}
if minimal && meta.hidden_in_minimal {
return false;
}
true
}
fn build_rows(registry: &SettingsRegistry) -> Vec<RowEntry> {
let kitty_releases = crate::app::kitty_flags_pushed();
let minimal = crate::app::minimal_mode_active();
let voice_mode = crate::app::voice_mode_enabled();
// Keys that belong to a group sub-sheet are rendered only inside that
// sheet, never as their own top-level rows.
let group_children: std::collections::HashSet<SettingKey> = registry
.all()
.iter()
.filter_map(|m| match &m.kind {
SettingKind::Group { children } => Some(*children),
_ => None,
})
.flatten()
.copied()
.collect();
let mut rows = Vec::new();
for cat in SettingCategory::ALL {
let mut emitted_header = false;
for (meta_index, meta) in registry.all().iter().enumerate() {
if meta.category != *cat {
continue;
}
if !setting_row_visible(meta, kitty_releases, minimal, voice_mode) {
continue;
}
if group_children.contains(meta.key) {
continue;
}
if !emitted_header {
rows.push(RowEntry::Header { category: *cat });
emitted_header = true;
}
rows.push(RowEntry::Setting {
key: meta.key,
meta_index,
});
}
}
rows
}
/// Construct the typed `Action::Set*` for a Bool setting.
pub(super) fn action_for_bool(key: SettingKey, new: bool) -> Option<Action> {
match key {
"compact_mode" => Some(Action::SetCompactMode(new)),
"show_timestamps" => Some(Action::SetTimestamps(new)),
"show_timeline" => Some(Action::SetTimeline(new)),
"simple_mode" => Some(Action::SetSimpleMode(new)),
"contextual_hints.undo" => Some(Action::SetContextualHintUndo(new)),
"contextual_hints.plan_mode" => Some(Action::SetContextualHintPlanMode(new)),
"contextual_hints.image_input" => Some(Action::SetContextualHintImageInput(new)),
"contextual_hints.send_now" => Some(Action::SetContextualHintSendNow(new)),
"contextual_hints.small_screen" => Some(Action::SetContextualHintSmallScreen(new)),
"contextual_hints.word_select" => Some(Action::SetContextualHintWordSelect(new)),
"multiline_mode" => Some(Action::SetMultilineMode(new)),
"vim_mode" => Some(Action::SetVimMode(new)),
"remember_tool_approvals" => Some(Action::SetRememberToolApprovals(new)),
"toolset.ask_user_question.timeout_enabled" => {
Some(Action::SetAskUserQuestionTimeoutEnabled(new))
}
"show_thinking_blocks" => Some(Action::SetShowThinkingBlocks(new)),
"group_tool_verbs" => Some(Action::SetGroupToolVerbs(new)),
"collapsed_edit_blocks" => Some(Action::SetCollapsedEditBlocks(new)),
"prompt_suggestions" => Some(Action::SetPromptSuggestions(new)),
"respect_manual_folds" => Some(Action::SetRespectManualFolds(new)),
"invert_scroll" => Some(Action::SetInvertScroll(new)),
"show_tips" => Some(Action::SetShowTips(new)),
"auto_update" => Some(Action::SetAutoUpdate(new)),
"display_refresh_auto_cadence" => Some(Action::SetDisplayRefreshAutoCadence(new)),
_ => None,
}
}
/// Construct `Action::Preview*` for an Enum setting — used by the
/// picker's Up/Down (live preview) and Esc (revert). Preview actions
/// never persist; they only mutate the live visual.
pub(super) fn action_for_enum(key: SettingKey, choice: &'static str) -> Option<Action> {
match key {
"theme" => Some(Action::PreviewTheme(choice.to_string())),
"auto_dark_theme" => Some(Action::PreviewAutoDarkTheme(choice.to_string())),
"auto_light_theme" => Some(Action::PreviewAutoLightTheme(choice.to_string())),
// No preview for settings with irreversible side effects.
"permission_mode" => None,
"coding_data_sharing" => None,
"plan_mode" => None,
"render_mermaid" => None,
"keep_text_selection" => None,
"scroll_mode" => None,
_ => None,
}
}
/// Construct `Action::Set*` commit variant for an Enum setting.
/// Commit actions persist to disk and fire a toast.
pub(super) fn action_for_enum_commit(key: SettingKey, choice: &'static str) -> Option<Action> {
match key {
"theme" => Some(Action::SetTheme(choice.to_string())),
"auto_dark_theme" => Some(Action::SetAutoDarkTheme(choice.to_string())),
"auto_light_theme" => Some(Action::SetAutoLightTheme(choice.to_string())),
// Canonical strings from settings/defs.rs are the source of truth.
"permission_mode" => match choice {
"always-approve" => Some(Action::SetPermissionMode(
crate::app::actions::PermissionModeKind::AlwaysApprove,
)),
// Auto's feature gate is enforced in `set_permission_mode`
// (via `app.auto_mode_gate`, the same source the Shift+Tab cycle
// uses), so the modal and the cycle never disagree. Committing Auto
// when the gate is off degrades to Ask there.
"auto" => Some(Action::SetPermissionMode(
crate::app::actions::PermissionModeKind::Auto,
)),
"ask" => Some(Action::SetPermissionMode(
crate::app::actions::PermissionModeKind::Ask,
)),
"default" => Some(Action::SetPermissionMode(
crate::app::actions::PermissionModeKind::Default,
)),
_ => None,
},
"coding_data_sharing" => match choice {
"opt-in" => Some(Action::SetCodingDataSharing { opted_in: true }),
"opt-out" => Some(Action::SetCodingDataSharing { opted_in: false }),
_ => None,
},
"plan_mode" => match choice {
"on" => Some(Action::SetPlanMode(crate::app::actions::PlanModeKind::On)),
"off" => Some(Action::SetPlanMode(crate::app::actions::PlanModeKind::Off)),
_ => None,
},
"hunk_tracker_mode" => Some(Action::SetHunkTrackerMode(choice.to_string())),
"screen_mode" => Some(Action::SetScreenMode(choice.to_string())),
"voice_capture_mode" => Some(Action::SetVoiceCaptureMode(choice.to_string())),
"voice_stt_language" => Some(Action::SetVoiceSttLanguage(choice.to_string())),
"render_mermaid" => {
crate::appearance::RenderMermaid::from_canonical(choice).map(Action::SetRenderMermaid)
}
"keep_text_selection" => crate::appearance::TextSelection::from_canonical(choice)
.map(Action::SetKeepTextSelection),
// Junk canonicals fold to None — Enter no-ops instead of mis-mapping.
"scroll_mode" => {
crate::appearance::ScrollMode::from_canonical(choice).map(Action::SetScrollMode)
}
"default_selected_permission" => {
Some(Action::SetDefaultSelectedPermission(choice.to_string()))
}
_ => None,
}
}
/// Construct `Action::Set*` commit variant for a String setting.
/// Resolves model names via the snapshot before producing the action.
/// Empty buffer maps to `Action::Clear*` for model settings.
pub(super) fn action_for_string(
key: SettingKey,
value: String,
snapshot: &PagerLocalSnapshot,
) -> Option<Action> {
match key {
"default_model" => {
if value.is_empty() {
Some(Action::ClearDefaultModel)
} else {
snapshot
.resolve_model_name(&value)
.map(Action::SetDefaultModel)
}
}
"fork_secondary_model" => {
if value.is_empty() {
Some(Action::ClearForkSecondaryModel)
} else {
snapshot
.resolve_model_name(&value)
.map(Action::SetForkSecondaryModel)
}
}
_ => {
let _ = value;
let _ = snapshot;
None
}
}
}
/// Construct `Action::Set*` commit variant for an Int setting.
pub(super) fn action_for_int(key: SettingKey, value: i64) -> Option<Action> {
match key {
"max_thoughts_width" => Some(Action::SetMaxThoughtsWidth(value)),
"scroll_speed" => Some(Action::SetScrollSpeed(value)),
"scroll_lines" => Some(Action::SetScrollLines(value)),
_ => None,
}
}
/// Validate a String buffer against the registered `StringValidator`.
/// Returns `Some(error_message)` on failure, `None` on success.
pub(super) fn validate_string(
validator: StringValidator,
buffer: &str,
available_models: &[(String, agent_client_protocol::ModelId)],
) -> Option<String> {
match validator {
StringValidator::Any => None,
StringValidator::NonEmptyToken => {
if buffer.is_empty() {
Some("Value cannot be empty".to_string())
} else if buffer.chars().any(|c| c.is_whitespace()) {
Some("Value cannot contain whitespace".to_string())
} else {
None
}
}
StringValidator::KnownModel => {
// Empty = "clear default" sentinel.
if buffer.is_empty() {
return None;
}
// Reject if the model catalog hasn't loaded yet.
if available_models.is_empty() {
return Some("Model catalog still loading — try again".to_string());
}
let matched = available_models
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case(buffer));
if matched {
None
} else {
Some(format!("Unknown model: \"{buffer}\""))
}
}
}
}
/// Validate an Int buffer against `(min, max)` bounds.
pub(super) fn validate_int(buffer: &str, min: i64, max: i64) -> Option<String> {
if buffer.is_empty() {
return Some("Value cannot be empty".to_string());
}
match buffer.parse::<i64>() {
Ok(v) if v >= min && v <= max => None,
Ok(v) => Some(format!("Value out of range ({min}\u{2013}{max}): {v}")),
Err(_) => Some(format!("Not a valid integer: \"{buffer}\"")),
}
}
/// Soft product cap on static Enum choices (settings unit tests enforce it).
///
/// The chooser already scrolls within the viewport when the focused choice
/// falls off-screen (`picker_scroll_offset`); this limit exists so catalogs
/// stay intentionally curated rather than unbounded. Sized to fit the full
/// Grok STT language list (25 codes + client-only `auto` = 26) with headroom.
pub(crate) const MAX_PICKER_CHOICES: usize = 32;
/// The children of a group setting, or an empty slice if `key` is not a group.
pub(super) fn group_children(state: &SettingsModalState, key: SettingKey) -> &'static [SettingKey] {
match state.registry.find(key).map(|m| &m.kind) {
Some(SettingKind::Group { children }) => children,
_ => &[],
}
}
/// Whether `(key, canonical)` is gated off and must not be offered as a choice:
/// `permission_mode`'s "auto" when the auto gate is off, and
/// `voice_capture_mode`'s "hold" without key-release reporting. Pure (gates
/// passed as args) so it's unit-testable without touching process globals.
pub(super) fn enum_choice_gated_off(
key: SettingKey,
canonical: &str,
auto_mode_gate: bool,
kitty_releases: bool,
) -> bool {
(key == "permission_mode" && canonical == "auto" && !auto_mode_gate)
|| (key == "voice_capture_mode" && canonical == "hold" && !kitty_releases)
}
/// The effective static Enum choices for a picker, hiding gated-off options so
/// the modal never offers a choice the setter would silently no-op. Every
/// index-based picker path (len / at / render / seed) routes through this.
pub(super) fn effective_enum_choices<'a>(
key: SettingKey,
choices: &'a [EnumChoice],
snapshot: &PagerLocalSnapshot,
) -> Vec<&'a EnumChoice> {
let kitty_releases = crate::app::kitty_flags_pushed();
choices
.iter()
.filter(|c| {
!enum_choice_gated_off(key, c.canonical, snapshot.auto_mode_gate, kitty_releases)
})
.collect()
}

File diff suppressed because it is too large Load diff