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:
parent
c68e39f604
commit
8adf9013a0
117 changed files with 16998 additions and 14540 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ®istry);
|
||||
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, ®istry);
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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![]);
|
||||
|
|
|
|||
|
|
@ -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![]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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") };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
1309
crates/codegen/xai-grok-pager/src/views/settings_modal/input.rs
Normal file
1309
crates/codegen/xai-grok-pager/src/views/settings_modal/input.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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};
|
||||
2943
crates/codegen/xai-grok-pager/src/views/settings_modal/render.rs
Normal file
2943
crates/codegen/xai-grok-pager/src/views/settings_modal/render.rs
Normal file
File diff suppressed because it is too large
Load diff
915
crates/codegen/xai-grok-pager/src/views/settings_modal/state.rs
Normal file
915
crates/codegen/xai-grok-pager/src/views/settings_modal/state.rs
Normal 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(®istry);
|
||||
// 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, ®istry, "");
|
||||
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 ¤t_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()
|
||||
}
|
||||
7455
crates/codegen/xai-grok-pager/src/views/settings_modal/tests.rs
Normal file
7455
crates/codegen/xai-grok-pager/src/views/settings_modal/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue