Synced from monorepo

Synced from monorepo

Changes:
- grok-shell: send an expired external-provider credential to the sign-in flow, not a 401 loop
- pager: clickable ▲ jumps to the top of the response being read
- grok-shell: keep a large task log from making the completion message too long
- Plan viewer scrollbar: widen grab zone to the border column; fix striped thumb in Terminal.app
- pager: poll the tmux probe teardown grace instead of sleeping it
- security: vendor-compat MCP kill switch is now actually enforced when reported as on
- grok-shell: restore session eviction when a leader client disconnects
- Bump rust-toolchain to 1.93.0
- workspace: lexical-normalize permission path patterns before glob matching
- pager: reject garbage Enter in the /resume picker
- pager: show Mermaid affordances in plan mode preview
- pager: drop manage-account link from /session-info
- workspace: auto-approve read-only git queries; defer write floor to auto classifier
- Add free-form pattern editor to the "Always allow" command prompt
- grok-shell: fix /btw caching
- pager: Tab walks answers in the ask_user_question card
- External-provider auth refresh: single 7s attempt instead of 3×5s
- pager: don't resurrect finished background tasks as Running when completion arrives first
- pager: report tmux truecolor clamping in Doctor
- Fix plan viewer scrollbar click+drag hijacked by comment gutter
- pager/shell: stop double Recap after the same last turn
- sampler: preserve x-should-retry through stream collection
- pager: clear plan-mode indicator immediately when the user approves a plan
- pager: tmux does not re-read its config on reattach

Source-Revision: 64c4de99cc822b25ce9c54ab5a4f372093d0885d
This commit is contained in:
grokkybara[bot] 2026-08-03 08:17:57 +00:00
commit 780d1388ff
323 changed files with 12258 additions and 7226 deletions

View file

@ -132,7 +132,9 @@ pub(super) fn handle_task_backgrounded(notif: &acp::ExtNotification, app: &mut A
// and backends predating that field still bake a "[monitor] <desc>" prefix
// into the command — detect it and strip the prefix so those render as a
// "Monitor" row instead of a bash-highlighted "[monitor] …" under Tasks.
let monitor_prefix = command.strip_prefix("[monitor] ").map(str::to_string);
let monitor_prefix = command
.strip_prefix(crate::app::agent::MONITOR_PREFIX)
.map(str::to_string);
let is_monitor = monitor_description.is_some() || monitor_prefix.is_some();
// Always drain the deferred-tool suppression key now that routing is being
// set up — even when we end up preferring the wire `description`. This entry
@ -153,6 +155,15 @@ pub(super) fn handle_task_backgrounded(notif: &acp::ExtNotification, app: &mut A
.or_else(|| non_blank(notif_description))
.or_else(|| non_blank(deferred_description));
// Completed-before-Backgrounded race: short bg shells can exit (and the
// terminal poll emit `TaskCompleted`) before this notification is sent.
// Never overwrite the recorded terminal state back to Running — the
// completion already came and went, so it would stick forever.
let completed_early = session
.bg_tasks
.get(&task_id)
.is_some_and(|t| t.status != BgTaskStatus::Running);
// Create central bg task state (description may still be filled from the
// Execute block on demotion before we insert into the map).
let mut bg_task = BgTaskState {
@ -199,7 +210,12 @@ pub(super) fn handle_task_backgrounded(notif: &acp::ExtNotification, app: &mut A
scrollback.mark_height_dirty(eid);
scrollback.finish_running(eid);
session.tracker.remove_pending_tool(&tool_call_id);
eid
Some(eid)
} else if completed_early {
// Entry gone and the task already completed: the completion
// block is already in scrollback — nothing left to render.
session.tracker.remove_pending_tool(&tool_call_id);
None
} else {
// Entry was removed between the tracker lookup and now (compaction,
// clear, etc.). Create a fresh BgTask so the task has UI presence.
@ -208,27 +224,34 @@ pub(super) fn handle_task_backgrounded(notif: &acp::ExtNotification, app: &mut A
.with_description(description.clone());
let fallback = scrollback.push_block(RenderBlock::BgTask(block));
scrollback.set_last_running(true);
fallback
Some(fallback)
}
} else if completed_early {
// The completion block is already in scrollback; a fresh "Task
// started" block would render out of order and animate forever.
None
} else {
let block = crate::scrollback::blocks::BgTaskBlock::started(&command, &task_id)
.with_description(description.clone());
let eid = scrollback.push_block(RenderBlock::BgTask(block));
scrollback.set_last_running(true);
eid
Some(eid)
};
bg_task.description = description;
session.bg_tasks.insert(task_id.clone(), bg_task);
if completed_early {
if let Some(existing) = session.bg_tasks.get_mut(&task_id) {
existing.absorb_late_backgrounded(bg_task, entry_id);
}
} else {
bg_task.scrollback_entry_id = entry_id;
session.bg_tasks.insert(task_id.clone(), bg_task);
}
session
.bg_tool_call_to_task
.insert(tool_call_id.clone(), task_id.clone());
if let Some(bg) = session.bg_tasks.get_mut(&task_id) {
bg.scrollback_entry_id = Some(entry_id);
}
is_active
}
@ -564,6 +587,9 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV
_ => return false,
};
// Stamps `restored_from_replay` on tombstones inserted below.
let meta = NotificationMeta::from_json(session_notif.meta.as_ref().and_then(|v| v.as_object()));
let (matched, is_active, agent) = match resolve_notif_agent(app, &session_notif.session_id) {
Some(t) => t,
None => return false,
@ -618,7 +644,8 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV
bg_task.scrollback_entry_id,
)
} else {
// Task we didn't know about — use snapshot data. Prefer
// Task we didn't know about — its `TaskBackgrounded` hasn't
// arrived yet. Label from the model-supplied description, else
// display_command when it differs from the raw command (monitors /
// isolation-wrapped shells); treat equal values as non-labels.
let command = task_snapshot.command.clone();
@ -626,21 +653,41 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV
.end_time
.and_then(|end| end.duration_since(task_snapshot.start_time).ok())
.unwrap_or_default();
let description = task_snapshot.display_command.clone().and_then(|d| {
// Strip the baked "[monitor] " prefix so the completed label
// matches the "Task started" path (which uses the bare
// monitor description), not "[monitor] …".
let d = d
.strip_prefix("[monitor] ")
.map(str::to_string)
.unwrap_or(d);
let t = d.trim();
if t.is_empty() || t == command.trim() {
None
} else {
Some(d)
}
});
let description = task_snapshot
.description
.clone()
.filter(|d| !d.trim().is_empty())
.or_else(|| {
task_snapshot.display_command.clone().and_then(|d| {
// Bare label, matching the "Task started" path.
let d = d
.strip_prefix(crate::app::agent::MONITOR_PREFIX)
.map(str::to_string)
.unwrap_or(d);
let t = d.trim();
if t.is_empty() || t == command.trim() {
None
} else {
Some(d)
}
})
});
// Record the terminal state so the late `TaskBackgrounded` merges
// into it instead of inserting a fresh Running entry.
let status = if success {
BgTaskStatus::Done
} else {
BgTaskStatus::Failed
};
let tombstone = BgTaskState::tombstone_from_snapshot(
&task_snapshot,
status,
description.clone(),
meta.is_replay,
);
session.bg_tasks.insert(task_id.clone(), tombstone);
(command, elapsed, description, None)
};
@ -692,7 +739,16 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV
RenderBlock::bg_task_failed(&command, task_id, elapsed, exit_code, signal)
.with_bg_task_description(description)
};
scrollback.push_block(block);
let completion_eid = scrollback.push_block(block);
// Anchor tasks that have no "Task started" block (tombstones) to the
// completion block, so block-viewer actions don't fall back to a bogus
// EntryId(0) and immediately close.
if let Some(bg_task) = session.bg_tasks.get_mut(task_id)
&& bg_task.scrollback_entry_id.is_none()
{
bg_task.scrollback_entry_id = Some(completion_eid);
}
is_active
}

View file

@ -52,7 +52,10 @@ mod workflow_ingest;
#[cfg(test)]
use permissions::{MCP_ARGS_MAX_LINE_CHARS, MCP_ARGS_MAX_LINES, mcp_args_lines};
use permissions::{apply_recap_block, handle_permission_request, should_drop_late_auto_recap};
use permissions::{
apply_recap_block, handle_permission_request, should_drop_duplicate_auto_recap,
should_drop_late_auto_recap,
};
// Hub + child modules (via `use super::*`) need sibling symbols in this scope.
use routing::{

View file

@ -436,6 +436,37 @@ pub(super) fn should_drop_late_auto_recap(auto: bool, is_replay: bool, agent_idl
auto && !is_replay && !agent_idle
}
/// Live auto recap when scrollback already has a recap after the last user
/// prompt. Replay still rebuilds history as stored.
pub(super) fn should_drop_duplicate_auto_recap(
auto: bool,
is_replay: bool,
scrollback: &crate::scrollback::state::ScrollbackState,
) -> bool {
auto && !is_replay && scrollback_has_recap_since_last_user(scrollback)
}
fn scrollback_has_recap_since_last_user(
scrollback: &crate::scrollback::state::ScrollbackState,
) -> bool {
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SessionEvent;
let mut recap_since_user = false;
for (_, entry) in scrollback.iter_entries() {
if entry.block.is_user_prompt() {
recap_since_user = false;
continue;
}
if let RenderBlock::SessionEvent(b) = &entry.block
&& matches!(b.event, SessionEvent::Recap { .. })
{
recap_since_user = true;
}
}
recap_since_user
}
/// Land a `SessionRecap` block: fill a manual `/recap`'s in-flight loading
/// spinner in place (and stop its animation) when one is showing, otherwise
/// append a fresh block. An automatic recap never consumes the manual loading

View file

@ -797,6 +797,12 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
"dropping late auto SessionRecap; agent busy (turn or command in flight)"
);
false
} else if should_drop_duplicate_auto_recap(auto, meta.is_replay, &agent.scrollback) {
tracing::debug!(
"dropping duplicate live auto SessionRecap; recap already shown since last user turn"
);
app.notification_service.focus_tracker.mark_recap_shown();
false
} else {
app.notification_service.focus_tracker.mark_recap_shown();
let recap_block = RenderBlock::session_event(SessionEvent::Recap { summary, auto });

View file

@ -635,3 +635,218 @@
);
}
/// Base snapshot for the Completed-before-Backgrounded race tests; tweak
/// fields per test (the shared helpers hardcode output/description).
fn race_snapshot(
task_id: &str,
command: &str,
exit_code: Option<i32>,
) -> xai_grok_tools::types::TaskSnapshot {
xai_grok_tools::types::TaskSnapshot {
task_id: task_id.into(),
command: command.into(),
display_command: None,
cwd: "/tmp".into(),
start_time: std::time::SystemTime::now(),
end_time: Some(std::time::SystemTime::now()),
output: String::new(),
output_file: "/tmp/out.log".into(),
truncated: false,
exit_code,
signal: None,
completed: true,
kind: Default::default(),
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: true,
output_total_bytes: 0,
}
}
fn completed_notif_from_snapshot(
session_id: &str,
task_snapshot: xai_grok_tools::types::TaskSnapshot,
replayed: bool,
) -> acp::ExtNotification {
let notif = SessionNotification {
session_id: acp::SessionId::new(session_id),
update: XaiSessionUpdate::TaskCompleted {
task_snapshot,
will_wake: false,
},
meta: replayed.then(crate::acp::meta::ReplayMetaStamp::replayed),
};
let raw = serde_json::value::to_raw_value(&notif).unwrap();
acp::ExtNotification::new("x.ai/task_completed", std::sync::Arc::from(raw))
}
/// Short bg shells can exit — and `TaskCompleted` arrive — before their
/// `TaskBackgrounded`. The late `TaskBackgrounded` must not resurrect the
/// finished task as Running or push a stray "Task started" block.
#[test]
fn completed_before_backgrounded_does_not_resurrect_running() {
let mut app = make_app_with_agent("sess-1");
// TaskCompleted first, for a task the pager has never seen.
let mut snapshot = race_snapshot("task-race", "echo done", Some(0));
snapshot.output = "task output line".into();
let done = completed_notif_from_snapshot("sess-1", snapshot, false);
assert!(handle_task_completed(&done, &mut app));
{
let agent = app.agents.get(&AgentId(0)).unwrap();
let task = agent
.session
.bg_tasks
.get("task-race")
.expect("unknown TaskCompleted must record terminal state");
assert_eq!(task.status, BgTaskStatus::Done);
assert_eq!(task.stdout, "task output line");
assert_eq!(agent.scrollback.len(), 1, "completed block rendered");
assert!(
task.scrollback_entry_id.is_some(),
"tombstone anchored to the completion block (viewer actions need an entry)"
);
}
// The late TaskBackgrounded (with a wire description) arrives.
let notif = SessionNotification {
session_id: acp::SessionId::new("sess-1"),
update: XaiSessionUpdate::TaskBackgrounded {
tool_call_id: "tc-race".into(),
task_id: "task-race".into(),
command: "echo done".into(),
cwd: "/tmp".into(),
output_file: "/tmp/output.log".into(),
monitor_description: None,
description: Some("wait for build".into()),
},
meta: None,
};
let raw = serde_json::value::to_raw_value(&notif).unwrap();
let late = acp::ExtNotification::new("x.ai/task_backgrounded", std::sync::Arc::from(raw));
assert!(handle_task_backgrounded(&late, &mut app));
let agent = app.agents.get(&AgentId(0)).unwrap();
let task = &agent.session.bg_tasks["task-race"];
assert_eq!(
task.status,
BgTaskStatus::Done,
"late TaskBackgrounded must not overwrite a terminal status with Running"
);
assert_eq!(task.tool_call_id, "tc-race", "tool_call_id backfilled");
assert_eq!(
task.description.as_deref(),
Some("wait for build"),
"description backfilled from the late notification"
);
assert_eq!(task.stdout, "task output line", "snapshot stdout kept");
assert_eq!(
agent.session.bg_tool_call_to_task.get("tc-race"),
Some(&"task-race".to_string())
);
assert_eq!(
agent.scrollback.len(),
1,
"no stray 'Task started' block after the completion"
);
assert!(
!agent.scrollback.needs_animation(),
"nothing may animate as running for a finished task"
);
}
/// Same race on the demotion path (foreground Execute auto-backgrounded):
/// the pending Execute block is still demoted to a finished BgTask block
/// and the terminal status survives.
#[test]
fn completed_before_backgrounded_demotion_finishes_execute_block() {
let mut app = make_app_with_agent("sess-1");
let tc_id = "call-race-demote";
setup_pending_execute_tool(&mut app, tc_id);
let done = make_task_completed_notif("sess-1", "task-demote", "sleep 9999", Some(1));
assert!(handle_task_completed(&done, &mut app));
{
let agent = app.agents.get(&AgentId(0)).unwrap();
assert_eq!(
agent.session.bg_tasks["task-demote"].status,
BgTaskStatus::Failed
);
assert_eq!(agent.scrollback.len(), 2, "Execute block + failed block");
}
let late = make_task_backgrounded_notif("sess-1", tc_id, "task-demote", "sleep 9999");
assert!(handle_task_backgrounded(&late, &mut app));
let agent = app.agents.get(&AgentId(0)).unwrap();
let task = &agent.session.bg_tasks["task-demote"];
assert_eq!(
task.status,
BgTaskStatus::Failed,
"terminal status survives the late demotion"
);
assert_eq!(agent.scrollback.len(), 2, "no extra block from the demotion");
let entry = agent.scrollback.get(0).unwrap();
assert!(
matches!(entry.block, RenderBlock::BgTask(_)),
"Execute block demoted to BgTask"
);
assert!(
!agent.scrollback.needs_animation(),
"the demoted entry must be finished, not animating"
);
assert!(
agent.session.tracker.pending_tool_entry_id(tc_id).is_none(),
"pending tool drained"
);
}
/// A completion tombstone prefers the snapshot's model-supplied
/// description, so the race renders the same label as the normal order.
#[test]
fn unknown_completed_prefers_snapshot_description() {
let mut app = make_app_with_agent("sess-1");
let mut snapshot = race_snapshot("task-desc", "cargo build", Some(0));
snapshot.description = Some("build the app".into());
let done = completed_notif_from_snapshot("sess-1", snapshot, false);
assert!(handle_task_completed(&done, &mut app));
let agent = app.agents.get(&AgentId(0)).unwrap();
assert_eq!(
agent.session.bg_tasks["task-desc"].description.as_deref(),
Some("build the app")
);
}
/// A monitor's completion tombstone keeps the Monitor rendering.
#[test]
fn unknown_completed_monitor_kind_marks_is_monitor() {
let mut app = make_app_with_agent("sess-1");
let mut snapshot = race_snapshot("task-mon", "tail -f x.log", Some(0));
snapshot.kind = xai_grok_tools::computer::types::TaskKind::Monitor;
let done = completed_notif_from_snapshot("sess-1", snapshot, false);
assert!(handle_task_completed(&done, &mut app));
let agent = app.agents.get(&AgentId(0)).unwrap();
assert!(agent.session.bg_tasks["task-mon"].is_monitor);
}
/// A tombstone from a replayed completion is historical context: it must
/// not read as new activity (mirrors restored `TaskBackgrounded`s).
#[test]
fn replayed_unknown_completed_marks_tombstone_restored() {
let mut app = make_app_with_agent("sess-1");
let snapshot = race_snapshot("task-replay", "echo hi", Some(0));
let done = completed_notif_from_snapshot("sess-1", snapshot, true);
assert!(handle_task_completed(&done, &mut app));
let agent = app.agents.get(&AgentId(0)).unwrap();
assert!(agent.session.bg_tasks["task-replay"].restored_from_replay);
}

View file

@ -1845,6 +1845,7 @@ pub(super) fn task_completed_notif(
owner_session_id: None,
description: None,
is_backgrounded: false,
output_total_bytes: 0,
},
will_wake,
},

View file

@ -186,6 +186,40 @@
);
}
#[test]
fn duplicate_live_auto_recap_dropped_after_existing_recap() {
let mut agent = make_agent(Some("s1"));
agent.scrollback.push_block(recap_block("first"));
assert!(should_drop_duplicate_auto_recap(
true,
false,
&agent.scrollback
));
assert!(
!should_drop_duplicate_auto_recap(true, true, &agent.scrollback),
"replay must still paint stored recaps"
);
assert!(
!should_drop_duplicate_auto_recap(false, false, &agent.scrollback),
"manual /recap still allowed"
);
}
#[test]
fn duplicate_auto_recap_allowed_after_new_user_prompt() {
let mut agent = make_agent(Some("s1"));
agent.scrollback.push_block(recap_block("old"));
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::user_prompt(
"next question",
));
assert!(
!should_drop_duplicate_auto_recap(true, false, &agent.scrollback),
"new user turn re-arms auto recap"
);
}
#[test]
fn enqueue_while_scrollback_steals_focus_to_prompt() {
use crate::app::agent_view::AgentPane;

View file

@ -166,6 +166,10 @@ pub const BG_TASK_MAX_STDOUT: usize = 10 * 1024 * 1024;
/// How long to wait for a kill response before auto-clearing `pending_kill`
/// so the user can retry. Applied to both bg tasks and subagents.
pub const PENDING_KILL_TIMEOUT_SECS: u64 = 10;
/// Prefix baked into monitor commands by backends predating the structured
/// `monitor_description` field (and by reparented monitors). Shared
/// convention with the shell's task notifications.
pub const MONITOR_PREFIX: &str = "[monitor] ";
/// Status of a background task.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BgTaskStatus {
@ -277,6 +281,82 @@ impl BgTaskState {
}
self.stdout_line_count = self.stdout.lines().count();
}
/// Terminal state recorded when a `TaskCompleted` arrives for a task with
/// no `bg_tasks` entry (its `TaskBackgrounded` hasn't arrived yet — short
/// bg shells can exit on the terminal's first poll). Keeps the late
/// `TaskBackgrounded` from inserting a fresh Running entry that nothing
/// would ever complete; see [`Self::absorb_late_backgrounded`].
pub fn tombstone_from_snapshot(
snapshot: &xai_grok_tools::types::TaskSnapshot,
status: BgTaskStatus,
description: Option<String>,
restored_from_replay: bool,
) -> Self {
let is_monitor = matches!(
snapshot.kind,
xai_grok_tools::computer::types::TaskKind::Monitor
) || snapshot
.display_command
.as_deref()
.is_some_and(|d| d.starts_with(MONITOR_PREFIX));
let mut tombstone = Self {
task_id: snapshot.task_id.clone(),
tool_call_id: String::new(),
command: snapshot.command.clone(),
description,
cwd: snapshot.cwd.clone(),
output_file: snapshot.output_file.to_string_lossy().into_owned(),
status,
start_time: snapshot.start_time,
end_time: Some(snapshot.end_time.unwrap_or_else(SystemTime::now)),
exit_code: snapshot.exit_code,
signal: snapshot.signal.clone(),
stdout: String::new(),
stdout_line_count: 0,
truncated: snapshot.truncated,
pending_kill: false,
kill_requested_at: None,
scrollback_entry_id: None,
is_monitor,
restored_from_replay,
};
if !snapshot.output.is_empty() {
let end = crate::render::line_utils::floor_char_boundary(
&snapshot.output,
BG_TASK_MAX_STDOUT,
);
tombstone.set_stdout(snapshot.output[..end].to_string());
if end < snapshot.output.len() {
tombstone.truncated = true;
}
}
tombstone
}
/// Fold a late `TaskBackgrounded` into an already-terminal entry: keep the
/// terminal status/exit/timing, backfill only what the completion snapshot
/// couldn't know (blank fields, demoted-Execute stdout, scrollback entry).
pub fn absorb_late_backgrounded(&mut self, fresh: BgTaskState, entry_id: Option<EntryId>) {
self.tool_call_id = fresh.tool_call_id;
if self.command.trim().is_empty() {
self.command = fresh.command;
}
if self
.description
.as_ref()
.is_none_or(|d| d.trim().is_empty())
{
self.description = fresh.description;
}
self.is_monitor |= fresh.is_monitor;
if self.stdout.is_empty() && !fresh.stdout.is_empty() {
self.stdout = fresh.stdout;
self.stdout_line_count = fresh.stdout_line_count;
self.truncated |= fresh.truncated;
}
if self.scrollback_entry_id.is_none() {
self.scrollback_entry_id = entry_id;
}
}
}
/// State for a scheduled (loop) task, displayed in the tasks pane.
#[derive(Debug, Clone)]
@ -1779,4 +1859,48 @@ mod tests {
vec!["hi /commit".to_string(), "go /push now".to_string()]
);
}
/// Folding a late `TaskBackgrounded` into a terminal tombstone keeps the
/// terminal state and backfills only what the snapshot couldn't know —
/// including a blank command (gateway-bridge completions synthesize one).
#[test]
fn absorb_late_backgrounded_backfills_without_resurrecting() {
let snapshot = xai_grok_tools::types::TaskSnapshot {
task_id: "t1".into(),
command: String::new(),
display_command: None,
cwd: "/tmp".into(),
start_time: SystemTime::now(),
end_time: None,
output: String::new(),
output_file: "/tmp/out.log".into(),
truncated: false,
exit_code: Some(0),
signal: None,
completed: true,
kind: Default::default(),
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: true,
output_total_bytes: 0,
};
let mut tombstone =
BgTaskState::tombstone_from_snapshot(&snapshot, BgTaskStatus::Done, None, false);
assert_eq!(tombstone.status, BgTaskStatus::Done);
assert!(tombstone.end_time.is_some(), "end_time falls back to now");
let mut fresh =
BgTaskState::tombstone_from_snapshot(&snapshot, BgTaskStatus::Running, None, false);
fresh.tool_call_id = "tc-1".into();
fresh.command = "echo hi".into();
fresh.description = Some("say hi".into());
fresh.set_stdout("demoted output".into());
tombstone.absorb_late_backgrounded(fresh, None);
assert_eq!(tombstone.status, BgTaskStatus::Done, "terminal status kept");
assert_eq!(tombstone.tool_call_id, "tc-1");
assert_eq!(tombstone.command, "echo hi", "blank command backfilled");
assert_eq!(tombstone.description.as_deref(), Some("say hi"));
assert_eq!(tombstone.stdout, "demoted output");
assert_eq!(tombstone.stdout_line_count, 1);
}
}

View file

@ -844,6 +844,7 @@ impl AgentView {
perm.active_idx = idx;
perm.focus =
crate::views::permission_view::PermissionFocus::Options;
self.permission_pattern_edit = None;
if is_double_click {
self.last_permission_click = None;
if let Some(opt) = perm.options.get(idx) {
@ -871,13 +872,20 @@ impl AgentView {
}
}
Event::Paste(text) => {
let in_followup = self.permission_queue.front().is_some_and(|p| {
p.focus == crate::views::permission_view::PermissionFocus::FollowupInput
});
if in_followup {
self.route_popup_paste(text)
} else {
InputOutcome::Changed
let front_focus = self.permission_queue.front().map(|p| p.focus);
match front_focus {
Some(crate::views::permission_view::PermissionFocus::FollowupInput) => {
self.route_popup_paste(text)
}
Some(crate::views::permission_view::PermissionFocus::PatternEdit) => {
if let Some(edit) = self.permission_pattern_edit.as_mut() {
for ch in text.chars().filter(|c| *c != '\n' && *c != '\r') {
edit.insert_char(ch);
}
}
InputOutcome::Changed
}
_ => InputOutcome::Changed,
}
}
_ => InputOutcome::Changed,
@ -1014,7 +1022,7 @@ impl AgentView {
_ => InputOutcome::Unchanged,
};
}
if self.question_view.is_some() && self.active_pane != AgentPane::Scrollback {
if self.is_question_focused() {
return match ev {
Event::Key(key) if key.kind != KeyEventKind::Release => {
if key!('q', CONTROL).matches(key) {
@ -1385,6 +1393,10 @@ impl AgentView {
other => resolve_action(Some(other)).unwrap_or(InputOutcome::Unchanged),
}
}
/// Whether an open `ask_user_question` card owns the keyboard.
pub(crate) fn is_question_focused(&self) -> bool {
self.question_view.is_some() && self.active_pane != AgentPane::Scrollback
}
/// Returns `true` if the switch happened immediately, `false` if blocked.
pub(crate) fn set_active_pane(&mut self, target: AgentPane, force: bool) -> bool {
if target != AgentPane::Scrollback {

View file

@ -20,6 +20,14 @@ use crate::views::question_view::QUESTION_VIEW_HPAD;
use crossterm::event::Event;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use std::time::Instant;
/// Which neighbouring question a key asked for, and where its cursor lands.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum QuestionSwitch {
Next,
Prev,
TabForward,
TabBackward,
}
impl AgentView {
/// Handle key input when the permission view is active.
///
@ -130,6 +138,25 @@ impl AgentView {
}
return InputOutcome::Changed;
}
let on_reject_once = perm.options.get(perm.active_idx).is_some_and(|o| {
o.kind == agent_client_protocol::PermissionOptionKind::RejectOnce
});
if key.code == KeyCode::Char('e')
&& key.modifiers.is_empty()
&& perm.has_editable_bash_pattern()
&& !on_reject_once
&& let Some(idx) = perm.options.iter().position(|o| {
o.kind == agent_client_protocol::PermissionOptionKind::AllowAlways
})
{
perm.active_idx = idx;
let initial = crate::views::permission_view::preview_command_text(perm);
self.permission_pattern_edit = Some(
crate::views::permission_view::PatternEditState::new(initial),
);
perm.focus = PermissionFocus::PatternEdit;
return InputOutcome::Changed;
}
if let Some(opt) = perm.options.get(perm.active_idx)
&& opt.kind == agent_client_protocol::PermissionOptionKind::RejectOnce
&& crate::input::key::is_text_input_key(key)
@ -141,6 +168,48 @@ impl AgentView {
}
InputOutcome::Changed
}
PermissionFocus::PatternEdit => {
if key.code == KeyCode::Esc {
self.permission_pattern_edit = None;
perm.focus = PermissionFocus::Options;
return InputOutcome::Changed;
}
if key!('c', CONTROL).matches(key) {
return InputOutcome::Action(Action::PermissionCancel);
}
let Some(edit) = self.permission_pattern_edit.as_mut() else {
perm.focus = PermissionFocus::Options;
return InputOutcome::Changed;
};
if key.code == KeyCode::Enter {
if edit.trimmed().is_some()
&& let Some(opt) = perm.options.iter().find(|o| {
o.kind == agent_client_protocol::PermissionOptionKind::AllowAlways
})
{
return InputOutcome::Action(Action::PermissionSelect(
opt.option_id.clone(),
));
}
return InputOutcome::Changed;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Backspace => edit.backspace(),
KeyCode::Delete => edit.delete(),
KeyCode::Left => edit.move_left(),
KeyCode::Right => edit.move_right(),
KeyCode::Home => edit.move_home(),
KeyCode::End => edit.move_end(),
KeyCode::Char('a') if ctrl => edit.move_home(),
KeyCode::Char('e') if ctrl => edit.move_end(),
KeyCode::Char('u') if ctrl => edit.clear(),
KeyCode::Char(c) if !ctrl && !alt => edit.insert_char(c),
_ => {}
}
InputOutcome::Changed
}
}
}
pub(super) fn handle_cancel_turn_key(&mut self, key: &KeyEvent) -> InputOutcome {
@ -224,12 +293,13 @@ impl AgentView {
/// Handle key input when the question view is active.
///
/// Two modes:
/// - **Navigation**: j/k move cursor, Space toggles, Enter advances or
/// edits freeform, h/l/[/] cycle questions, 1-9/a-f jump+toggle,
/// n next, s skip, Shift-X kill (only explicit way to dismiss).
/// - **Navigation**: j/k move the cursor between answers and Tab/Shift+Tab
/// walk the same rows in a loop, Space toggles, Enter advances or edits
/// freeform, h/l/[/] cycle questions, 1-9/a-f jump+toggle, Esc unselects,
/// Shift-X kills the question tool.
/// - **InputMode**: all keys go to the prompt widget; Esc exits input mode.
pub(super) fn handle_question_key(&mut self, key: &KeyEvent) -> InputOutcome {
use crate::views::question_view::{QuestionFocus, QuestionSelection};
use crate::views::question_view::{CursorMotion, QuestionFocus};
let Some(ref mut qv) = self.question_view else {
return InputOutcome::Unchanged;
};
@ -346,7 +416,7 @@ impl AgentView {
return InputOutcome::Changed;
}
let mut needs_scroll_update = false;
let mut needs_switch_question: Option<bool> = None;
let mut needs_switch_question: Option<QuestionSwitch> = None;
if qv.is_on_freeform_row()
&& (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT)
&& matches!(key.code, KeyCode::Char(c) if c != ' ')
@ -360,51 +430,37 @@ impl AgentView {
KeyCode::Char('j') | KeyCode::Down
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::CONTROL =>
{
let max = qv.total_items(qv.active_tab).saturating_sub(1);
let cur = qv.cursor();
if cur < max {
qv.set_cursor(cur + 1);
needs_scroll_update = true;
}
qv.move_cursor(CursorMotion::Next);
needs_scroll_update = true;
}
KeyCode::Char('k') | KeyCode::Up
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::CONTROL =>
{
let cur = qv.cursor();
if cur > 0 {
qv.set_cursor(cur - 1);
needs_scroll_update = true;
}
}
KeyCode::Char('d') if key.modifiers == KeyModifiers::CONTROL => {
let max = qv.total_items(qv.active_tab).saturating_sub(1);
let half = (max / 2).max(1);
qv.set_cursor((qv.cursor() + half).min(max));
qv.move_cursor(CursorMotion::Prev);
needs_scroll_update = true;
}
KeyCode::PageDown => {
let max = qv.total_items(qv.active_tab).saturating_sub(1);
let page = max.max(1);
qv.set_cursor((qv.cursor() + page).min(max));
KeyCode::Char('d') if key.modifiers == KeyModifiers::CONTROL => {
qv.move_cursor(CursorMotion::HalfPageDown);
needs_scroll_update = true;
}
KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => {
let half = (qv.total_items(qv.active_tab) / 2).max(1);
qv.set_cursor(qv.cursor().saturating_sub(half));
qv.move_cursor(CursorMotion::HalfPageUp);
needs_scroll_update = true;
}
KeyCode::PageDown => {
qv.move_cursor(CursorMotion::PageDown);
needs_scroll_update = true;
}
KeyCode::PageUp => {
let page = qv.total_items(qv.active_tab).saturating_sub(1).max(1);
qv.set_cursor(qv.cursor().saturating_sub(page));
qv.move_cursor(CursorMotion::PageUp);
needs_scroll_update = true;
}
KeyCode::Char('g') if key.modifiers.is_empty() => {
qv.set_cursor(0);
qv.move_cursor(CursorMotion::First);
needs_scroll_update = true;
}
KeyCode::Char('G') if key.modifiers == KeyModifiers::SHIFT => {
let max = qv.total_items(qv.active_tab).saturating_sub(1);
qv.set_cursor(max);
qv.move_cursor(CursorMotion::Last);
needs_scroll_update = true;
}
KeyCode::Char(' ') => {
@ -440,7 +496,7 @@ impl AgentView {
}
let last = qv.questions.len().saturating_sub(1);
if qv.active_tab < last {
needs_switch_question = Some(true);
needs_switch_question = Some(QuestionSwitch::Next);
} else {
return self.submit_question_answers(false);
}
@ -458,14 +514,14 @@ impl AgentView {
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::CONTROL =>
{
if qv.questions.len() > 1 {
needs_switch_question = Some(true);
needs_switch_question = Some(QuestionSwitch::Next);
}
}
KeyCode::Char('h') | KeyCode::Char('[') | KeyCode::Left
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::CONTROL =>
{
if qv.questions.len() > 1 {
needs_switch_question = Some(false);
needs_switch_question = Some(QuestionSwitch::Prev);
}
}
KeyCode::Char(c)
@ -487,7 +543,7 @@ impl AgentView {
}
let last = qv.questions.len().saturating_sub(1);
if qv.active_tab < last {
needs_switch_question = Some(true);
needs_switch_question = Some(QuestionSwitch::Next);
} else {
return self.submit_question_answers(false);
}
@ -520,38 +576,45 @@ impl AgentView {
return self.submit_question_answers(true);
}
let active = qv.active_tab;
if let Some(sel) = qv.selections.get_mut(active) {
match sel {
QuestionSelection::Multi(set) => {
set.clear();
}
QuestionSelection::Single(opt) => {
*opt = None;
}
}
}
if let Some(sel) = qv.per_question_freeform_selected.get_mut(active) {
*sel = false;
}
qv.clear_selection(active);
}
KeyCode::Tab => {
self.swap_question_freeform();
self.active_pane = AgentPane::Scrollback;
return InputOutcome::Changed;
KeyCode::Tab | KeyCode::BackTab => {
let backward = crate::input::key::is_shift_tab(key);
let crosses_questions = qv.questions.len() > 1;
needs_scroll_update = true;
match (backward, qv.is_on_first_row(), qv.is_on_last_row()) {
(false, _, false) => qv.move_cursor(CursorMotion::Next),
(true, false, _) => qv.move_cursor(CursorMotion::Prev),
(false, _, true) if crosses_questions => {
needs_switch_question = Some(QuestionSwitch::TabForward);
}
(true, true, _) if crosses_questions => {
needs_switch_question = Some(QuestionSwitch::TabBackward);
}
(false, _, true) => qv.move_cursor(CursorMotion::First),
(true, true, _) => qv.move_cursor(CursorMotion::Last),
}
}
KeyCode::Char('X') if key.modifiers == KeyModifiers::SHIFT => {
return self.submit_question_answers(true);
}
_ => {}
}
if let Some(forward) = needs_switch_question {
if let Some(switch) = needs_switch_question {
self.last_question_click = None;
self.swap_question_freeform();
if let Some(ref mut qv) = self.question_view {
if forward {
qv.next_question();
} else {
qv.prev_question();
match switch {
QuestionSwitch::Next => qv.next_question(),
QuestionSwitch::Prev => qv.prev_question(),
QuestionSwitch::TabForward => {
qv.wrapping_next_question();
qv.move_cursor(CursorMotion::First);
}
QuestionSwitch::TabBackward => {
qv.wrapping_prev_question();
qv.move_cursor(CursorMotion::Last);
}
}
}
self.load_question_freeform();
@ -1984,3 +2047,223 @@ mod question_freeform_chip_tests {
assert_eq!(paste_chip_count(&agent), 1, "chip must stay folded");
}
}
#[cfg(test)]
mod question_answer_focus_tests {
//! The question card's answer walk. Tab used to hand focus to the
//! scrollback while the card stayed drawn; these pin the walk that
//! replaced it.
use super::super::test_fixtures::make_agent;
use super::super::{AgentPane, AgentView};
use super::question_no_freeform_tests::open_question;
use crate::actions::ActionRegistry;
use crate::app::app_view::InputOutcome;
use crate::views::prompt_widget::StashedPrompt;
use crate::views::question_view::{
LocalQuestionKind, QuestionFocus, QuestionSelection, QuestionViewState,
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use xai_grok_tools::implementations::grok_build::ask_user_question::{
Question, QuestionOption,
};
fn question(prompt: &str, labels: &[&str]) -> Question {
Question {
question: prompt.into(),
options: labels
.iter()
.map(|label| QuestionOption {
label: (*label).into(),
description: "why".into(),
preview: None,
id: None,
})
.collect(),
multi_select: Some(false),
id: None,
}
}
fn open_two_questions(agent: &mut AgentView) {
agent.question_view = Some(QuestionViewState::new(
"tc-tab".into(),
vec![
question("First?", &["Alpha", "Beta"]),
question("Second?", &["Gamma", "Delta"]),
],
StashedPrompt::default(),
));
}
fn press(agent: &mut AgentView, code: KeyCode, modifiers: KeyModifiers) {
let _ = agent.handle_question_key_for_test(&KeyEvent::new(code, modifiers));
}
fn tab(agent: &mut AgentView) {
press(agent, KeyCode::Tab, KeyModifiers::NONE);
}
fn qv(agent: &AgentView) -> &QuestionViewState {
agent.question_view.as_ref().expect("question view open")
}
/// (question index, cursor row).
fn stop(agent: &AgentView) -> (usize, usize) {
(qv(agent).active_tab, qv(agent).cursor())
}
fn hint_labels(agent: &AgentView) -> Vec<String> {
agent
.current_shortcut_hints(&ActionRegistry::defaults(), false)
.iter()
.map(|hint| hint.label.to_string())
.collect()
}
#[test]
fn tab_walks_every_answer_and_wraps() {
let mut agent = make_agent();
open_two_questions(&mut agent);
let mut visited = vec![stop(&agent)];
for _ in 0..6 {
tab(&mut agent);
visited.push(stop(&agent));
}
assert_eq!(
visited,
vec![(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2), (0, 0)],
"Tab visits every answer row of question 1, then question 2, then wraps"
);
assert_eq!(
agent.active_pane,
AgentPane::Prompt,
"the card keeps the keyboard the whole way round"
);
}
#[test]
fn tab_wraps_within_a_single_question() {
let mut agent = make_agent();
open_question(&mut agent, false);
for expected in [1, 2, 0] {
tab(&mut agent);
assert_eq!(stop(&agent), (0, expected));
}
assert_eq!(agent.active_pane, AgentPane::Prompt);
}
/// Both Shift+Tab encodings terminals emit walk the answers backwards.
#[test]
fn shift_tab_walks_the_answers_backwards() {
for (code, modifiers) in [
(KeyCode::BackTab, KeyModifiers::NONE),
(KeyCode::Tab, KeyModifiers::SHIFT),
] {
let mut agent = make_agent();
open_two_questions(&mut agent);
for _ in 0..3 {
tab(&mut agent);
}
assert_eq!(stop(&agent), (1, 0), "parked on the second question");
press(&mut agent, code, modifiers);
assert_eq!(
stop(&agent),
(0, 2),
"Shift+Tab off the first row enters the previous question at its last row ({code:?})"
);
for _ in 0..2 {
press(&mut agent, code, modifiers);
}
assert_eq!(stop(&agent), (0, 0));
press(&mut agent, code, modifiers);
assert_eq!(
stop(&agent),
(1, 2),
"before the first answer, Shift+Tab wraps to the last one ({code:?})"
);
assert_eq!(agent.active_pane, AgentPane::Prompt);
}
}
#[test]
fn tab_from_the_scrollback_focuses_the_card() {
let mut agent = make_agent();
open_two_questions(&mut agent);
agent.active_pane = AgentPane::Scrollback;
let registry = ActionRegistry::defaults();
let outcome = agent
.handle_scrollback_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &registry);
assert!(
matches!(outcome, InputOutcome::Changed),
"Tab in the scrollback focuses the card, got {outcome:?}"
);
assert_eq!(agent.active_pane, AgentPane::Prompt);
}
#[test]
fn tab_skips_the_free_text_row_when_the_card_has_none() {
let mut agent = make_agent();
open_question(&mut agent, true);
tab(&mut agent);
assert_eq!(stop(&agent), (0, 1), "two options, so one step");
tab(&mut agent);
assert_eq!(
stop(&agent),
(0, 0),
"the last option wraps to the first when there is no free-text row"
);
}
#[test]
fn esc_unselects_and_leaves_focus_alone() {
let mut agent = make_agent();
open_two_questions(&mut agent);
press(&mut agent, KeyCode::Char(' '), KeyModifiers::NONE);
assert!(
matches!(qv(&agent).selections[0], QuestionSelection::Single(Some(0))),
"Space marks the focused answer"
);
press(&mut agent, KeyCode::Esc, KeyModifiers::NONE);
assert!(
matches!(qv(&agent).selections[0], QuestionSelection::Single(None)),
"Esc clears the answer"
);
assert_eq!(agent.active_pane, AgentPane::Prompt);
press(&mut agent, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(agent.active_pane, AgentPane::Prompt);
assert!(agent.question_view.is_some());
}
#[test]
fn esc_still_skips_the_project_picker() {
let mut agent = make_agent();
open_two_questions(&mut agent);
if let Some(ref mut qv) = agent.question_view {
qv.local_kind = Some(LocalQuestionKind::ProjectSelect {
resolved_paths: Vec::new(),
original_cwd: std::path::PathBuf::from("/tmp"),
stashed_prompt: String::new(),
dont_ask_index: 0,
});
}
press(&mut agent, KeyCode::Esc, KeyModifiers::NONE);
assert!(
agent.question_view.is_none(),
"Esc submits the picker as skipped instead of unselecting"
);
}
#[test]
fn tab_in_input_mode_stays_with_the_text_field() {
let mut agent = make_agent();
open_two_questions(&mut agent);
press(&mut agent, KeyCode::Char('z'), KeyModifiers::NONE);
assert_eq!(qv(&agent).focus, QuestionFocus::InputMode);
tab(&mut agent);
assert_eq!(
qv(&agent).focus,
QuestionFocus::InputMode,
"Tab must not walk the answers out from under a half-typed answer"
);
assert_eq!(agent.active_pane, AgentPane::Prompt);
}
/// The reported symptom was the bar promising one thing while Tab did
/// another, so the bar must name the walk at every stop.
#[test]
fn shortcut_hints_name_the_answer_walk() {
let mut agent = make_agent();
open_two_questions(&mut agent);
for step in 0..7 {
let hints = hint_labels(&agent);
assert!(
hints.contains(&"next answer".to_string()),
"step {step}: the bar advertises the answer walk, got {hints:?}"
);
tab(&mut agent);
}
}
}

View file

@ -1093,6 +1093,9 @@ pub struct AgentView {
pub hit_plan_button: HitArea,
pub hit_plan_approval_status: HitArea,
pub hit_follow_indicator: HitArea,
/// ▲ jump-to-response-top indicator in the sticky header's gap row
/// (click snaps the answer's first line to the top, same as `K`).
pub hit_response_top_indicator: HitArea,
/// CWD / worktree path in the status bar (click to copy).
pub hit_cwd: HitArea,
/// Cancel button in turn status line (`[stop]`).
@ -1326,6 +1329,10 @@ pub struct AgentView {
pub permission_stashed_prompt: Option<StashedPrompt>,
/// Scrollback focus stolen for a permission prompt; restored when the queue empties.
pub permission_stashed_pane: Option<AgentPane>,
/// Free-form "Always allow" pattern editor buffer for the front request.
/// `Some` only in `PermissionFocus::PatternEdit`; cleared when the request
/// resolves or the edit is cancelled.
pub permission_pattern_edit: Option<crate::views::permission_view::PatternEditState>,
/// Active plan approval view (from `exit_plan_mode` ext_method). When `Some`,
/// the prompt area shows the plan approval overlay and input is modal.
pub(crate) plan_approval_view: Option<PlanApprovalViewState>,

View file

@ -555,7 +555,7 @@ impl AgentView {
}
if let Some(ref mut viewer) = self.line_viewer {
if let Some(area) = viewer.last_popup_area
&& area.contains((col, row).into())
&& (area.contains((col, row).into()) || viewer.list_state.scrollbar_hit(col, row))
{
viewer
.list_state

View file

@ -9,11 +9,22 @@ use crate::app::actions::Action;
use crate::app::app_view::InputOutcome;
use crate::views::file_search::line_viewer::LineViewerState;
use crate::views::list_pane::ListItem;
use crate::views::plan_approval_view::{PlanApprovalFocus, PlanComment, PlanReviewSource};
use crate::views::plan_approval_view::{
PlanApprovalFocus, PlanApprovalViewState, PlanComment, PlanReviewSource,
};
use crate::views::prompt_widget::{EnterOutcome, PromptEvent};
#[cfg(test)]
use crossterm::event::KeyModifiers;
use crossterm::event::{KeyCode, KeyEvent};
/// Telemetry for every way a plan review resolves ("build", "abandon",
/// "revise").
fn log_plan_submit(action: &str) {
use xai_grok_telemetry::events::PlanSubmit;
use xai_grok_telemetry::session_ctx::log_event;
log_event(PlanSubmit {
action: action.to_string(),
});
}
impl AgentView {
/// Resolve the absolute path to the plan file for this session.
fn plan_file_path(&self) -> Option<std::path::PathBuf> {
@ -193,19 +204,7 @@ impl AgentView {
None
};
pav.send_approved();
self.latest_inline_plan_content = None;
self.plan_next_comment_id = pav.next_comment_id;
self.prompt.restore(pav.stashed_prompt);
self.line_viewer = None;
self.casual_commenting_range = None;
self.casual_editing_comment_id = None;
{
use xai_grok_telemetry::events::PlanSubmit;
use xai_grok_telemetry::session_ctx::log_event;
log_event(PlanSubmit {
action: "build".to_string(),
});
}
self.close_plan_review(pav, "build");
if let Some(text) = review_comments {
return InputOutcome::Action(Action::Interject {
text,
@ -219,6 +218,20 @@ impl AgentView {
return InputOutcome::Changed;
};
pav.send_abandoned();
self.close_plan_review(pav, "abandon");
InputOutcome::Changed
}
/// Shared teardown for the two plan-review decisions that end the
/// review (approve and abandon). The shell leaves plan mode as a
/// result, but its confirming `CurrentModeUpdate("default")` is
/// fire-and-forget and only arrives after the exit tool runs — so
/// flip the mode indicator optimistically here (a lost update would
/// otherwise leave the badge stuck on "plan"), restore the
/// pre-review UI, and log the decision.
///
/// Not for the revision path (`send_plan_feedback`): the shell
/// stays in plan mode there, so the indicator must stay on.
fn close_plan_review(&mut self, pav: PlanApprovalViewState, action: &'static str) {
self.plan_mode_pending = Some(false);
self.latest_inline_plan_content = None;
self.plan_next_comment_id = pav.next_comment_id;
@ -226,14 +239,7 @@ impl AgentView {
self.line_viewer = None;
self.casual_commenting_range = None;
self.casual_editing_comment_id = None;
{
use xai_grok_telemetry::events::PlanSubmit;
use xai_grok_telemetry::session_ctx::log_event;
log_event(PlanSubmit {
action: "abandon".to_string(),
});
}
InputOutcome::Changed
log_plan_submit(action);
}
fn send_plan_feedback(&mut self, feedback: Option<String>) -> InputOutcome {
let Some(mut pav) = self.plan_approval_view.take() else {
@ -260,13 +266,7 @@ impl AgentView {
self.line_viewer = None;
self.prompt.textarea.cancel_undo_group();
self.show_toast("Plan revision sent.");
{
use xai_grok_telemetry::events::PlanSubmit;
use xai_grok_telemetry::session_ctx::log_event;
log_event(PlanSubmit {
action: "revise".to_string(),
});
}
log_plan_submit("revise");
InputOutcome::Changed
}
pub(crate) fn reopen_plan_approval(&mut self) {
@ -993,3 +993,84 @@ mod plan_approval_enter_tests {
assert_eq!(agent.prompt.text(), "a");
}
}
/// The mode indicator renders
/// `plan_mode_pending.unwrap_or(plan_mode_active)`, and the shell's
/// confirming `CurrentModeUpdate("default")` only arrives after the exit
/// tool runs (and can be lost entirely). Resolving the review with a
/// decision must therefore optimistically clear the effective plan mode
/// on BOTH decision paths — approve and abandon.
#[cfg(test)]
mod plan_approval_optimistic_mode_tests {
use super::test_fixtures::make_agent;
use super::*;
use agent_client_protocol as acp;
fn agent_in_plan_mode_with_approval() -> (
AgentView,
tokio::sync::oneshot::Receiver<xai_acp_lib::AcpResult<acp::ExtResponse>>,
) {
let mut agent = make_agent();
agent.plan_mode_active = true;
let (tx, rx) = tokio::sync::oneshot::channel();
let request = crate::views::plan_approval_view::ExitPlanModeExtRequest {
session_id: "test-session".into(),
tool_call_id: "call-1".into(),
plan_content: Some("# Plan\n\n## Step 1\nDo something".into()),
};
let pav = crate::views::plan_approval_view::PlanApprovalViewState::new(
request,
agent.prompt.stash(),
tx,
);
agent.plan_approval_view = Some(pav);
(agent, rx)
}
fn effective_plan_mode(agent: &AgentView) -> bool {
agent.plan_mode_pending.unwrap_or(agent.plan_mode_active)
}
#[test]
fn approve_plan_optimistically_clears_plan_mode() {
let (mut agent, mut rx) = agent_in_plan_mode_with_approval();
assert!(effective_plan_mode(&agent));
agent.approve_plan();
assert_eq!(agent.plan_mode_pending, Some(false));
assert!(
!effective_plan_mode(&agent),
"indicator must leave plan mode immediately on approve, \
not wait for the shell's CurrentModeUpdate"
);
let raw = rx
.try_recv()
.expect("approval response must be sent")
.expect("Ok");
let parsed: serde_json::Value = serde_json::from_str(raw.0.get()).unwrap();
assert_eq!(parsed["outcome"], "approved");
}
/// Approve with review comments takes the early `Action::Interject`
/// return — the optimistic clear must happen before that branch.
#[test]
fn approve_plan_with_comments_still_clears_plan_mode() {
let (mut agent, _rx) = agent_in_plan_mode_with_approval();
if let Some(ref mut pav) = agent.plan_approval_view {
pav.comments
.push(crate::views::plan_approval_view::PlanComment {
id: 1,
line_range: 1..2,
text: "use the existing helper".into(),
});
}
let outcome = agent.approve_plan();
assert!(matches!(
outcome,
InputOutcome::Action(Action::Interject { .. })
));
assert_eq!(agent.plan_mode_pending, Some(false));
assert!(!effective_plan_mode(&agent));
}
#[test]
fn abandon_plan_optimistically_clears_plan_mode() {
let (mut agent, _rx) = agent_in_plan_mode_with_approval();
agent.abandon_plan();
assert_eq!(agent.plan_mode_pending, Some(false));
assert!(!effective_plan_mode(&agent));
}
}

View file

@ -131,6 +131,36 @@ impl AgentView {
PlanApprovalFocus::Preview => vec![HintItem::new(key!('y'), "copy plan")],
}
}
/// Shortcut hints for an open `ask_user_question` card.
fn question_shortcut_hints(
&self,
qv: &crate::views::question_view::QuestionViewState,
) -> Vec<HintItem> {
use crate::views::question_view::QuestionFocus;
match qv.focus {
QuestionFocus::InputMode if self.prompt.file_search_visible() => {
vec![
HintItem::paired(key!(Up), key!(Down), "nav"),
HintItem::new(key!(Tab), "accept"),
HintItem::new(key!(Right), "drill"),
HintItem::new(key!(Esc), "dismiss"),
]
}
QuestionFocus::InputMode => {
vec![
HintItem::new(key!(Enter), "submit"),
HintItem::new(key!(Esc), "back"),
]
}
QuestionFocus::Navigation => {
vec![
HintItem::new(key!(Tab), "next answer"),
HintItem::new(key!(Esc), "unselect"),
HintItem::new(key!('X'), "dismiss"),
]
}
}
}
/// Returns the *exact* hints the bottom shortcuts bar would render right now.
///
/// Single source of truth for context-sensitive shortcuts (pane, overlays,
@ -165,6 +195,12 @@ impl AgentView {
HintItem::new(key!(Esc), "back"),
]
}
PermissionFocus::PatternEdit => {
vec![
HintItem::new(key!(Enter), "save"),
HintItem::new(key!(Esc), "cancel"),
]
}
PermissionFocus::Options => {
use crate::input::key::KeyShortcut;
use crossterm::event::{KeyCode, KeyModifiers};
@ -175,6 +211,9 @@ impl AgentView {
if perm.has_adjustable_scope() {
hints.push(HintItem::paired(key!(Left), key!(Right), "scope"));
}
if perm.has_editable_bash_pattern() {
hints.push(HintItem::new(key!('e'), "edit pattern"));
}
if !perm.description.is_empty() {
let label = if perm.args_expanded {
"collapse"
@ -218,31 +257,7 @@ impl AgentView {
h
}
} else if let Some(ref qv) = self.question_view {
use crate::views::question_view::QuestionFocus;
match qv.focus {
QuestionFocus::InputMode => {
if self.prompt.file_search_visible() {
vec![
HintItem::paired(key!(Up), key!(Down), "nav"),
HintItem::new(key!(Tab), "accept"),
HintItem::new(key!(Right), "drill"),
HintItem::new(key!(Esc), "dismiss"),
]
} else {
vec![
HintItem::new(key!(Enter), "submit"),
HintItem::new(key!(Esc), "back"),
]
}
}
QuestionFocus::Navigation => {
vec![
HintItem::new(key!(Esc), "unselect"),
HintItem::new(key!(Tab), "scrollback"),
HintItem::new(key!('X'), "dismiss"),
]
}
}
self.question_shortcut_hints(qv)
} else if self.cancel_turn_view.is_some() {
vec![
HintItem::paired(key!('1'), key!('4'), "select"),
@ -1518,6 +1533,7 @@ impl AgentView {
self.hit_upgrade_cta
.set_unless_dropdown(upgrade_cta_rect, dropdown_open);
let mut inline_edit_cursor: Option<(u16, u16)> = None;
let sticky_gap_row: Option<u16>;
{
self.sync_pending_user_input_marks();
self.scrollback.set_cwd(Some(self.session.cwd.clone()));
@ -1551,6 +1567,7 @@ impl AgentView {
scratch,
);
let sb_output = sb_rendered.output;
sticky_gap_row = sb_output.sticky_gap_row;
self.update_scrollback_selection_state(
sb_output.selection_model.clone(),
sb_rendered.selection_boundaries,
@ -1745,6 +1762,8 @@ impl AgentView {
}
}
}
let mut follow_indicator_y: Option<u16> = None;
let mut response_top_indicator_y: Option<u16> = None;
if self.block_viewer.is_none() && !search_active {
use crate::appearance::FollowIndicator;
let gap_y = layout.scrollback.y + layout.scrollback.height;
@ -1771,33 +1790,35 @@ impl AgentView {
}
}
}
let show_indicator = appearance.scrollback.scroll.follow_indicator
!= FollowIndicator::None
&& !self.scrollback.is_follow_mode()
&& self.scrollback.has_content_below()
&& content_line_y.is_none();
if show_indicator {
let center_x = gap_x + gap_w / 2;
let indicator_style =
ratatui::style::Style::default().fg(if self.hit_follow_indicator.hovered {
theme.gray_bright
} else {
theme.gray
});
if let Some(cell) = buf.cell_mut((center_x, gap_y)) {
cell.set_symbol("");
cell.set_style(indicator_style);
if appearance.scrollback.scroll.follow_indicator != FollowIndicator::None {
if !self.scrollback.is_follow_mode()
&& self.scrollback.has_content_below()
&& content_line_y.is_none()
{
follow_indicator_y = Some(gap_y);
}
if self.scrollback.has_response_top_above() {
response_top_indicator_y = sticky_gap_row.map(|row| layout.scrollback.y + row);
}
self.hit_follow_indicator.set(Some(Rect::new(
center_x.saturating_sub(1),
gap_y,
3,
1,
)));
} else {
self.hit_follow_indicator.clear();
}
}
let indicator_center_x = layout.scrollback.x + layout.scrollback.width / 2;
draw_scroll_arrow(
buf,
&theme,
indicator_center_x,
follow_indicator_y,
"",
&mut self.hit_follow_indicator,
);
draw_scroll_arrow(
buf,
&theme,
indicator_center_x,
response_top_indicator_y,
"",
&mut self.hit_response_top_indicator,
);
if let Some(msg) = self.active_toast_message() {
let sb = layout.scrollback;
if let Some(toast_text) = fit_toast_text(msg, sb.width) {
@ -2345,6 +2366,7 @@ impl AgentView {
perm_area,
perm,
followup_text,
self.permission_pattern_edit.as_ref(),
self.hovered_permission_item,
&theme,
prompt_focused,
@ -3171,6 +3193,12 @@ impl AgentView {
HintItem::new(key!(Esc), "back"),
]
}
PermissionFocus::PatternEdit => {
vec![
HintItem::new(key!(Enter), "save"),
HintItem::new(key!(Esc), "cancel"),
]
}
PermissionFocus::Options => {
use crate::input::key::KeyShortcut;
use crossterm::event::{KeyCode, KeyModifiers};
@ -3181,6 +3209,9 @@ impl AgentView {
if perm.has_adjustable_scope() {
hints.push(HintItem::paired(key!(Left), key!(Right), "scope"));
}
if perm.has_editable_bash_pattern() {
hints.push(HintItem::new(key!('e'), "edit pattern"));
}
if !perm.description.is_empty() {
let label = if perm.args_expanded {
"collapse"
@ -3236,32 +3267,7 @@ impl AgentView {
.render(layout.shortcuts, buf);
}
} else if let Some(ref qv) = self.question_view {
use crate::views::question_view::QuestionFocus;
use crate::views::shortcuts_bar::HintItem;
let hints = match qv.focus {
QuestionFocus::InputMode => {
if self.prompt.file_search_visible() {
vec![
HintItem::paired(key!(Up), key!(Down), "nav"),
HintItem::new(key!(Tab), "accept"),
HintItem::new(key!(Right), "drill"),
HintItem::new(key!(Esc), "dismiss"),
]
} else {
vec![
HintItem::new(key!(Enter), "submit"),
HintItem::new(key!(Esc), "back"),
]
}
}
QuestionFocus::Navigation => {
vec![
HintItem::new(key!(Esc), "unselect"),
HintItem::new(key!(Tab), "scrollback"),
HintItem::new(key!('X'), "dismiss"),
]
}
};
let hints = self.question_shortcut_hints(qv);
ShortcutsBar::new(&hints).render(layout.shortcuts, buf);
} else if self.cancel_turn_view.is_some() {
use crate::views::shortcuts_bar::HintItem;
@ -3319,7 +3325,7 @@ impl AgentView {
let is_plan_viewer = self.is_plan_viewer();
let has_plan_comments = !self.plan_comments.is_empty();
let casual_commenting = self.is_casual_commenting();
if let Some(ref mut viewer) = self.line_viewer {
if self.line_viewer.is_some() {
use crate::views::file_search::line_viewer::render_line_viewer;
use crate::views::shortcuts_bar::HintItem;
let plan_prompt_focused = self
@ -3349,19 +3355,35 @@ impl AgentView {
} else {
self.plan_comments.len()
};
if let Some(ref pav) = self.plan_approval_view {
viewer.plan_mut().active_commenting_range = pav.commenting_range.clone();
} else {
viewer.plan_mut().active_commenting_range = self.casual_commenting_range.clone();
}
render_line_viewer(
buf,
overlay_area,
viewer,
&self.session.cwd,
&theme,
effective_comment_count,
);
let mermaid_placements = self
.line_viewer
.as_mut()
.map(|viewer| {
if let Some(ref pav) = self.plan_approval_view {
viewer.plan_mut().active_commenting_range = pav.commenting_range.clone();
} else {
viewer.plan_mut().active_commenting_range =
self.casual_commenting_range.clone();
}
render_line_viewer(
buf,
overlay_area,
viewer,
&self.session.cwd,
&theme,
effective_comment_count,
);
viewer
.last_popup_area
.map(|area| viewer.diagram_affordance_placements(area))
.unwrap_or_default()
})
.unwrap_or_default();
self.inline_media_hits = super::InlineMediaHitAreas::default();
self.paint_diagram_affordances(buf, mermaid_placements, &theme);
let Some(viewer) = self.line_viewer.as_mut() else {
return (prompt_cursor_pos, prompt_post_flush);
};
let toast_area = viewer
.last_popup_area
.or(viewer.last_modal_area)
@ -4361,6 +4383,34 @@ impl AgentView {
(cursor, prompt_post_flush)
}
}
/// Draw one ▼/▲ scroll-indicator arrow centered on row `y`, or clear its
/// hit area when hidden (`y: None`). The unconditional set-or-clear is the
/// point: a hit rect must never outlive the frame that painted its arrow,
/// or an invisible click target keeps firing under whatever covers it
/// (e.g. an open block viewer).
fn draw_scroll_arrow(
buf: &mut Buffer,
theme: &Theme,
center_x: u16,
y: Option<u16>,
symbol: &str,
hit: &mut super::HitArea,
) {
let Some(y) = y else {
hit.clear();
return;
};
let style = Style::default().fg(if hit.hovered {
theme.gray_bright
} else {
theme.gray
});
if let Some(cell) = buf.cell_mut((center_x, y)) {
cell.set_symbol(symbol);
cell.set_style(style);
}
hit.set(Some(Rect::new(center_x.saturating_sub(1), y, 3, 1)));
}
/// Pad `msg` for the toast slot, truncating with a trailing ellipsis when it
/// cannot fit in `avail_width` columns (long clipboard toasts embed backup
/// file paths — dropping the whole toast would hide the copy feedback

View file

@ -200,6 +200,7 @@ impl AgentView {
hit_plan_button: Default::default(),
hit_plan_approval_status: Default::default(),
hit_follow_indicator: Default::default(),
hit_response_top_indicator: Default::default(),
hit_cwd: Default::default(),
hit_cancel_button: Default::default(),
hit_watching_cue: Default::default(),
@ -276,6 +277,7 @@ impl AgentView {
next_perm_req_id: 0,
permission_stashed_prompt: None,
permission_stashed_pane: None,
permission_pattern_edit: None,
plan_approval_view: None,
latest_inline_plan_content: None,
plan_comments: Vec::new(),

View file

@ -8,7 +8,7 @@ use crate::scrollback::selection::SelectionBox;
use crate::scrollback::types::DisplayMode;
use crate::theme::Theme;
use crate::views::btw_overlay::BTW_OVERLAY_ENTRY_IDX;
use crate::views::file_search::line_viewer::LineViewerState;
use crate::views::file_search::line_viewer::{LineViewerState, PlanViewerItem};
use crate::views::list_pane::ListItem;
use crate::views::plan_approval_view::PlanApprovalFocus;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
@ -415,6 +415,47 @@ impl AgentView {
let is_plan_preview =
viewer.kind == crate::views::file_search::line_viewer::LineViewerKind::PlanPreview;
let scrollbar_owns_gesture = match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
viewer.list_state.scrollbar_hit(mouse.column, mouse.row)
}
MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) => {
viewer.list_state.is_scrollbar_dragging()
}
_ => false,
};
if scrollbar_owns_gesture {
viewer.list_state.handle_mouse_event(
mouse.kind,
mouse.column,
mouse.row,
popup_area.unwrap_or_default(),
&viewer.lines,
);
if is_plan_preview {
viewer.plan_mut().gutter_drag_start = None;
viewer.plan_mut().gutter_drag_end = None;
}
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
let was_commenting = self
.plan_approval_view
.as_ref()
.is_some_and(|pav| pav.focus == PlanApprovalFocus::Commenting);
if let Some(ref mut pav) = self.plan_approval_view {
pav.focus = PlanApprovalFocus::Preview;
if was_commenting {
pav.commenting_range = None;
pav.editing_comment_id = None;
pav.stashed_feedback_prompt = None;
}
}
if was_commenting {
self.prompt.set_text("");
}
}
return InputOutcome::Changed;
}
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
// Click on close button -> cancel.
@ -470,6 +511,19 @@ impl AgentView {
}
return self.send_casual_plan_comments();
}
// Mermaid buttons before click-to-comment (early return ends
// the `viewer` borrow so `handle_inline_media_click` can take
// `&mut self`).
let mermaid_hit = self
.inline_media_hits
.mermaid_buttons
.iter()
.any(|(rect, _, _)| rect.contains((mouse.column, mouse.row).into()));
if mermaid_hit {
return self
.handle_inline_media_click(mouse.column, mouse.row)
.unwrap_or(InputOutcome::Changed);
}
if modal_area.is_none_or(|a| !a.contains((mouse.column, mouse.row).into())) {
if self.plan_approval_view.is_some()
&& self
@ -509,6 +563,20 @@ impl AgentView {
}
MouseEventKind::Moved => {
let mut changed = false;
// Redraw only when mermaid button hover would change.
if self.last_mouse_pos != (mouse.column, mouse.row) {
let old = self.last_mouse_pos;
self.last_mouse_pos = (mouse.column, mouse.row);
let hits = |col: u16, row: u16| {
self.inline_media_hits
.mermaid_buttons
.iter()
.any(|(rect, _, _)| rect.contains((col, row).into()))
};
if hits(old.0, old.1) || hits(mouse.column, mouse.row) {
changed = true;
}
}
let close_hover =
close_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into()));
if close_hover != viewer.close_hovered {
@ -684,10 +752,21 @@ impl AgentView {
// edit-comment) for that row. Same shortcut as
// selecting + pressing `c` / Enter. Works for both
// plan-approval and casual plan-preview modes.
// Skip Mermaid affordance rows (button hits handled above).
let on_list_row = mouse.row >= area.y && {
let ry = (mouse.row - area.y) as usize;
let vy = viewer.list_state.scroll_offset() + ry;
viewer.list_state.layout().item_at_y(vy).is_some()
viewer
.list_state
.layout()
.item_at_y(vy)
.map(|vi| {
let pi = viewer.list_state.to_physical(vi);
viewer.lines.get(pi).is_some_and(|item| {
!matches!(item, PlanViewerItem::MermaidAffordance(_))
})
})
.unwrap_or(false)
};
// Skip the click-to-comment trigger if the user is
// already composing a comment. Without this guard, any
@ -1009,3 +1088,7 @@ impl AgentView {
})
}
}
#[cfg(test)]
#[path = "viewer_tests.rs"]
mod tests;

View file

@ -0,0 +1,460 @@
//! Mouse-routing tests for the line viewer's plan preview: the scrollbar
//! must own a click+drag gesture end-to-end. A press on the track was
//! previously also treated as a comment-gutter anchor (row-only hit test),
//! so dragging the thumb selected plan lines for a comment instead of
//! scrolling (GB-4579: "can't click and drag scrollbar to view plan").
use crossterm::event::{Event, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use crate::actions::ActionRegistry;
use crate::app::agent_view::AgentView;
use crate::app::agent_view::test_fixtures::make_agent;
use crate::views::plan_approval_view::PlanApprovalFocus;
const POPUP: Rect = Rect {
x: 0,
y: 0,
width: 80,
height: 10,
};
/// Scrollbar track column as split off by the list pane render
/// (`maybe_split_for_scrollbar`): last column of the popup area.
const TRACK_X: u16 = 79;
fn mouse(kind: MouseEventKind, col: u16, row: u16) -> Event {
Event::Mouse(MouseEvent {
kind,
column: col,
row,
modifiers: KeyModifiers::empty(),
})
}
/// Agent showing a plan-approval preview whose plan overflows the
/// viewport, with the render-time areas planted so mouse dispatch works.
fn agent_with_scrollable_plan() -> AgentView {
let mut agent = make_agent();
let (tx, _rx) = tokio::sync::oneshot::channel();
let plan: String = (1..=60).fold(String::new(), |mut acc, i| {
acc.push_str(&format!("step {i}\n"));
acc
});
let request = crate::views::plan_approval_view::ExitPlanModeExtRequest {
session_id: "test-session".into(),
tool_call_id: "call-1".into(),
plan_content: Some(plan),
};
agent.plan_approval_view = Some(
crate::views::plan_approval_view::PlanApprovalViewState::new(
request,
crate::views::prompt_widget::StashedPrompt {
text: String::new(),
cursor: 0,
images: Vec::new(),
chip_elements: Vec::new(),
image_counter: 0,
image_undo_stash: Vec::new(),
},
tx,
),
);
agent.show_plan_preview();
let viewer = agent
.line_viewer
.as_mut()
.expect("plan preview opens the line viewer");
viewer.prepare_layout(POPUP.width, POPUP.height);
viewer.last_popup_area = Some(POPUP);
viewer.last_modal_area = Some(Rect::new(0, 0, 80, 12));
viewer
.list_state
.set_scrollbar_area(Some(Rect::new(TRACK_X, POPUP.y, 1, POPUP.height)));
assert!(
viewer.list_state.total_height() > POPUP.height as usize,
"plan must overflow the viewport so the scrollbar is live"
);
agent
}
/// Presses on the modal border column next to the track (users read the
/// thumb + border as one two-column scrollbar) used to fall into the
/// click-outside-modal path instead of grabbing the thumb.
#[test]
fn border_column_press_grabs_scrollbar() {
let mut agent = agent_with_scrollable_plan();
let registry = ActionRegistry::defaults();
let _ = agent.handle_input(
&mouse(MouseEventKind::Down(MouseButton::Left), TRACK_X + 1, 5),
&registry,
);
let viewer = agent.line_viewer.as_ref().expect("viewer stays open");
assert!(
viewer.list_state.is_scrollbar_dragging(),
"press one column right of the track (modal border) must grab the thumb"
);
assert!(
viewer.list_state.scroll_offset() > 0,
"the press must scroll toward the clicked track position"
);
assert!(
viewer
.plan_ref()
.and_then(|p| p.gutter_drag_start)
.is_none(),
"a border-column press must not anchor a comment-gutter drag"
);
let pav = agent.plan_approval_view.as_ref().unwrap();
assert_eq!(pav.focus, PlanApprovalFocus::Preview);
let offset_after_press = agent
.line_viewer
.as_ref()
.unwrap()
.list_state
.scroll_offset();
let _ = agent.handle_input(
&mouse(MouseEventKind::Drag(MouseButton::Left), TRACK_X + 1, 9),
&registry,
);
let viewer = agent.line_viewer.as_ref().unwrap();
assert!(
viewer.list_state.scroll_offset() > offset_after_press,
"dragging on the border column must keep scrolling (offset {} -> {})",
offset_after_press,
viewer.list_state.scroll_offset()
);
}
#[test]
fn gap_column_press_grabs_scrollbar() {
let mut agent = agent_with_scrollable_plan();
let registry = ActionRegistry::defaults();
let _ = agent.handle_input(
&mouse(MouseEventKind::Down(MouseButton::Left), TRACK_X - 1, 5),
&registry,
);
let viewer = agent.line_viewer.as_ref().unwrap();
assert!(
viewer.list_state.is_scrollbar_dragging(),
"press on the gap column must grab the thumb"
);
assert!(
viewer
.plan_ref()
.and_then(|p| p.gutter_drag_start)
.is_none(),
"a gap-column press must not anchor a comment-gutter drag"
);
}
#[test]
fn border_column_press_does_not_close_casual_preview() {
let mut agent = agent_with_scrollable_plan();
agent.plan_approval_view = None;
let registry = ActionRegistry::defaults();
let _ = agent.handle_input(
&mouse(MouseEventKind::Down(MouseButton::Left), TRACK_X + 1, 5),
&registry,
);
let viewer = agent
.line_viewer
.as_ref()
.expect("a border-column press must not close the casual preview");
assert!(viewer.list_state.is_scrollbar_dragging());
}
#[test]
fn press_beyond_grab_zone_still_closes_casual_preview() {
let mut agent = agent_with_scrollable_plan();
agent.plan_approval_view = None;
let registry = ActionRegistry::defaults();
let _ = agent.handle_input(
&mouse(MouseEventKind::Down(MouseButton::Left), TRACK_X + 2, 5),
&registry,
);
assert!(
agent.line_viewer.is_none(),
"a click two columns right of the track is outside the modal and must close it"
);
}
#[test]
fn scrollbar_press_does_not_enter_commenting() {
let mut agent = agent_with_scrollable_plan();
let registry = ActionRegistry::defaults();
let _ = agent.handle_input(
&mouse(MouseEventKind::Down(MouseButton::Left), TRACK_X, 5),
&registry,
);
let viewer = agent.line_viewer.as_ref().unwrap();
assert!(
viewer.list_state.is_scrollbar_dragging(),
"press on the track must latch a scrollbar drag"
);
assert!(
viewer
.plan_ref()
.and_then(|p| p.gutter_drag_start)
.is_none(),
"press on the track must not anchor a comment-gutter drag"
);
let pav = agent.plan_approval_view.as_ref().unwrap();
assert_eq!(
pav.focus,
PlanApprovalFocus::Preview,
"press on the track must not enter commenting"
);
}
#[test]
fn scrollbar_drag_scrolls_plan_instead_of_selecting_lines() {
let mut agent = agent_with_scrollable_plan();
let registry = ActionRegistry::defaults();
let _ = agent.handle_input(
&mouse(MouseEventKind::Down(MouseButton::Left), TRACK_X, 2),
&registry,
);
let offset_after_press = agent
.line_viewer
.as_ref()
.unwrap()
.list_state
.scroll_offset();
// Drag the thumb to the bottom of the track.
let _ = agent.handle_input(
&mouse(MouseEventKind::Drag(MouseButton::Left), TRACK_X, 9),
&registry,
);
let viewer = agent.line_viewer.as_ref().unwrap();
assert!(
viewer.list_state.scroll_offset() > offset_after_press,
"dragging the thumb down must scroll the plan (offset {} -> {})",
offset_after_press,
viewer.list_state.scroll_offset()
);
assert!(
viewer.plan_ref().and_then(|p| p.gutter_drag_end).is_none(),
"thumb drag must not extend a comment line selection"
);
let _ = agent.handle_input(
&mouse(MouseEventKind::Up(MouseButton::Left), TRACK_X, 9),
&registry,
);
let viewer = agent.line_viewer.as_ref().unwrap();
assert!(
!viewer.list_state.is_scrollbar_dragging(),
"release must end the scrollbar drag"
);
let pav = agent.plan_approval_view.as_ref().unwrap();
assert_eq!(
pav.commenting_range, None,
"releasing the thumb must not open a comment on the dragged lines"
);
assert_eq!(pav.focus, PlanApprovalFocus::Preview);
}
/// The thumb must keep following the pointer when a drag drifts off the
/// popup rect (standard scrollbar behavior in every toolkit).
#[test]
fn scrollbar_drag_outside_popup_keeps_scrolling() {
let mut agent = agent_with_scrollable_plan();
let registry = ActionRegistry::defaults();
let _ = agent.handle_input(
&mouse(MouseEventKind::Down(MouseButton::Left), TRACK_X, 8),
&registry,
);
let offset_after_press = agent
.line_viewer
.as_ref()
.unwrap()
.list_state
.scroll_offset();
assert!(offset_after_press > 0, "press near the bottom scrolls down");
// Pointer drifts left of the track and above the popup while dragging.
let _ = agent.handle_input(
&mouse(MouseEventKind::Drag(MouseButton::Left), 40, 0),
&registry,
);
let viewer = agent.line_viewer.as_ref().unwrap();
assert!(
viewer.list_state.scroll_offset() < offset_after_press,
"drag toward the top of the track must scroll back up (offset {} -> {})",
offset_after_press,
viewer.list_state.scroll_offset()
);
assert!(
viewer.plan_ref().and_then(|p| p.gutter_drag_end).is_none(),
"scrollbar drag must never turn into a comment line selection"
);
}
/// A gutter line-selection whose Up was lost must not survive a later
/// scrollbar gesture: the track press drops the stale anchor, so a stray
/// release afterwards cannot commit the leftover lines as a comment.
#[test]
fn scrollbar_gesture_drops_stale_gutter_anchor() {
let mut agent = agent_with_scrollable_plan();
let registry = ActionRegistry::defaults();
// Anchor + extend a comment line selection, then lose the Up.
let _ = agent.handle_input(
&mouse(MouseEventKind::Down(MouseButton::Left), 10, 4),
&registry,
);
let _ = agent.handle_input(
&mouse(MouseEventKind::Drag(MouseButton::Left), 10, 6),
&registry,
);
{
let viewer = agent.line_viewer.as_ref().unwrap();
let start = viewer.plan_ref().and_then(|p| p.gutter_drag_start);
let end = viewer.plan_ref().and_then(|p| p.gutter_drag_end);
assert!(
start.is_some() && end.is_some() && start != end,
"precondition: a multi-line gutter drag is live (start {start:?}, end {end:?})"
);
}
// Scrollbar click + release: the track press must drop the stale anchor.
let _ = agent.handle_input(
&mouse(MouseEventKind::Down(MouseButton::Left), TRACK_X, 5),
&registry,
);
{
let viewer = agent.line_viewer.as_ref().unwrap();
assert!(viewer.list_state.is_scrollbar_dragging());
assert!(
viewer
.plan_ref()
.and_then(|p| p.gutter_drag_start)
.is_none()
&& viewer.plan_ref().and_then(|p| p.gutter_drag_end).is_none(),
"track press must drop a stale comment-gutter anchor"
);
}
let _ = agent.handle_input(
&mouse(MouseEventKind::Up(MouseButton::Left), TRACK_X, 5),
&registry,
);
// The track press also discarded the in-progress comment draft
// (same rule as clicking back into the modal).
let pav = agent.plan_approval_view.as_ref().unwrap();
assert_eq!(pav.commenting_range, None);
assert_eq!(pav.focus, PlanApprovalFocus::Preview);
// A stray release on content must not commit the leftover lines.
let _ = agent.handle_input(
&mouse(MouseEventKind::Up(MouseButton::Left), 10, 6),
&registry,
);
let pav = agent.plan_approval_view.as_ref().unwrap();
assert_eq!(
pav.commenting_range, None,
"stale gutter lines must not be committed as a comment range"
);
assert_eq!(
pav.focus,
PlanApprovalFocus::Preview,
"a stray release must not re-enter commenting"
);
}
/// A lost mouse-up after a track press must not make the next plan-line
/// click skip gutter / click-to-comment (sticky `is_scrollbar_dragging`).
#[test]
fn lost_scrollbar_up_does_not_block_next_line_click() {
let mut agent = agent_with_scrollable_plan();
let registry = ActionRegistry::defaults();
let _ = agent.handle_input(
&mouse(MouseEventKind::Down(MouseButton::Left), TRACK_X, 5),
&registry,
);
assert!(
agent
.line_viewer
.as_ref()
.unwrap()
.list_state
.is_scrollbar_dragging(),
"precondition: track press latched a thumb drag"
);
// No Up — simulate a dropped release, then click a plan line.
let _ = agent.handle_input(
&mouse(MouseEventKind::Down(MouseButton::Left), 10, 4),
&registry,
);
let viewer = agent.line_viewer.as_ref().unwrap();
assert!(
!viewer.list_state.is_scrollbar_dragging(),
"content Down must clear the stale scrollbar latch"
);
assert!(
viewer
.plan_ref()
.and_then(|p| p.gutter_drag_start)
.is_some(),
"content Down must still anchor a comment-gutter drag"
);
let pav = agent.plan_approval_view.as_ref().unwrap();
assert_eq!(
pav.focus,
PlanApprovalFocus::Commenting,
"content Down must still enter click-to-comment"
);
}
#[test]
fn wheel_on_border_column_scrolls_plan() {
let mut agent = agent_with_scrollable_plan();
let registry = ActionRegistry::defaults();
let _ = agent.handle_input(
&mouse(MouseEventKind::Down(MouseButton::Left), TRACK_X + 1, 9),
&registry,
);
let _ = agent.handle_input(
&mouse(MouseEventKind::Up(MouseButton::Left), TRACK_X + 1, 9),
&registry,
);
let off = agent
.line_viewer
.as_ref()
.unwrap()
.list_state
.scroll_offset();
assert!(off > 0, "border click near track bottom scrolls down");
agent.handle_scroll(-3, TRACK_X + 1, 5);
let off_after = agent
.line_viewer
.as_ref()
.unwrap()
.list_state
.scroll_offset();
assert!(
off_after < off,
"wheel-up on the border column must scroll up ({off} -> {off_after})"
);
}

View file

@ -3525,9 +3525,10 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
None => return InputOutcome::Changed,
},
PickerOutcome::SubmitQuery => {
let query = ctx.sp_state.query().trim().to_string();
if !query.is_empty() {
return InputOutcome::Action(Action::LoadSession(query, None, false));
if let Some(sid) =
crate::views::session_picker::session_id_for_direct_load(ctx.sp_state.query())
{
return InputOutcome::Action(Action::LoadSession(sid.to_string(), None, false));
}
return InputOutcome::Unchanged;
}

View file

@ -2367,41 +2367,10 @@ pub(super) fn dispatch_dashboard_permission_select(
return vec![];
};
let meta = if let Some(scope) = perm
.mcp_scope
.as_ref()
.filter(|_| option_id.0.as_ref() == "allow-always-mcp")
{
let selection = match scope.selected {
crate::views::permission_view::McpScope::Tool => {
xai_grok_workspace::permission::McpScopeSelection::Tool {
tool_name: scope.tool_name.clone(),
}
}
crate::views::permission_view::McpScope::Server => match &scope.server_prefix {
Some(prefix) => xai_grok_workspace::permission::McpScopeSelection::Server {
server: prefix.clone(),
},
None => xai_grok_workspace::permission::McpScopeSelection::Tool {
tool_name: scope.tool_name.clone(),
},
},
};
serde_json::to_value(selection)
.ok()
.and_then(|v| v.as_object().cloned())
} else if let Some(ref h) = perm.bash_highlights
&& perm.bash_selection_count > 0
{
let parts: Vec<String> = h.highlighted_words[..perm.bash_selection_count].to_vec();
serde_json::to_value(xai_grok_workspace::permission::BashCommandSelectedTerms {
command_parts: parts,
})
.ok()
.and_then(|v| v.as_object().cloned())
} else {
None
};
// Share the main dispatch's meta logic so dashboard peek honors an edited
// pattern (and the glob routing) identically instead of dropping it.
let edited_pattern = super::permissions::take_edited_pattern(agent, &perm);
let meta = super::permissions::build_selection_meta(&perm, &option_id, edited_pattern);
perm.request
.response_tx

View file

@ -10,6 +10,104 @@ use agent_client_protocol as acp;
// Permission dispatch
// ---------------------------------------------------------------------------
use crate::views::permission_view::{McpScope, PermissionFocus, PermissionViewState};
use xai_grok_workspace::permission::{BashCommandSelectedTerms, McpScopeSelection};
/// Free-form pattern taken from the editor on confirm.
pub(super) struct EditedPattern {
pub pattern: String,
/// True when the buffer was mutated — routes to `allowed_bash_globs`.
pub is_glob: bool,
}
/// Take the free-form pattern-editor buffer, honoring it only when the resolved
/// request was in `PatternEdit` focus (and always clearing it, so an abandoned
/// edit can't leak into a later prompt).
pub(super) fn take_edited_pattern(
agent: &mut AgentView,
perm: &PermissionViewState,
) -> Option<EditedPattern> {
agent
.permission_pattern_edit
.take()
.filter(|_| perm.focus == PermissionFocus::PatternEdit)
.and_then(|e| {
e.trimmed().map(|s| EditedPattern {
pattern: s.to_owned(),
is_glob: e.is_dirty(),
})
})
}
/// Build the ACP response meta for a permission selection — the single source
/// of truth shared by the main and dashboard dispatch paths. MCP scope wins for
/// the `allow-always-mcp` id; otherwise a free-form edited pattern (glob when
/// dirty) wins over the arrow word-scope (literal prefix). `None` when there is
/// nothing to scope.
pub(super) fn build_selection_meta(
perm: &PermissionViewState,
option_id: &acp::PermissionOptionId,
edited: Option<EditedPattern>,
) -> Option<serde_json::Map<String, serde_json::Value>> {
let obj = |v: serde_json::Value| v.as_object().cloned();
if let Some(scope) = perm
.mcp_scope
.as_ref()
.filter(|_| option_id.0.as_ref() == "allow-always-mcp")
{
let selection = match scope.selected {
McpScope::Tool => McpScopeSelection::Tool {
tool_name: scope.tool_name.clone(),
},
// Defensive: render disables Server when there is no prefix.
McpScope::Server => match &scope.server_prefix {
Some(prefix) => McpScopeSelection::Server {
server: prefix.clone(),
},
None => McpScopeSelection::Tool {
tool_name: scope.tool_name.clone(),
},
},
};
return serde_json::to_value(selection).ok().and_then(obj);
}
if let Some(edited) = edited.filter(|_| {
// The editor authors an *allow* pattern, so apply it only to the bash
// allow-always option. A different selection (reject-always, allow-once)
// made while the editor is open must fall through to the arrow word-scope
// — otherwise the allow text would land in the deny set.
option_id.0.as_ref() == "allow-always-command" && perm.bash_highlights.is_some()
}) {
// Glob only when the editor is dirty. Unedited save is a literal grant
// of the pre-filled command, so metacharacters that came from the
// command itself (e.g. `find . -name *.rs`) stay literal.
return serde_json::to_value(BashCommandSelectedTerms {
command_parts: vec![edited.pattern],
is_glob: edited.is_glob,
})
.ok()
.and_then(obj);
}
if let Some(h) = perm
.bash_highlights
.as_ref()
.filter(|_| perm.bash_selection_count > 0)
{
// Arrow word-scope: a literal command prefix, never a glob.
return serde_json::to_value(BashCommandSelectedTerms {
command_parts: h.highlighted_words[..perm.bash_selection_count].to_vec(),
is_glob: false,
})
.ok()
.and_then(obj);
}
None
}
/// Handle permission option selection (AllowOnce, AllowAlways, RejectAlways).
///
/// Pops the front request, sends the response, and handles queue transitions
@ -40,6 +138,8 @@ pub(super) fn dispatch_permission_select(
return vec![];
};
let edited_pattern = take_edited_pattern(agent, &perm);
// Detect the "enable always-approve mode" id BEFORE moving option_id
// into the response. Cheap str compare on the `Arc<str>` interior.
let enable_always_approve =
@ -72,46 +172,7 @@ pub(super) fn dispatch_permission_select(
);
}
// Build response meta. MCP and bash flows are mutually exclusive at
// the per-request level; check MCP first because it owns the
// `allow-always-mcp` option id and the bash branch is the existing
// fallback.
let meta = if let Some(scope) = perm
.mcp_scope
.as_ref()
.filter(|_| option_id.0.as_ref() == "allow-always-mcp")
{
let selection = match scope.selected {
crate::views::permission_view::McpScope::Tool => {
xai_grok_workspace::permission::McpScopeSelection::Tool {
tool_name: scope.tool_name.clone(),
}
}
crate::views::permission_view::McpScope::Server => match &scope.server_prefix {
Some(prefix) => xai_grok_workspace::permission::McpScopeSelection::Server {
server: prefix.clone(),
},
// Defensive: render path should disable Server when no prefix.
None => xai_grok_workspace::permission::McpScopeSelection::Tool {
tool_name: scope.tool_name.clone(),
},
},
};
serde_json::to_value(selection)
.ok()
.and_then(|v| v.as_object().cloned())
} else if let Some(ref h) = perm.bash_highlights
&& perm.bash_selection_count > 0
{
let parts: Vec<String> = h.highlighted_words[..perm.bash_selection_count].to_vec();
serde_json::to_value(xai_grok_workspace::permission::BashCommandSelectedTerms {
command_parts: parts,
})
.ok()
.and_then(|v| v.as_object().cloned())
} else {
None
};
let meta = build_selection_meta(&perm, &option_id, edited_pattern);
perm.request
.response_tx
@ -252,6 +313,9 @@ pub(super) fn drain_permission_queue(agent: &mut AgentView) {
/// - Queue still has items → clear prompt text and reset next front to Options.
pub(crate) fn resolve_permission_queue_transition(agent: &mut AgentView) {
agent.last_permission_click = None;
// The pattern editor is front-request scoped: drop any buffer when the
// front request is resolved (covers cancel/followup/select paths).
agent.permission_pattern_edit = None;
if agent.permission_queue.is_empty() {
restore_permission_stashes(agent);
} else {
@ -267,6 +331,7 @@ pub(crate) fn resolve_permission_queue_transition(agent: &mut AgentView) {
/// Restore composer + pane stashes when the permission queue empties.
pub(super) fn restore_permission_stashes(agent: &mut AgentView) {
agent.permission_pattern_edit = None;
if let Some(stashed) = agent.permission_stashed_prompt.take() {
agent.prompt.restore(stashed);
}

View file

@ -568,3 +568,214 @@ fn permission_select_reject_does_not_steer_sticky_cursor() {
"reject selection must not steer the sticky cursor"
);
}
/// Push a bash "Always allow" prompt (id `allow-always-command`) whose
/// arrow-scope covers `gh api`, returning the response receiver.
fn push_bash_allow_always(
agent: &mut crate::app::agent_view::AgentView,
focus: crate::views::permission_view::PermissionFocus,
) -> tokio::sync::oneshot::Receiver<Result<acp::RequestPermissionResponse, acp::Error>> {
use crate::views::permission_view::PermissionViewState;
use std::sync::Arc;
use xai_grok_workspace::permission::bash_command_splitting::BashCommandHighlights;
let (tx, rx) = tokio::sync::oneshot::channel();
let request = acp::RequestPermissionRequest::new(
acp::SessionId::new(Arc::from("sess")),
acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from("tc")),
acp::ToolCallUpdateFields::default(),
),
vec![
acp::PermissionOption::new(
acp::PermissionOptionId::new(Arc::from("allow-always-command")),
"Always allow",
acp::PermissionOptionKind::AllowAlways,
),
acp::PermissionOption::new(
acp::PermissionOptionId::new(Arc::from("reject-always-command")),
"Never allow",
acp::PermissionOptionKind::RejectAlways,
),
],
);
let options = request.options.clone();
agent.permission_queue.push_back(PermissionViewState {
request: xai_acp_lib::AcpArgs {
request,
response_tx: tx,
},
id: 1,
focus,
options,
active_idx: 0,
bash_highlights: Some(BashCommandHighlights {
prefix: vec![],
highlighted_words: vec!["gh".into(), "api".into()],
suffix: vec![],
}),
bash_selection_count: 2,
bash_command_raw: Some("gh api repos/owner/repo/pulls".into()),
mcp_scope: None,
title: "Allow command?".into(),
description: vec![],
args_expanded: false,
desc_scroll: 0,
subagent_label: None,
options_area_height: 0,
options_scroll_offset: 0,
});
rx
}
fn selected_terms(
resp: acp::RequestPermissionResponse,
) -> xai_grok_workspace::permission::BashCommandSelectedTerms {
let meta = resp.meta.expect("bash selection meta");
serde_json::from_value(serde_json::Value::Object(meta)).expect("selection terms")
}
/// An edit abandoned before dispatch (focus back to `Options`, e.g. via a
/// mouse click) must not persist its buffer: the resolved rule uses the
/// arrow-scope words, and the buffer is cleared.
#[test]
fn abandoned_pattern_edit_is_not_persisted() {
use crate::views::permission_view::{PatternEditState, PermissionFocus};
use std::sync::Arc;
let mut app = test_app_with_agent();
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
let mut rx = push_bash_allow_always(agent, PermissionFocus::Options);
agent.permission_pattern_edit = Some(PatternEditState::new("rm -rf /"));
let _ = dispatch(
Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from(
"allow-always-command",
))),
&mut app,
);
assert!(app.agents[&AgentId(0)].permission_pattern_edit.is_none());
let terms = selected_terms(rx.try_recv().expect("response").expect("ok"));
assert_eq!(terms.command_parts, vec!["gh", "api"]);
assert!(!terms.is_glob, "arrow-scope grant is literal, not a glob");
}
/// Seed a dirty editor buffer with the given text (insert+undo marks dirty).
fn dirty_pattern_edit(text: &str) -> crate::views::permission_view::PatternEditState {
let mut e = crate::views::permission_view::PatternEditState::new(text);
e.insert_char('x');
e.backspace();
debug_assert_eq!(e.buffer, text);
debug_assert!(e.is_dirty());
e
}
/// A pattern confirmed from `PatternEdit` focus is persisted verbatim as a glob
/// when the editor is dirty.
#[test]
fn confirmed_pattern_edit_is_persisted() {
use crate::views::permission_view::PermissionFocus;
use std::sync::Arc;
let mut app = test_app_with_agent();
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
let mut rx = push_bash_allow_always(agent, PermissionFocus::PatternEdit);
agent.permission_pattern_edit = Some(dirty_pattern_edit("gh api repos/owner/*"));
let _ = dispatch(
Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from(
"allow-always-command",
))),
&mut app,
);
assert!(app.agents[&AgentId(0)].permission_pattern_edit.is_none());
let terms = selected_terms(rx.try_recv().expect("response").expect("ok"));
assert_eq!(terms.command_parts, vec!["gh api repos/owner/*"]);
assert!(terms.is_glob, "dirty editor routes to the glob set");
}
/// A non-allow selection (e.g. reject-always) made while the editor is open
/// must not carry the edited *allow* text — it falls back to the arrow scope, so
/// the allow pattern can never land in the deny set.
#[test]
fn edited_pattern_not_applied_to_reject_option() {
use crate::views::permission_view::PermissionFocus;
use std::sync::Arc;
let mut app = test_app_with_agent();
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
let mut rx = push_bash_allow_always(agent, PermissionFocus::PatternEdit);
agent.permission_pattern_edit = Some(dirty_pattern_edit("gh api repos/owner/*"));
let _ = dispatch(
Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from(
"reject-always-command",
))),
&mut app,
);
let terms = selected_terms(rx.try_recv().expect("response").expect("ok"));
assert_eq!(
terms.command_parts,
vec!["gh", "api"],
"reject must use arrow scope, not the edited allow pattern"
);
assert!(!terms.is_glob);
}
/// Opening the editor and saving without edits is an exact grant, not a glob —
/// so a metacharacter that came from the command itself is not a wildcard.
#[test]
fn unedited_pattern_edit_is_literal_not_glob() {
use crate::views::permission_view::{PatternEditState, PermissionFocus};
use std::sync::Arc;
let mut app = test_app_with_agent();
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
let mut rx = push_bash_allow_always(agent, PermissionFocus::PatternEdit);
// Clean pre-fill (dirty=false); content may even contain metacharacters.
agent.permission_pattern_edit = Some(PatternEditState::new("gh api"));
let _ = dispatch(
Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from(
"allow-always-command",
))),
&mut app,
);
let terms = selected_terms(rx.try_recv().expect("response").expect("ok"));
assert!(
!terms.is_glob,
"unedited (clean) save is an exact grant, not a glob"
);
}
/// Editing then restoring the original text still counts as glob intent —
/// routing follows dirty, not string equality with the pre-fill.
#[test]
fn retyped_same_text_is_still_glob() {
use crate::views::permission_view::PermissionFocus;
use std::sync::Arc;
let mut app = test_app_with_agent();
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
let mut rx = push_bash_allow_always(agent, PermissionFocus::PatternEdit);
// Pre-fill is `gh api`; dirty but buffer equals pre-fill.
agent.permission_pattern_edit = Some(dirty_pattern_edit("gh api"));
let _ = dispatch(
Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from(
"allow-always-command",
))),
&mut app,
);
let terms = selected_terms(rx.try_recv().expect("response").expect("ok"));
assert_eq!(terms.command_parts, vec!["gh api"]);
assert!(
terms.is_glob,
"dirty editor is a glob even when text matches pre-fill"
);
}

View file

@ -4569,7 +4569,7 @@ fn format_session_info(
"{title_line} Shell version: {version_display}\n{auth_lines} Session ID: {session_id}{conversation_line}\n Working directory: {cwd}\n Model: {model_display}{model_hash_line}{backend_line}{sandbox_line}{turn_line}\n Context: {used} / {total} tokens ({pct}%)"
)
}
/// Auth section for `/session-info` — login method + where to manage account/credits.
/// Auth section for `/session-info` — active login method.
///
/// This reflects the process login / ACP auth method, not per-model sampling
/// credentials (a model `api_key`/`env_key` can still own the turn).
@ -4581,12 +4581,10 @@ fn format_auth_lines(is_api_key_auth: bool, api_key_env_set: bool) -> String {
" Auth method: API key\n"
};
return format!(
"{method} Manage account and credits: console.x.ai\n Run `grok login` to use your SuperGrok subscription instead.\n"
"{method} Run `grok login` to use your SuperGrok subscription instead.\n"
);
}
String::from(
" Auth method: OAuth\n Manage account and credits: https://grok.com/?_s=billing\n",
)
String::from(" Auth method: OAuth\n")
}
/// Build the single text content block for a plain `Effect::SendPrompt`.
///

View file

@ -2338,10 +2338,7 @@ fn format_session_info_session_auth_ignores_api_key_env() {
let info = make_session_info("auto", None, 1000, 10000);
let text = format_session_info(&info, None, false, false, true);
assert!(text.contains("Auth method: OAuth"), "{text}");
assert!(
text.contains("Manage account and credits: https://grok.com/?_s=billing"),
"{text}"
);
assert!(!text.contains("Manage account and credits"), "{text}");
assert!(!text.contains("Also present: XAI_API_KEY"), "{text}");
assert!(!text.contains("console.x.ai"), "{text}");
assert!(!text.contains("grok login"), "{text}");
@ -2352,10 +2349,7 @@ fn format_session_info_api_key_without_env() {
let text = format_session_info(&info, None, false, true, false);
assert!(text.contains("Auth method: API key\n"), "{text}");
assert!(!text.contains("XAI_API_KEY"), "{text}");
assert!(
text.contains("Manage account and credits: console.x.ai"),
"{text}"
);
assert!(!text.contains("Manage account and credits"), "{text}");
assert!(
text.contains("Run `grok login` to use your SuperGrok subscription instead."),
"{text}"
@ -2363,30 +2357,25 @@ fn format_session_info_api_key_without_env() {
assert!(!text.contains("grok.com"), "{text}");
}
#[test]
fn format_session_info_api_key_auth_notes_console_billing() {
fn format_session_info_api_key_auth_suggests_grok_login() {
let info = make_session_info("auto", None, 1000, 10000);
let text = format_session_info(&info, None, false, true, true);
assert!(text.contains("Auth method: API key (XAI_API_KEY)"), "{text}");
assert!(
text.contains("Manage account and credits: console.x.ai"),
"{text}"
);
assert!(!text.contains("Manage account and credits"), "{text}");
assert!(
text.contains("Run `grok login` to use your SuperGrok subscription instead."),
"{text}"
);
assert!(!text.contains("Also present: XAI_API_KEY"), "{text}");
assert!(!text.contains("console.x.ai"), "{text}");
assert!(!text.contains("grok.com"), "{text}");
}
#[test]
fn format_session_info_session_only_manage_at_grok_com() {
fn format_session_info_session_only_shows_oauth() {
let info = make_session_info("auto", None, 1000, 10000);
let text = format_session_info(&info, None, false, false, false);
assert!(text.contains("Auth method: OAuth"), "{text}");
assert!(
text.contains("Manage account and credits: https://grok.com/?_s=billing"),
"{text}"
);
assert!(!text.contains("Manage account and credits"), "{text}");
assert!(!text.contains("Also present: XAI_API_KEY"), "{text}");
assert!(!text.contains("console.x.ai"), "{text}");
assert!(!text.contains("grok login"), "{text}");

View file

@ -1089,10 +1089,15 @@ impl AgentView {
}
}
PickerOutcome::SubmitQuery => {
let query = state.query().trim().to_string();
if !query.is_empty() {
// Free-text load only for a UUID session id.
// Own the id before clearing the modal (state is a
// reborrow of `active_modal`).
let load_id =
crate::views::session_picker::session_id_for_direct_load(state.query())
.map(str::to_owned);
if let Some(sid) = load_id {
self.active_modal = None;
InputOutcome::Action(Action::LoadSession(query, None, false))
InputOutcome::Action(Action::LoadSession(sid, None, false))
} else {
InputOutcome::Unchanged
}
@ -2729,6 +2734,43 @@ mod session_picker_delete_tests {
"typing a query restores the selection highlight"
);
}
/// Paste garbage + Enter with no rows must not LoadSession.
#[test]
fn enter_with_garbage_query_does_not_load_session() {
let mut agent = make_agent();
open_picker(&mut agent, vec![]);
if let Some(ActiveModal::SessionPicker { state, .. }) = agent.active_modal.as_mut() {
state.set_query("this is pasted garbage!!!");
}
let out = agent.handle_palette_or_arg_input(&key_code(KeyCode::Enter));
assert!(
matches!(out, InputOutcome::Unchanged),
"garbage query must be a no-op, got {out:?}"
);
assert!(
matches!(agent.active_modal, Some(ActiveModal::SessionPicker { .. })),
"picker must stay open"
);
}
#[test]
fn enter_with_uuid_query_loads_session() {
let mut agent = make_agent();
open_picker(&mut agent, vec![]);
let sid = "019fb61a-85a5-7ba0-a4ec-24647dca1893";
if let Some(ActiveModal::SessionPicker { state, .. }) = agent.active_modal.as_mut() {
state.set_query(sid);
}
let out = agent.handle_palette_or_arg_input(&key_code(KeyCode::Enter));
assert!(
matches!(
out,
InputOutcome::Action(Action::LoadSession(ref id, None, false)) if id == sid
),
"UUID query should direct-load, got {out:?}"
);
}
}
#[cfg(test)]

View file

@ -273,6 +273,13 @@ impl AgentView {
self.scrollback.goto_bottom();
return InputOutcome::Changed;
}
if self
.hit_response_top_indicator
.contains(mouse.column, mouse.row)
{
self.scrollback.prev_response();
return InputOutcome::Changed;
}
if let Some(hd_area) = self.history_dropdown_area
&& hd_area.contains((mouse.column, mouse.row).into())
&& self.prompt.history_search.is_active()
@ -1098,6 +1105,9 @@ impl AgentView {
changed |= self
.hit_follow_indicator
.update_hover(mouse.column, mouse.row);
changed |= self
.hit_response_top_indicator
.update_hover(mouse.column, mouse.row);
changed |= self.hit_cancel_button.update_hover(mouse.column, mouse.row);
changed |= self.hit_bg_button.update_hover(mouse.column, mouse.row);
changed |= self.hit_watching_cue.update_hover(mouse.column, mouse.row);

View file

@ -43,6 +43,7 @@ fn unavailable_tmux() -> TmuxProbeFacts {
allow_passthrough_support: TmuxProbeResult::Unavailable,
allow_passthrough: TmuxProbeResult::Unavailable,
control_mode: TmuxProbeResult::Unavailable,
client_features: TmuxProbeResult::Unavailable,
}
}
@ -171,6 +172,7 @@ fn tmux_config_and_reload_notes_output_is_stable() {
allow_passthrough_support: TmuxProbeResult::Available(()),
allow_passthrough: TmuxProbeResult::Available("off".to_owned()),
control_mode: TmuxProbeResult::Available(false),
client_features: TmuxProbeResult::Unavailable,
},
&TMUX_ROUTE,
"pbcopy",
@ -202,17 +204,17 @@ fn tmux_config_and_reload_notes_output_is_stable() {
" ! terminal.tmux-clipboard `set-clipboard` is off in tmux, so OSC 52 clipboard copies are blocked\n",
" Automatic setup: `grok doctor fix tmux-clipboard`\n",
" Add `set -g set-clipboard on` to ~/.byobu/.tmux.conf\n",
" Note: Reload tmux with `tmux source-file ~/.byobu/.tmux.conf`, or detach and reattach.\n",
" Note: Reload tmux with `tmux source-file ~/.byobu/.tmux.conf`, or restart the tmux server.\n",
"\n",
" ! terminal.dcs-passthrough `allow-passthrough` is off in tmux, which can block clipboard copies in nested sessions\n",
" Automatic setup: `grok doctor fix dcs-passthrough`\n",
" Add `set -wg allow-passthrough on` to ~/.byobu/.tmux.conf\n",
" Note: Reload tmux with `tmux source-file ~/.byobu/.tmux.conf`, or detach and reattach.\n",
" Note: Reload tmux with `tmux source-file ~/.byobu/.tmux.conf`, or restart the tmux server.\n",
"\n",
" ! terminal.tmux-extended-keys `extended-keys` is off in tmux, so some shortcuts may not work\n",
" Automatic setup: `grok doctor fix tmux-extended-keys`\n",
" Add `set -g extended-keys on` to ~/.byobu/.tmux.conf\n",
" Note: Reload tmux with `tmux source-file ~/.byobu/.tmux.conf`, or detach and reattach.\n",
" Note: Reload tmux with `tmux source-file ~/.byobu/.tmux.conf`, or restart the tmux server.\n",
)
);
}
@ -393,6 +395,7 @@ fn unavailable_and_error_probes_do_not_create_false_issues() {
allow_passthrough_support: TmuxProbeResult::Unavailable,
allow_passthrough: TmuxProbeResult::Error("query failed".to_owned()),
control_mode: TmuxProbeResult::Unavailable,
client_features: TmuxProbeResult::Unavailable,
},
&TMUX_ROUTE,
"pbcopy",
@ -488,6 +491,7 @@ fn runtime_merge_does_not_duplicate_view_findings() {
allow_passthrough_support: TmuxProbeResult::Available(()),
allow_passthrough: TmuxProbeResult::Available("off".to_owned()),
control_mode: TmuxProbeResult::Available(false),
client_features: TmuxProbeResult::Unavailable,
},
&TMUX_ROUTE,
"pbcopy",
@ -620,6 +624,7 @@ fn keyboard_fact_formats_from_explicit_target_evidence() {
set_clipboard: crate::diagnostics::TmuxOptionFact::Unavailable,
allow_passthrough_support: crate::diagnostics::TmuxSupportFact::Unavailable,
allow_passthrough: crate::diagnostics::TmuxOptionFact::Unavailable,
color_passthrough: crate::diagnostics::TmuxColorPassthrough::Unknown,
},
color: ColorFacts {
level: RuntimeFact::Available(ColorLevel::TrueColor),

View file

@ -8,13 +8,16 @@ use xai_grok_config::managed_text::{
ManagedConfigStatus, ManagedItem, ManagedItemState, SyntaxValidator,
};
use crate::diagnostics::{DiagnosticId, DiagnosticReport, TmuxOptionFact, TmuxSupportFact};
use crate::diagnostics::{
DiagnosticId, DiagnosticReport, TmuxColorPassthrough, TmuxOptionFact, TmuxSupportFact,
};
use crate::terminal::{ByobuBackend, TerminalContext};
pub const SSH_WRAP_ID: DiagnosticId = DiagnosticId::new("terminal", "ssh-wrap");
pub const TMUX_CLIPBOARD_ID: DiagnosticId = DiagnosticId::new("terminal", "tmux-clipboard");
pub const DCS_PASSTHROUGH_ID: DiagnosticId = DiagnosticId::new("terminal", "dcs-passthrough");
pub const TMUX_EXTENDED_KEYS_ID: DiagnosticId = DiagnosticId::new("terminal", "tmux-extended-keys");
pub const TMUX_TRUECOLOR_ID: DiagnosticId = DiagnosticId::new("terminal", "tmux-truecolor");
pub const SSH_WRAP_FIX_COMMAND: &str = "grok doctor fix terminal.ssh-wrap";
pub const SSH_WRAP_ONE_OFF: &str = "grok wrap ssh <host>";
@ -405,6 +408,20 @@ enum TmuxEvidence {
Clipboard,
DcsPassthrough,
ExtendedKeys,
ColorPassthrough,
}
/// How a tmux remedy reaches its healthy state, which decides whether an
/// existing line elsewhere in the config can defeat Grok's managed block.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TmuxRemedy {
/// `set -g <option> <value>`: the last assignment wins, so a direct
/// assignment in the user's own config must be classified before writing.
Assignment,
/// `set -as <option> …`: tmux accumulates these and Grok appends its block
/// at the end of the file, so earlier lines add to the fix rather than
/// override it and are never a conflict.
Accumulating,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -412,7 +429,10 @@ struct TmuxOptionSpec {
id: DiagnosticId,
option: &'static str,
line: &'static str,
/// Values that already satisfy the fix. Empty for an accumulating remedy,
/// whose health comes from the attached client, not from one option value.
healthy_values: &'static [&'static str],
remedy: TmuxRemedy,
evidence: TmuxEvidence,
scope: TmuxOptionScope,
label: &'static str,
@ -423,6 +443,7 @@ const TMUX_CLIPBOARD_SPEC: TmuxOptionSpec = TmuxOptionSpec {
option: "set-clipboard",
line: "set -g set-clipboard on",
healthy_values: &["on", "external"],
remedy: TmuxRemedy::Assignment,
evidence: TmuxEvidence::Clipboard,
scope: TmuxOptionScope::Server,
label: "Enable tmux clipboard forwarding",
@ -432,6 +453,7 @@ const DCS_PASSTHROUGH_SPEC: TmuxOptionSpec = TmuxOptionSpec {
option: "allow-passthrough",
line: "set -wg allow-passthrough on",
healthy_values: &["on", "all"],
remedy: TmuxRemedy::Assignment,
evidence: TmuxEvidence::DcsPassthrough,
scope: TmuxOptionScope::Window,
label: "Enable tmux DCS passthrough",
@ -441,11 +463,23 @@ const TMUX_EXTENDED_KEYS_SPEC: TmuxOptionSpec = TmuxOptionSpec {
option: "extended-keys",
line: "set -g extended-keys on",
healthy_values: &["on"],
remedy: TmuxRemedy::Assignment,
evidence: TmuxEvidence::ExtendedKeys,
scope: TmuxOptionScope::Server,
label: "Enable tmux extended keys",
};
const TMUX_TRUECOLOR_SPEC: TmuxOptionSpec = TmuxOptionSpec {
id: TMUX_TRUECOLOR_ID,
option: "terminal-features",
line: "set -as terminal-features \",*:RGB\"",
healthy_values: &[],
remedy: TmuxRemedy::Accumulating,
evidence: TmuxEvidence::ColorPassthrough,
scope: TmuxOptionScope::Server,
label: "Enable tmux truecolor passthrough",
};
const FIX_REGISTRY: &[FixSpec] = &[
FixSpec {
id: SSH_WRAP_ID,
@ -475,6 +509,13 @@ const FIX_REGISTRY: &[FixSpec] = &[
command: "grok doctor fix terminal.tmux-extended-keys",
kind: FixKind::TmuxOption(&TMUX_EXTENDED_KEYS_SPEC),
},
FixSpec {
id: TMUX_TRUECOLOR_ID,
handle: "tmux-truecolor",
label: TMUX_TRUECOLOR_SPEC.label,
command: "grok doctor fix terminal.tmux-truecolor",
kind: FixKind::TmuxOption(&TMUX_TRUECOLOR_SPEC),
},
];
fn fix_spec(id: DiagnosticId) -> Option<&'static FixSpec> {
@ -624,7 +665,8 @@ pub(crate) fn format_fix_preview(plan: &FixPlan) -> String {
);
}
FixPayload::TmuxOption(payload) => {
let instruction = reload_instruction(&plan.change.requested_path);
let instruction =
tmux_activation_instruction(payload.spec, &plan.change.requested_path);
let _ = writeln!(
output,
"\nWhat this changes:\n Persists `{}`.\n Grok does not reload or modify the live tmux server.\n After applying, {instruction}\n Run /doctor again to verify the live setting.",
@ -723,11 +765,14 @@ fn plan_tmux_option(
validator: None,
})
.map_err(FixError::TmuxManaged)?;
let direct = scan_direct_tmux_option(
managed.inspection().unmanaged_text(),
managed.target_path(),
spec,
)?;
let direct = match spec.remedy {
TmuxRemedy::Assignment => scan_direct_tmux_option(
managed.inspection().unmanaged_text(),
managed.target_path(),
spec,
)?,
TmuxRemedy::Accumulating => DirectOptionState::Absent,
};
let item_state = managed
.inspection()
.requested_item_state(0)
@ -745,10 +790,7 @@ fn plan_tmux_option(
Ok(FixPlan {
id: request.id,
change,
caveats: vec![
"The live tmux server is unchanged until you reload this config or detach and reattach.",
TMUX_SCANNER_CAVEAT,
],
caveats: tmux_caveats(spec.remedy),
payload: FixPayload::TmuxOption(TmuxOptionPlan {
spec,
managed,
@ -761,8 +803,26 @@ fn plan_tmux_option(
})
}
fn tmux_caveats(remedy: TmuxRemedy) -> Vec<&'static str> {
match remedy {
TmuxRemedy::Assignment => vec![
"The live tmux server is unchanged until you reload this config or restart it.",
TMUX_SCANNER_CAVEAT,
],
// Reloading is not enough on its own: tmux fixes a client's feature set
// when that client attaches.
TmuxRemedy::Accumulating => vec![
"Reloading alone is not enough: the attached client keeps its current color depth until it reattaches.",
"Terminals that cannot render 24-bit color ignore the extra escape sequence.",
],
}
}
fn tmux_evidence_is_applicable(report: &DiagnosticReport, spec: &TmuxOptionSpec) -> bool {
match spec.evidence {
TmuxEvidence::ColorPassthrough => {
report.facts.tmux.color_passthrough == TmuxColorPassthrough::Reduced
}
TmuxEvidence::Clipboard => matches!(
&report.facts.tmux.set_clipboard,
TmuxOptionFact::Available(value)
@ -890,12 +950,8 @@ fn fix_outcome(
pub(crate) fn format_fix_success(outcome: &FixOutcome) -> String {
let path = markdown_code_path(outcome.changed_path());
let kind = match outcome.id {
SSH_WRAP_ID => FixKind::SshWrap,
TMUX_CLIPBOARD_ID => FixKind::TmuxOption(&TMUX_CLIPBOARD_SPEC),
DCS_PASSTHROUGH_ID => FixKind::TmuxOption(&DCS_PASSTHROUGH_SPEC),
TMUX_EXTENDED_KEYS_ID => FixKind::TmuxOption(&TMUX_EXTENDED_KEYS_SPEC),
_ => return "Applied the Doctor fix.".to_owned(),
let Some(kind) = fix_spec(outcome.id).map(|spec| spec.kind) else {
return "Applied the Doctor fix.".to_owned();
};
let status = match (kind, outcome.status) {
(FixKind::SshWrap, FixStatus::Applied) => format!("Set up SSH wrapping in {path}."),
@ -917,9 +973,9 @@ pub(crate) fn format_fix_success(outcome: &FixOutcome) -> String {
(FixKind::SshWrap, FixActivation::SatisfiedNow) => {
"\nStart a new shell to use the alias.".to_owned()
}
(FixKind::TmuxOption(_), FixActivation::RequiresReload) => format!(
(FixKind::TmuxOption(tmux), FixActivation::RequiresReload) => format!(
"\n{}\nRun /doctor again to verify the live setting.",
reload_instruction(outcome.changed_path())
tmux_activation_instruction(tmux, outcome.changed_path())
),
_ => String::new(),
};
@ -971,13 +1027,32 @@ fn shell_quote_path(path: &Path) -> Option<String> {
Some(format!("'{}'", value.replace('\'', "'\\''")))
}
/// An accumulating remedy needs both steps: the server reads the new option
/// only on reload, and a client resolves its feature set only at attach, so
/// neither reloading nor reattaching alone changes anything.
fn tmux_activation_instruction(spec: &TmuxOptionSpec, path: &Path) -> String {
match spec.remedy {
TmuxRemedy::Assignment => reload_instruction(path),
TmuxRemedy::Accumulating => match shell_quote_path(path) {
Some(shell_path) => format!(
"Run {}, then detach and reattach: only clients that attach after the reload get \
24-bit color.",
commonmark_code_span(&format!("tmux source-file {shell_path}"))
),
None => "Reload your tmux config, then detach and reattach: only clients that attach \
after the reload get 24-bit color."
.to_owned(),
},
}
}
fn reload_instruction(path: &Path) -> String {
let Some(shell_path) = shell_quote_path(path) else {
return "Detach and reattach to activate the persistent tmux setting.".to_owned();
return "Reload your tmux config, or restart the tmux server, to activate the persistent setting.".to_owned();
};
let command = format!("tmux source-file {shell_path}");
format!(
"Reload tmux with {}, or detach and reattach.",
"Reload tmux with {}, or restart the tmux server.",
commonmark_code_span(&command)
)
}
@ -1006,11 +1081,17 @@ fn tmux_option_configured(path: &Path, spec: &'static TmuxOptionSpec) -> bool {
comments: CommentSyntax::hash(),
validator: None,
};
ManagedConfig::plan(request).is_ok_and(|plan| {
let direct =
scan_direct_tmux_option(plan.inspection().unmanaged_text(), plan.target_path(), spec);
matches!(direct, Ok(DirectOptionState::Healthy))
|| !plan.changes_file() && matches!(direct, Ok(DirectOptionState::Absent))
ManagedConfig::plan(request).is_ok_and(|plan| match spec.remedy {
TmuxRemedy::Accumulating => !plan.changes_file(),
TmuxRemedy::Assignment => {
let direct = scan_direct_tmux_option(
plan.inspection().unmanaged_text(),
plan.target_path(),
spec,
);
matches!(direct, Ok(DirectOptionState::Healthy))
|| !plan.changes_file() && matches!(direct, Ok(DirectOptionState::Absent))
}
})
}

View file

@ -17,6 +17,7 @@ pub(super) fn report() -> DiagnosticReport {
set_clipboard: crate::diagnostics::TmuxOptionFact::Unavailable,
allow_passthrough_support: crate::diagnostics::TmuxSupportFact::Unavailable,
allow_passthrough: crate::diagnostics::TmuxOptionFact::Unavailable,
color_passthrough: crate::diagnostics::TmuxColorPassthrough::Unknown,
},
color: crate::diagnostics::ColorFacts {
level: crate::diagnostics::RuntimeFact::Unavailable,
@ -177,6 +178,11 @@ fn tmux_report(id: DiagnosticId, evidence: TmuxEvidence) -> DiagnosticReport {
}
.to_owned(),
),
color_passthrough: if evidence == TmuxEvidence::ColorPassthrough {
crate::diagnostics::TmuxColorPassthrough::Reduced
} else {
crate::diagnostics::TmuxColorPassthrough::Forwarded
},
};
report.findings.push(DiagnosticFinding {
id,
@ -251,6 +257,11 @@ fn tmux_specs_plan_exact_independent_managed_items() {
TmuxEvidence::ExtendedKeys,
"set -g extended-keys on",
),
(
TMUX_TRUECOLOR_ID,
TmuxEvidence::ColorPassthrough,
"set -as terminal-features \",*:RGB\"",
),
] {
let plan = plan_fix(
tmux_request(temp.path(), id),
@ -296,11 +307,11 @@ fn safe_absolute_directory_rejects_hostile_home_and_byobu_values() {
fn reload_instruction_shell_quotes_and_markdown_escapes_paths() {
assert_eq!(
reload_instruction(Path::new("/tmp/a b/q'v.conf")),
"Reload tmux with `tmux source-file '/tmp/a b/q'\\''v.conf'`, or detach and reattach."
"Reload tmux with `tmux source-file '/tmp/a b/q'\\''v.conf'`, or restart the tmux server."
);
assert_eq!(
reload_instruction(Path::new("/tmp/a`b.conf")),
"Reload tmux with ``tmux source-file '/tmp/a`b.conf'``, or detach and reattach."
"Reload tmux with ``tmux source-file '/tmp/a`b.conf'``, or restart the tmux server."
);
assert_eq!(
shell_quote_path(Path::new("/tmp/a`b.conf")).unwrap(),
@ -308,7 +319,7 @@ fn reload_instruction_shell_quotes_and_markdown_escapes_paths() {
);
assert_eq!(
reload_instruction(Path::new("/tmp/bad\npath")),
"Detach and reattach to activate the persistent tmux setting."
"Reload your tmux config, or restart the tmux server, to activate the persistent setting."
);
assert_eq!(markdown_code_path(Path::new("/tmp/a`b")), "``/tmp/a`b``");
}
@ -407,6 +418,11 @@ fn tmux_managed_items_coexist_and_each_apply_is_one_transaction() {
TmuxEvidence::ExtendedKeys,
"set -g extended-keys on",
),
(
TMUX_TRUECOLOR_ID,
TmuxEvidence::ColorPassthrough,
"set -as terminal-features \",*:RGB\"",
),
] {
let plan = plan_fix(
tmux_request(temp.path(), id),
@ -422,7 +438,12 @@ fn tmux_managed_items_coexist_and_each_apply_is_one_transaction() {
}
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content.matches("# >>> grok doctor >>>").count(), 1);
for id in [TMUX_CLIPBOARD_ID, DCS_PASSTHROUGH_ID, TMUX_EXTENDED_KEYS_ID] {
for id in [
TMUX_CLIPBOARD_ID,
DCS_PASSTHROUGH_ID,
TMUX_EXTENDED_KEYS_ID,
TMUX_TRUECOLOR_ID,
] {
assert_eq!(content.matches(&format!("# >>> {id} >>>")).count(), 1);
}
}
@ -1190,3 +1211,45 @@ fn shell_aliases_expand_to_exact_argv_and_bypass_is_explicit() {
eprintln!("fish unavailable; fish runtime alias test skipped explicitly");
}
}
/// An accumulating remedy is additive, so a user's own `terminal-features`
/// lines are not a conflict: tmux applies Grok's managed block last and the
/// features merge. A direct-assignment remedy would refuse to touch the file.
#[test]
fn tmux_truecolor_fix_appends_alongside_existing_terminal_features() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(".tmux.conf");
std::fs::write(
&path,
"set -g mouse on\nset -as terminal-features \",xterm-256color:RGB\"\n",
)
.unwrap();
let plan = plan_fix(
tmux_request(temp.path(), TMUX_TRUECOLOR_ID),
&tmux_report(TMUX_TRUECOLOR_ID, TmuxEvidence::ColorPassthrough),
&tmux_terminal(false),
)
.unwrap();
let outcome = apply_fix(plan).unwrap();
assert_eq!(outcome.status(), FixStatus::Applied);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("set -as terminal-features \",xterm-256color:RGB\""));
assert!(content.contains("set -as terminal-features \",*:RGB\""));
}
#[test]
fn tmux_truecolor_fix_requires_a_reducing_client() {
let temp = tempfile::tempdir().unwrap();
let report = tmux_report(TMUX_TRUECOLOR_ID, TmuxEvidence::Clipboard);
assert!(matches!(
plan_fix(
tmux_request(temp.path(), TMUX_TRUECOLOR_ID),
&report,
&tmux_terminal(false)
),
Err(FixError::TmuxNotApplicable)
));
}

View file

@ -22,9 +22,9 @@ pub(crate) use fix::test_fix_plan;
pub use fix::{
AutomaticRemediation, DCS_PASSTHROUGH_ID, FixActivation, FixError, FixOutcome, FixPlan,
FixRequest, FixStatus, PlannedChange, SSH_WRAP_FIX_COMMAND, SSH_WRAP_ID, SSH_WRAP_ONE_OFF,
ShellKind, TMUX_CLIPBOARD_ID, TMUX_EXTENDED_KEYS_ID, apply_fix, configured_report,
managed_alias_configured, plan_fix, resolve_fix_id, ssh_wrap_automatic_remediation,
verify_persistent_fix,
ShellKind, TMUX_CLIPBOARD_ID, TMUX_EXTENDED_KEYS_ID, TMUX_TRUECOLOR_ID, apply_fix,
configured_report, managed_alias_configured, plan_fix, resolve_fix_id,
ssh_wrap_automatic_remediation, verify_persistent_fix,
};
pub(crate) use fix::{
automatic_fix_choices, automatic_remediation_for, format_applicable_automatic_fixes,
@ -40,7 +40,8 @@ pub(crate) use model::{
pub use model::{
ClipboardFacts, ColorFacts, DataControlFact, DiagnosticFacts, DiagnosticFinding, DiagnosticId,
DiagnosticReport, FindingDisposition, KeyboardFact, ManualRemediation, NewlineFact, ProbeNote,
ProbeStatus, RuntimeFact, TmuxFacts, TmuxOptionFact, TmuxSupportFact, VoiceFacts,
ProbeStatus, RuntimeFact, TmuxColorPassthrough, TmuxFacts, TmuxOptionFact, TmuxSupportFact,
VoiceFacts,
};
pub use view::{DiagnosticSnapshot, view};
@ -130,6 +131,9 @@ pub enum WarningCategory {
WaylandNoDataControl,
/// Below truecolor: truecolor themes hidden. Explicit `/doctor` only.
LimitedColorSupport,
/// tmux is attached to a client it believes cannot render 24-bit color, so
/// it rewrites every truecolor cell to the client terminfo's palette.
TmuxColorReduced,
SandboxProfileConflict,
/// The session runs over SSH without `grok wrap` on the local end, so
/// clipboard forwarding and terminal-mode restore on dropped connections
@ -702,7 +706,7 @@ pub(crate) fn merge_tui_runtime_findings(
}
fn tmux_reload_note(config_path: &str) -> String {
format!("Reload tmux with `tmux source-file {config_path}`, or detach and reattach.")
format!("Reload tmux with `tmux source-file {config_path}`, or restart the tmux server.")
}
fn diagnose_clipboard_from_facts(
@ -951,16 +955,13 @@ pub fn format_clipboard_diagnostics(input: ClipboardDiagnosticsInput<'_>) -> Cli
/// Not in `collect_startup_warnings` — limited color is normal on some
/// emulators and would spam the welcome banner.
pub fn color_support_warning(
level: ColorLevel,
level: probes::RuntimeEvidence<ColorLevel>,
brand: TerminalName,
color_passthrough: TmuxColorPassthrough,
is_tmux_backed: bool,
tmux_config_path: &str,
) -> Option<TerminalWarning> {
if level.has_truecolor() {
return None;
}
if level == ColorLevel::None {
if level == probes::RuntimeEvidence::Available(ColorLevel::None) {
let mut warning = TerminalWarning::new(
WarningCategory::LimitedColorSupport,
"Colors are off because `NO_COLOR` is set",
@ -971,6 +972,35 @@ pub fn color_support_warning(
return Some(warning);
}
// Checked before the detected level is consulted at all: the level says
// what Grok emits, which is a different question from what survives tmux.
// A truecolor detection is not evidence that truecolor reaches the
// terminal, and a session with no color evidence (piped `grok doctor`)
// still has a clamping client worth reporting.
if color_passthrough == TmuxColorPassthrough::Reduced {
let mut warning = TerminalWarning::new(
WarningCategory::TmuxColorReduced,
"tmux is reducing 24-bit color to this client's palette, so themes look washed out",
Some("set -as terminal-features \",*:RGB\""),
Some(tmux_config_path),
);
warning.note = Some(format!(
"Run `tmux source-file {tmux_config_path}`, then detach and reattach: the server \
reads the option only on reload, and a client fixes its color depth only at attach. \
If Grok still reports less than truecolor afterwards, also add `set -g \
default-terminal \"tmux-256color\"` and `export COLORTERM=truecolor` to your shell \
startup file."
));
return Some(warning);
}
let probes::RuntimeEvidence::Available(level) = level else {
return None;
};
if level.has_truecolor() {
return None;
}
let level_label = level.as_str();
if brand == TerminalName::AppleTerminal {
@ -996,7 +1026,7 @@ pub fn color_support_warning(
warning.note = Some(format!(
"In the same tmux config, also add `set -g default-terminal \"tmux-256color\"`. Add \
`export COLORTERM=truecolor` to your shell startup file. Then reload tmux with \
`tmux source-file {tmux_config_path}`, or detach and reattach, and restart Grok."
`tmux source-file {tmux_config_path}`, then detach and reattach, and restart Grok."
));
return Some(warning);
}
@ -1061,6 +1091,10 @@ mod tests {
}
impl probes::TmuxOptionQuery for FakeTmuxQuery {
fn client_features(&self) -> probes::TmuxProbeResult<String> {
probes::TmuxProbeResult::Unavailable
}
fn show_option(&self, option: &str) -> probes::TmuxProbeResult<String> {
if let Some(error) = &self.error {
return probes::TmuxProbeResult::Error(error.clone());
@ -1118,6 +1152,7 @@ mod tests {
allow_passthrough_support: query.option_support("allow-passthrough"),
allow_passthrough: query.show_option("allow-passthrough"),
control_mode: probes::TmuxProbeResult::Available(control_mode),
client_features: probes::TmuxProbeResult::Unavailable,
},
wayland: probes::WaylandProbeFacts {
is_wayland: false,
@ -2521,14 +2556,16 @@ mod tests {
allow_passthrough_support: probes::TmuxProbeResult::Available(()),
allow_passthrough: probes::TmuxProbeResult::Available("off".to_owned()),
control_mode: probes::TmuxProbeResult::Available(true),
client_features: probes::TmuxProbeResult::Unavailable,
};
let mut warnings = collect_startup_warnings_from(&terminal, &tmux, Some(false));
warnings.push(wezterm_kitty_keyboard_warning_from(&wezterm_ctx(), false, None).unwrap());
warnings.push(diagnose_wayland_data_control(true, false, false).unwrap());
warnings.push(
color_support_warning(
ColorLevel::Ansi256,
probes::RuntimeEvidence::Available(ColorLevel::Ansi256),
TerminalName::Unknown,
TmuxColorPassthrough::Unknown,
true,
config_path,
)
@ -2946,8 +2983,9 @@ mod tests {
fn color_support_warning_none_on_truecolor() {
assert!(
color_support_warning(
ColorLevel::TrueColor,
probes::RuntimeEvidence::Available(ColorLevel::TrueColor),
TerminalName::Ghostty,
TmuxColorPassthrough::Unknown,
false,
"~/.tmux.conf"
)
@ -2955,11 +2993,13 @@ mod tests {
);
}
/// `NO_COLOR` outranks a tmux clamp: there is no color to forward.
#[test]
fn color_support_warning_no_color() {
let w = color_support_warning(
ColorLevel::None,
probes::RuntimeEvidence::Available(ColorLevel::None),
TerminalName::Ghostty,
TmuxColorPassthrough::Reduced,
false,
"~/.tmux.conf",
)
@ -2972,8 +3012,9 @@ mod tests {
#[test]
fn color_support_warning_apple_terminal() {
let w = color_support_warning(
ColorLevel::Ansi256,
probes::RuntimeEvidence::Available(ColorLevel::Ansi256),
TerminalName::AppleTerminal,
TmuxColorPassthrough::Unknown,
false,
"~/.tmux.conf",
)
@ -2991,8 +3032,9 @@ mod tests {
#[test]
fn color_support_warning_tmux() {
let w = color_support_warning(
ColorLevel::Ansi256,
probes::RuntimeEvidence::Available(ColorLevel::Ansi256),
TerminalName::Unknown,
TmuxColorPassthrough::Unknown,
true,
"~/.byobu/.tmux.conf",
)
@ -3010,8 +3052,9 @@ mod tests {
#[test]
fn color_support_warning_colorterm() {
let w = color_support_warning(
ColorLevel::Basic,
probes::RuntimeEvidence::Available(ColorLevel::Basic),
TerminalName::Unknown,
TmuxColorPassthrough::Unknown,
false,
"~/.tmux.conf",
)
@ -3020,11 +3063,81 @@ mod tests {
assert!(w.config_path.is_none());
}
/// Regression: a tmux client that reduces color used to be invisible to
/// Doctor whenever Grok's own detection reported truecolor, so a session
/// with washed-out themes was reported completely healthy.
#[test]
fn color_support_warning_reports_tmux_clamp_at_truecolor() {
let w = color_support_warning(
probes::RuntimeEvidence::Available(ColorLevel::TrueColor),
TerminalName::Ghostty,
TmuxColorPassthrough::Reduced,
true,
"~/.tmux.conf",
)
.expect("warn");
assert_eq!(w.category, WarningCategory::TmuxColorReduced);
assert_eq!(
w.fix.as_deref(),
Some("set -as terminal-features \",*:RGB\"")
);
assert_eq!(w.config_path.as_deref(), Some("~/.tmux.conf"));
assert!(
w.note
.as_deref()
.is_some_and(|note| note.contains("detach and reattach"))
);
}
/// Piped `grok doctor` has no color evidence, but the tmux client is still
/// measurable, and `doctor fix` needs the finding to plan against.
#[test]
fn color_support_warning_reports_tmux_clamp_without_color_evidence() {
let w = color_support_warning(
probes::RuntimeEvidence::Unavailable,
TerminalName::Unknown,
TmuxColorPassthrough::Reduced,
true,
"~/.tmux.conf",
)
.expect("warn");
assert_eq!(w.category, WarningCategory::TmuxColorReduced);
}
#[test]
fn color_support_warning_unknown_color_evidence_is_silent() {
assert!(
color_support_warning(
probes::RuntimeEvidence::Unavailable,
TerminalName::Unknown,
TmuxColorPassthrough::Unknown,
true,
"~/.tmux.conf"
)
.is_none()
);
}
#[test]
fn color_support_warning_forwarded_tmux_color_is_silent() {
assert!(
color_support_warning(
probes::RuntimeEvidence::Available(ColorLevel::TrueColor),
TerminalName::Ghostty,
TmuxColorPassthrough::Forwarded,
true,
"~/.tmux.conf"
)
.is_none()
);
}
#[test]
fn summarize_warnings_suppresses_limited_color_support() {
let w = color_support_warning(
ColorLevel::Ansi256,
probes::RuntimeEvidence::Available(ColorLevel::Ansi256),
TerminalName::Unknown,
TmuxColorPassthrough::Unknown,
false,
"~/.tmux.conf",
)

View file

@ -114,6 +114,24 @@ pub struct TmuxFacts {
pub set_clipboard: TmuxOptionFact,
pub allow_passthrough_support: TmuxSupportFact,
pub allow_passthrough: TmuxOptionFact,
pub color_passthrough: TmuxColorPassthrough,
}
/// Whether the attached tmux client forwards 24-bit color to the terminal.
///
/// tmux resolves a client's features once, at attach time, so this describes
/// the live client and not the config on disk: a config change applies only
/// after that client reattaches.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TmuxColorPassthrough {
/// The client advertises `RGB`, so truecolor SGR reaches the terminal.
Forwarded,
/// tmux reduces 24-bit color to the client terminfo's palette, which is
/// what makes themes look washed out even when Grok emits truecolor.
Reduced,
/// No usable evidence: tmux predates `terminal-features` (3.2), no client
/// is attached, or the query failed. Never treated as a problem.
Unknown,
}
#[derive(Clone, Debug, Eq, PartialEq)]

View file

@ -67,6 +67,15 @@ pub struct DoctorProbeSnapshot<'a> {
pub color_level: crate::theme::color_support::ColorLevel,
}
/// Whether the caller will read the colour-passthrough fact. Only `view()`
/// reads it, and the startup path never calls `view()`, so probing there
/// spends ~116ms before first paint on a value that is dropped.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ColorPassthroughProbe {
Skip,
Run,
}
pub struct TmuxProbeFacts {
pub version: TmuxProbeResult<String>,
pub extended_keys: TmuxProbeResult<String>,
@ -74,6 +83,8 @@ pub struct TmuxProbeFacts {
pub allow_passthrough_support: TmuxProbeResult<()>,
pub allow_passthrough: TmuxProbeResult<String>,
pub control_mode: TmuxProbeResult<bool>,
/// Comma-separated `client_termfeatures` for the attached client.
pub client_features: TmuxProbeResult<String>,
}
#[derive(Clone)]
@ -108,6 +119,7 @@ pub fn collect_startup_tui<'a>(
terminal,
runtime,
Some(control_mode),
ColorPassthroughProbe::Skip,
tmux,
is_wayland,
native_tool,
@ -122,7 +134,15 @@ pub fn collect_doctor_tui<'a>(
let is_wayland = crate::host::DisplayServer::current() == crate::host::DisplayServer::Wayland;
let native_tool = xai_grok_shell::util::clipboard::native_tool_name();
DoctorProbeSnapshot {
common: collect_common(terminal, runtime, None, tmux, is_wayland, Some(native_tool)),
common: collect_common(
terminal,
runtime,
None,
ColorPassthroughProbe::Run,
tmux,
is_wayland,
Some(native_tool),
),
clipboard: ClipboardProbeFacts {
route: crate::clipboard::clipboard_route().clone(),
native_tool,
@ -181,6 +201,11 @@ fn collect_tmux_fix(
} else {
(TmuxProbeResult::Unavailable, TmuxProbeResult::Unavailable)
};
let client_features = if wants(crate::diagnostics::TMUX_TRUECOLOR_ID) {
tmux.client_features()
} else {
TmuxProbeResult::Unavailable
};
TmuxProbeFacts {
version: TmuxProbeResult::Unavailable,
extended_keys,
@ -188,6 +213,7 @@ fn collect_tmux_fix(
allow_passthrough_support,
allow_passthrough,
control_mode: TmuxProbeResult::Unavailable,
client_features,
}
}
@ -267,6 +293,7 @@ fn unavailable_tmux() -> TmuxProbeFacts {
allow_passthrough_support: TmuxProbeResult::Unavailable,
allow_passthrough: TmuxProbeResult::Unavailable,
control_mode: TmuxProbeResult::Unavailable,
client_features: TmuxProbeResult::Unavailable,
}
}
@ -287,10 +314,13 @@ fn standalone_data_control(is_wayland: bool) -> TmuxProbeResult<bool> {
}
}
// Plumbing constructor: one argument per probe input feeding the snapshot.
#[allow(clippy::too_many_arguments)]
fn collect_common<'a>(
terminal: &'a TerminalContext,
runtime: TuiProbeEvidence<'a>,
control_mode: Option<bool>,
color_probe: ColorPassthroughProbe,
tmux: &dyn TmuxOptionQuery,
is_wayland: bool,
native_tool: Option<&str>,
@ -299,7 +329,7 @@ fn collect_common<'a>(
is_wayland && xai_grok_shell::util::clipboard::wayland_data_control_supported();
ProbeSnapshot {
terminal,
tmux: collect_tmux(terminal, control_mode, tmux),
tmux: collect_tmux(terminal, control_mode, color_probe, tmux),
wayland: WaylandProbeFacts {
is_wayland,
data_control: TmuxProbeResult::Available(data_control),
@ -319,17 +349,11 @@ fn startup_native_tool(
fn collect_tmux(
terminal: &TerminalContext,
control_mode: Option<bool>,
color_probe: ColorPassthroughProbe,
tmux: &dyn TmuxOptionQuery,
) -> TmuxProbeFacts {
if !terminal.is_tmux_backed() {
return TmuxProbeFacts {
version: TmuxProbeResult::Unavailable,
extended_keys: TmuxProbeResult::Unavailable,
set_clipboard: TmuxProbeResult::Unavailable,
allow_passthrough_support: TmuxProbeResult::Unavailable,
allow_passthrough: TmuxProbeResult::Unavailable,
control_mode: TmuxProbeResult::Unavailable,
};
return unavailable_tmux();
}
let allow_passthrough_support = tmux.option_support("allow-passthrough");
@ -356,6 +380,10 @@ fn collect_tmux(
control_mode: control_mode
.map(TmuxProbeResult::Available)
.unwrap_or_else(|| tmux.control_mode()),
client_features: match color_probe {
ColorPassthroughProbe::Run => tmux.client_features(),
ColorPassthroughProbe::Skip => TmuxProbeResult::Unavailable,
},
}
}
@ -395,6 +423,14 @@ mod tests {
self.calls.borrow_mut().push("control-mode".to_owned());
self.control_mode.clone()
}
fn client_features(&self) -> TmuxProbeResult<String> {
self.calls.borrow_mut().push("client-features".to_owned());
self.values
.get("client-features")
.cloned()
.unwrap_or(TmuxProbeResult::Unavailable)
}
}
fn runtime() -> TuiProbeEvidence<'static> {
@ -431,7 +467,15 @@ mod tests {
calls: RefCell::new(Vec::new()),
};
let snapshot = collect_common(&terminal, runtime(), Some(true), &fake, false, None);
let snapshot = collect_common(
&terminal,
runtime(),
Some(true),
ColorPassthroughProbe::Skip,
&fake,
false,
None,
);
assert_eq!(snapshot.tmux.control_mode, TmuxProbeResult::Available(true));
assert_eq!(
@ -462,7 +506,15 @@ mod tests {
control_mode: TmuxProbeResult::Available(true),
..empty_fake()
};
let snapshot = collect_common(&terminal, runtime(), None, &fake, false, None);
let snapshot = collect_common(
&terminal,
runtime(),
None,
ColorPassthroughProbe::Run,
&fake,
false,
None,
);
assert_eq!(snapshot.tmux.control_mode, TmuxProbeResult::Available(true));
assert_eq!(
@ -472,6 +524,7 @@ mod tests {
"extended-keys",
"set-clipboard",
"control-mode",
"client-features",
]
);
}
@ -483,7 +536,15 @@ mod tests {
control_mode: TmuxProbeResult::Error("must not run".to_owned()),
..empty_fake()
};
let snapshot = collect_common(&terminal, runtime(), None, &fake, false, None);
let snapshot = collect_common(
&terminal,
runtime(),
None,
ColorPassthroughProbe::Run,
&fake,
false,
None,
);
assert_eq!(snapshot.tmux.set_clipboard, TmuxProbeResult::Unavailable);
assert_eq!(snapshot.tmux.control_mode, TmuxProbeResult::Unavailable);

View file

@ -8,6 +8,10 @@ pub trait TmuxOptionQuery {
fn option_support(&self, option: &str) -> TmuxProbeResult<()>;
fn control_mode(&self) -> TmuxProbeResult<bool>;
/// The attached client's resolved terminal features, which decide whether
/// tmux forwards 24-bit color or reduces it to the client terminfo palette.
fn client_features(&self) -> TmuxProbeResult<String>;
}
pub struct LiveTmuxProbe;
@ -24,4 +28,8 @@ impl TmuxOptionQuery for LiveTmuxProbe {
fn control_mode(&self) -> TmuxProbeResult<bool> {
tmux_probe::query_control_mode()
}
fn client_features(&self) -> TmuxProbeResult<String> {
tmux_probe::query_client_features()
}
}

View file

@ -7,8 +7,8 @@ use crate::diagnostics::probes::{
use crate::diagnostics::{
ClipboardFacts, ColorFacts, DataControlFact, DiagnosticFacts, DiagnosticFinding, DiagnosticId,
DiagnosticReport, FindingDisposition, KeyboardFact, ManualRemediation, NewlineFact, ProbeNote,
ProbeStatus, RuntimeFact, TerminalWarning, TmuxFacts, TmuxOptionFact, TmuxSupportFact,
WarningCategory,
ProbeStatus, RuntimeFact, TerminalWarning, TmuxColorPassthrough, TmuxFacts, TmuxOptionFact,
TmuxSupportFact, WarningCategory,
};
use crate::terminal::TerminalName;
@ -103,14 +103,13 @@ pub fn view(snapshot: DiagnosticSnapshot<'_>) -> DiagnosticReport {
&snapshot.common,
));
warnings.extend(wezterm_warning);
if let RuntimeEvidence::Available(color_level) = snapshot.color_level {
warnings.extend(super::color_support_warning(
color_level,
ctx.brand,
ctx.is_tmux_backed(),
&ctx.tmux_config_path(),
));
}
warnings.extend(super::color_support_warning(
snapshot.color_level,
ctx.brand,
tmux_color_passthrough(&snapshot.common.tmux.client_features),
ctx.is_tmux_backed(),
&ctx.tmux_config_path(),
));
let (facts, clipboard_recovery) = facts(&snapshot, suppress_newline);
let mut findings = warnings
@ -235,6 +234,7 @@ fn facts(
&snapshot.common.tmux.allow_passthrough_support,
),
allow_passthrough: tmux_option_fact(&snapshot.common.tmux.allow_passthrough),
color_passthrough: tmux_color_passthrough(&snapshot.common.tmux.client_features),
},
color: ColorFacts {
level: match snapshot.color_level {
@ -533,6 +533,7 @@ pub(crate) const fn id_for(category: WarningCategory) -> Option<DiagnosticId> {
WarningCategory::WaylandNoDataControl => "wayland-data-control",
WarningCategory::WezTermKittyKeyboardOff => "wezterm-kitty",
WarningCategory::LimitedColorSupport => "limited-color",
WarningCategory::TmuxColorReduced => "tmux-truecolor",
WarningCategory::SshWithoutWrap => "ssh-wrap",
WarningCategory::NotificationProtocolFallback => {
return Some(crate::diagnostics::NOTIFICATION_PROTOCOL_FALLBACK_ID);
@ -581,6 +582,11 @@ fn probe_notes(snapshot: &DiagnosticSnapshot<'_>) -> Vec<ProbeNote> {
"tmux.control-mode",
&snapshot.common.tmux.control_mode,
);
probe_note(
&mut notes,
"tmux.client-features",
&snapshot.common.tmux.client_features,
);
}
runtime_probe_note(
&mut notes,
@ -613,6 +619,26 @@ fn tmux_option_fact(result: &TmuxProbeResult<String>) -> TmuxOptionFact {
}
}
/// tmux marks a client `RGB` when the outer terminfo declares `RGB`/`Tc` or
/// `terminal-features` adds it; either way the feature list is the single
/// authoritative signal, and a missing answer is not evidence of clamping.
fn tmux_color_passthrough(result: &TmuxProbeResult<String>) -> TmuxColorPassthrough {
let TmuxProbeResult::Available(features) = result else {
return TmuxColorPassthrough::Unknown;
};
if features.trim().is_empty() {
return TmuxColorPassthrough::Unknown;
}
if features
.split(',')
.any(|feature| feature.trim().eq_ignore_ascii_case("RGB"))
{
TmuxColorPassthrough::Forwarded
} else {
TmuxColorPassthrough::Reduced
}
}
fn tmux_support_fact(result: &TmuxProbeResult<()>) -> TmuxSupportFact {
match result {
TmuxProbeResult::Available(()) => TmuxSupportFact::Supported,

View file

@ -179,6 +179,7 @@ fn findings_have_stable_semantic_ids_and_dispositions() {
allow_passthrough_support: TmuxProbeResult::Available(()),
allow_passthrough: TmuxProbeResult::Available("on".to_owned()),
control_mode: TmuxProbeResult::Available(false),
client_features: TmuxProbeResult::Unavailable,
},
available_runtime(),
false,
@ -250,6 +251,7 @@ fn all_tmux_finding_metadata_uses_stable_automatic_fix_ids_without_schema_change
allow_passthrough_support: TmuxProbeResult::Available(()),
allow_passthrough: TmuxProbeResult::Available("off".to_owned()),
control_mode: TmuxProbeResult::Available(false),
client_features: TmuxProbeResult::Unavailable,
},
available_runtime(),
false,
@ -288,6 +290,7 @@ fn all_tmux_finding_metadata_uses_stable_automatic_fix_ids_without_schema_change
allow_passthrough_support: TmuxProbeResult::Available(()),
allow_passthrough: TmuxProbeResult::Available("all".to_owned()),
control_mode: TmuxProbeResult::Available(false),
client_features: TmuxProbeResult::Unavailable,
},
available_runtime(),
false,
@ -318,6 +321,7 @@ fn unavailable_runtime_evidence_is_honest_and_fail_open() {
allow_passthrough_support: TmuxProbeResult::Available(()),
allow_passthrough: TmuxProbeResult::Available("on".to_owned()),
control_mode: TmuxProbeResult::Available(true),
client_features: TmuxProbeResult::Unavailable,
},
DiagnosticRuntimeEvidence {
fullscreen_active: RuntimeEvidence::Unavailable,
@ -369,6 +373,7 @@ fn unavailable_and_error_probe_evidence_is_retained_without_findings() {
allow_passthrough_support: TmuxProbeResult::Unsupported,
allow_passthrough: TmuxProbeResult::Unavailable,
control_mode: TmuxProbeResult::Unavailable,
client_features: TmuxProbeResult::Unavailable,
},
available_runtime(),
true,
@ -384,7 +389,7 @@ fn unavailable_and_error_probe_evidence_is_retained_without_findings() {
report.facts.clipboard.delivery,
crate::clipboard::ClipboardDelivery::Confirmed
);
assert_eq!(report.probe_notes.len(), 6);
assert_eq!(report.probe_notes.len(), 7);
assert_eq!(report.probe_notes[0].probe, "tmux.version");
assert_eq!(report.probe_notes[1].probe, "tmux.extended-keys");
assert_eq!(report.probe_notes[2].status, ProbeStatus::Error);
@ -394,7 +399,8 @@ fn unavailable_and_error_probe_evidence_is_retained_without_findings() {
);
assert_eq!(report.probe_notes[3].status, ProbeStatus::Unsupported);
assert_eq!(report.probe_notes[4].probe, "tmux.control-mode");
assert_eq!(report.probe_notes[5].probe, "wayland.data-control");
assert_eq!(report.probe_notes[5].probe, "tmux.client-features");
assert_eq!(report.probe_notes[6].probe, "wayland.data-control");
}
fn plain_tmux() -> TmuxProbeFacts {
@ -405,6 +411,7 @@ fn plain_tmux() -> TmuxProbeFacts {
allow_passthrough_support: TmuxProbeResult::Available(()),
allow_passthrough: TmuxProbeResult::Available("on".to_owned()),
control_mode: TmuxProbeResult::Available(false),
client_features: TmuxProbeResult::Unavailable,
}
}
@ -712,3 +719,53 @@ fn keyboard_fact_and_formatter_use_snapshot_host() {
assert!(!output.contains(" keyboard "));
}
}
/// `RGB` in the resolved feature list is the only signal that 24-bit color
/// survives tmux. Empty output means the answer is unknown rather than
/// negative: tmux before 3.2 renders the unknown format as an empty string.
#[test]
fn client_features_decide_color_passthrough() {
let cases = [
(
TmuxProbeResult::Available(
"bpaste,ccolour,clipboard,cstyle,focus,RGB,title".to_owned(),
),
TmuxColorPassthrough::Forwarded,
),
(
TmuxProbeResult::Available("RGB".to_owned()),
TmuxColorPassthrough::Forwarded,
),
(
TmuxProbeResult::Available("bpaste,ccolour,clipboard,cstyle,focus,title".to_owned()),
TmuxColorPassthrough::Reduced,
),
(
TmuxProbeResult::Available(String::new()),
TmuxColorPassthrough::Unknown,
),
(
TmuxProbeResult::Available(" ".to_owned()),
TmuxColorPassthrough::Unknown,
),
(TmuxProbeResult::Unsupported, TmuxColorPassthrough::Unknown),
(TmuxProbeResult::Unavailable, TmuxColorPassthrough::Unknown),
(
TmuxProbeResult::Error("tmux unreachable".to_owned()),
TmuxColorPassthrough::Unknown,
),
];
let actual = cases
.iter()
.map(|(result, _)| tmux_color_passthrough(result))
.collect::<Vec<_>>();
assert_eq!(
actual,
cases
.iter()
.map(|(_, expected)| *expected)
.collect::<Vec<_>>()
);
}

View file

@ -76,6 +76,7 @@ fn tmux_facts(
allow_passthrough_support: TmuxProbeResult::Available(()),
allow_passthrough: TmuxProbeResult::Available("on".to_owned()),
control_mode,
client_features: TmuxProbeResult::Unavailable,
}
}
@ -87,6 +88,7 @@ fn unavailable_tmux_facts() -> TmuxProbeFacts {
allow_passthrough_support: TmuxProbeResult::Unavailable,
allow_passthrough: TmuxProbeResult::Unavailable,
control_mode: TmuxProbeResult::Unavailable,
client_features: TmuxProbeResult::Unavailable,
}
}
@ -103,6 +105,7 @@ fn healthy_report() -> DiagnosticReport {
set_clipboard: crate::diagnostics::TmuxOptionFact::Unavailable,
allow_passthrough_support: crate::diagnostics::TmuxSupportFact::Unavailable,
allow_passthrough: crate::diagnostics::TmuxOptionFact::Unavailable,
color_passthrough: crate::diagnostics::TmuxColorPassthrough::Unknown,
},
color: ColorFacts {
level: RuntimeFact::Available(ColorLevel::TrueColor),
@ -411,6 +414,7 @@ fn standalone_runtime_and_tmux_are_unavailable_without_false_wezterm_finding() {
"tmux.set-clipboard",
"tmux.allow-passthrough-support",
"tmux.control-mode",
"tmux.client-features",
]
);
let runtime_notes = report

View file

@ -45,6 +45,8 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock, RwLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub use xai_tty_utils::{ProcessResources, sample_process_memory};
/// Allocator gauges sampled from jemalloc (`stats.*` mallctls). All bytes.
#[derive(Clone, Copy, Debug, serde::Serialize)]
pub struct AllocatorStats {
@ -135,125 +137,6 @@ pub fn install_threshold_hook(hook: fn(&Path, u64)) {
let _ = THRESHOLD_HOOK.set(hook);
}
// ─── Process memory sampling ──────────────────────────────────────────────
/// Cross-platform process memory gauges. Fields are `None` where the
/// platform offers no cheap equivalent.
#[derive(Clone, Copy, Debug, Default)]
pub struct ProcessMem {
pub footprint_bytes: Option<u64>,
pub rss_bytes: Option<u64>,
}
/// Sample this process's memory. Sub-microsecond syscall on macOS/Linux.
pub fn sample_process_memory() -> ProcessMem {
imp::sample()
}
#[cfg(target_os = "macos")]
mod imp {
use super::ProcessMem;
// Hand-rolled `task_vm_info` prefix through `phys_footprint` (the kernel
// accepts any count ≤ the current struct revision; passing the prefix
// count returns exactly these fields). Layout per XNU osfmk/mach/task_info.h.
#[repr(C)]
#[derive(Default)]
struct TaskVmInfoPrefix {
virtual_size: u64,
region_count: i32,
page_size: i32,
resident_size: u64,
resident_size_peak: u64,
device: u64,
device_peak: u64,
internal: u64,
internal_peak: u64,
external: u64,
external_peak: u64,
reusable: u64,
reusable_peak: u64,
purgeable_volatile_pmap: u64,
purgeable_volatile_resident: u64,
purgeable_volatile_virtual: u64,
compressed: u64,
compressed_peak: u64,
compressed_lifetime: u64,
phys_footprint: u64,
}
const TASK_VM_INFO: u32 = 22;
// mach natural_t (u32) units.
const PREFIX_COUNT: u32 = (size_of::<TaskVmInfoPrefix>() / size_of::<u32>()) as u32;
unsafe extern "C" {
// libSystem: the calling task's control port and task_info(2).
static mach_task_self_: u32;
fn task_info(task: u32, flavor: u32, info: *mut u8, count: *mut u32) -> i32;
}
pub(super) fn sample() -> ProcessMem {
let mut info = TaskVmInfoPrefix::default();
let mut count = PREFIX_COUNT;
// SAFETY: `info` is a properly sized/aligned out-buffer and `count`
// tells the kernel its length in natural_t units; TASK_VM_INFO on
// the caller's own task port cannot fault.
let kr = unsafe {
task_info(
mach_task_self_,
TASK_VM_INFO,
(&raw mut info).cast::<u8>(),
&raw mut count,
)
};
if kr != 0 {
return ProcessMem::default();
}
ProcessMem {
footprint_bytes: Some(info.phys_footprint),
rss_bytes: Some(info.resident_size),
}
}
}
#[cfg(target_os = "linux")]
mod imp {
use super::ProcessMem;
pub(super) fn sample() -> ProcessMem {
// /proc/self/statm field 2 = resident pages.
let Ok(statm) = std::fs::read_to_string("/proc/self/statm") else {
return ProcessMem::default();
};
let rss_pages: u64 = statm
.split_whitespace()
.nth(1)
.and_then(|f| f.parse().ok())
.unwrap_or(0);
// Kernel page size is not always 4 KiB (aarch64 kernels commonly use
// 16K/64K pages); ask once.
// SAFETY: sysconf(_SC_PAGESIZE) has no preconditions.
static PAGE_SIZE: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
let page = *PAGE_SIZE.get_or_init(|| {
let sz = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
if sz > 0 { sz as u64 } else { 4096 }
});
ProcessMem {
footprint_bytes: None,
rss_bytes: Some(rss_pages * page),
}
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
mod imp {
use super::ProcessMem;
pub(super) fn sample() -> ProcessMem {
ProcessMem::default()
}
}
// ─── Threshold state (pure; unit-tested) ──────────────────────────────────
/// Exactly-once-per-growth-cycle threshold buckets. A bucket fires when the
@ -677,19 +560,6 @@ mod tests {
assert_eq!(t.observe(64 << 20), vec![64 << 20]);
}
#[test]
fn process_memory_sampling_returns_gauges() {
let mem = sample_process_memory();
#[cfg(target_os = "macos")]
{
assert!(mem.footprint_bytes.unwrap_or(0) > 0, "footprint on macOS");
assert!(mem.rss_bytes.unwrap_or(0) > 0, "rss on macOS");
}
#[cfg(target_os = "linux")]
assert!(mem.rss_bytes.unwrap_or(0) > 0, "rss on linux");
let _ = mem;
}
#[test]
#[serial_test::serial(MEMTRACE_SINK)]
fn sample_events_are_valid_jsonl_and_rotate() {

View file

@ -514,7 +514,7 @@ impl ScrollbackPane {
}
// Render content
if content_area.height > 0 {
let mut output = if content_area.height > 0 {
// Use the entry_range for content rendering (same range we built descriptors for)
let visible_range = entry_range.clone();
@ -556,7 +556,11 @@ impl ScrollbackPane {
},
..Default::default()
}
}
};
// Publish the gap row this frame's pinned header actually produced
// (None during push transitions and degenerate tiny viewports).
output.output.sticky_gap_row = sticky.gap_row().filter(|row| *row < area.height);
output
}
/// Build prompt descriptors for a range of entries.

View file

@ -82,6 +82,12 @@ pub struct RenderOutput {
pub inline_media: Vec<crate::scrollback::render::InlineMediaPlacement>,
/// Mermaid diagram affordance rows to paint + register click hit-rects for.
pub diagram_affordances: Vec<crate::scrollback::render::DiagramAffordancePlacement>,
/// Screen row (relative to the scrollback area top) of the sticky
/// header's gap row, when this frame drew a pinned header. The ▲
/// response-top indicator renders here; publishing the row the pane
/// actually used keeps the indicator from re-deriving (and possibly
/// disagreeing with) the frame's layout.
pub sticky_gap_row: Option<u16>,
}
/// Scroll information for scrollbar rendering.
@ -106,12 +112,7 @@ impl RenderOutput {
pub fn with_selection_box(selection_box: SelectionBox) -> Self {
Self {
selection_box: Some(selection_box),
scroll_info: None,
selected_entry_area: None,
selection_model: ResolvedSelectionModel::default(),
link_overlay: Default::default(),
inline_media: Vec::new(),
diagram_affordances: Vec::new(),
..Self::default()
}
}

View file

@ -350,6 +350,32 @@ impl ScrollbackState {
false
}
/// Whether the response being read starts above the viewport top: the
/// active turn (the one owning the top row) has a response anchor whose
/// first line is scrolled off screen. Drives the ▲ jump-to-response-top
/// indicator, whose click runs [`Self::prev_response`] — from inside an
/// answer that anchor is exactly the nearest one above, so the indicator
/// only shows when the click has that answer's top to land on.
///
/// Cache-only estimate (`&self`, headers ignored) so render can poll it
/// every frame; the estimate never undershoots the exact target, so a
/// visible indicator always has a real jump behind it.
pub fn has_response_top_above(&self) -> bool {
let Some(turn) = self
.active_turn_for_viewport()
.and_then(|t| self.turns.get(t))
else {
return false;
};
let Some(idx) = response_anchor_in_range(&self.entries, turn.range()) else {
return false;
};
self.visible_entry_range().contains(&idx)
&& self
.entry_top_estimate(idx)
.is_some_and(|estimate| estimate < self.scroll_offset)
}
/// Set status of the last turn.
pub fn set_last_turn_status(&mut self, status: TurnStatus) {
if let Some(turn) = self.turns.last_mut() {
@ -2423,4 +2449,85 @@ mod tests {
"page-down should advance a full viewport - 2 with sticky headers off"
);
}
#[test]
fn response_top_above_tracks_the_answer_being_read() {
let mut state = ScrollbackState::new();
state.push_block(user_block("Q1")); // 0
state.push_block(tall_agent_block()); // 1
state.prepare_layout(80, 6);
// Follow mode parks at the tail of the long answer: its first line
// is above the viewport, so the indicator has a jump to offer.
assert!(state.is_follow_mode());
assert!(state.has_response_top_above());
// Taking the jump (the indicator click = K) lands on the answer's
// top; from there there is nothing further up to jump to.
assert!(state.prev_response());
assert_eq!(state.selected(), Some(1));
assert!(!state.has_response_top_above());
}
#[test]
fn response_top_above_is_false_for_short_answers_and_without_layout() {
let mut state = ScrollbackState::new();
state.push_block(user_block("Q1"));
state.push_block(agent_block("short answer"));
// No layout yet: no viewport top to compare against.
assert!(!state.has_response_top_above());
// Fully visible answer: nothing above the viewport top.
state.prepare_layout(80, 20);
assert!(!state.has_response_top_above());
}
#[test]
fn response_top_above_works_for_earlier_turns_too() {
let mut state = ScrollbackState::new();
state.push_block(user_block("Q1")); // 0
state.push_block(tall_agent_block()); // 1
state.push_block(user_block("Q2")); // 2
state.push_block(tall_agent_block()); // 3
state.prepare_layout(80, 6);
// Park the viewport mid-way through turn 0's answer: the indicator
// is not reserved for the last turn.
state.goto_top();
while !state.has_response_top_above() {
let before = state.scroll_offset();
state.scroll_down(1);
assert_ne!(
state.scroll_offset(),
before,
"hit the bottom without ever entering turn 0's answer"
);
}
assert_eq!(state.active_turn_for_viewport(), Some(0));
// The jump snaps to THIS answer's top, not the last one's.
assert!(state.prev_response());
assert_eq!(state.selected(), Some(1));
}
#[test]
fn response_top_above_is_false_while_the_answer_is_still_below() {
let mut state = ScrollbackState::new();
state.push_block(user_block("Q1")); // 0
state.push_block(tall_agent_block()); // 1
state.push_block(user_block("Q2")); // 2
for i in 0..8 {
state.push_block(tool_block(&format!("tool {i}"))); // 3..=10
}
state.push_block(agent_block("A2")); // 11
state.prepare_layout(80, 6);
// Viewport top inside turn 1's tool run: turn 1's answer starts
// BELOW the top, so there is no "top of the response" to return to
// even though turn 0's answer sits further up.
state.scroll_to_entry_top(8);
assert_eq!(state.active_turn_for_viewport(), Some(1));
assert!(!state.has_response_top_above());
}
}

View file

@ -208,14 +208,13 @@ impl StickyHeaderLayout {
Some(pushed_visible + gap_after_pushed)
}
/// Screen row where the gap after header is (for selection corners).
/// Returns None if no header.
/// Screen row of the gap after a pinned header (selection corners, the
/// ▲ response-top indicator). `None` without a pinned header: a
/// push-only header renders no gap after it (see
/// [`Self::header_screen_rows`]), so this row would point at content.
pub fn gap_row(&self) -> Option<u16> {
if self.has_header() {
Some(self.header_content_height())
} else {
None
}
self.pinned?;
Some(self.header_content_height())
}
/// Screen row where pushed header starts (always 0 if present).
@ -1334,10 +1333,7 @@ mod tests {
// Need to find scroll where both pushed and pinned exist
for scroll in 0..20 {
let layout = compute_sticky_layout(scroll, 20, &prompts);
if layout.pushed.is_some() && layout.pinned.is_some() {
let pushed = layout.pushed.unwrap();
let pinned = layout.pinned.unwrap();
if let (Some(pushed), Some(pinned)) = (&layout.pushed, &layout.pinned) {
// Pushed rows → entry 0
for row in 0..pushed.visible_height() {
assert_eq!(layout.entry_at_header_row(row), Some(0));

View file

@ -24,6 +24,8 @@ use syntect::easy::HighlightLines;
use crate::render::scrollbar::SCROLLBAR_TOTAL_COLS;
use crate::render::wrapping::word_wrap_line;
use crate::scrollback::blocks::markdown_content::MarkdownContent;
use crate::scrollback::blocks::mermaid_content::{MermaidDisplay, mermaid_display};
use crate::scrollback::render::DiagramAffordancePlacement;
use crate::syntax::get_syntect;
use crate::theme::Theme;
use crate::views::list_pane::{
@ -32,6 +34,9 @@ use crate::views::list_pane::{
use xai_ratatui_textarea::ElementId;
/// Stable ids for mermaid affordance rows (above source lines and comments).
const MERMAID_AFFORDANCE_ID_BASE: u64 = 2_000_000;
// ── Line item ───────────────────────────────────────────────────────────
/// A single source line for the line viewer.
@ -421,12 +426,103 @@ impl ListItem for CommentLine {
}
}
// ── Mermaid affordance row ────────────────────────────────────────────
/// Blank reserved row under a Mermaid diagram; buttons are painted by the
/// draw loop (same pattern as scrollback).
pub struct MermaidAffordanceLine {
item_id: u64,
/// Fence body — data for Open / Copy path / Copy source.
pub source: String,
prefix: Line<'static>,
}
impl MermaidAffordanceLine {
fn new(item_id: u64, source: String, max_digits: usize) -> Self {
let prefix = Line::from(Span::styled(
" ".repeat(max_digits + 1),
Style::default().fg(Theme::current().gray_dim),
));
Self {
item_id,
source,
prefix,
}
}
fn prefix_width(&self) -> u16 {
crate::views::list_pane::line_display_width(&self.prefix) as u16
}
}
impl ListItem for MermaidAffordanceLine {
fn content(&self) -> &Line<'_> {
static EMPTY: std::sync::LazyLock<Line<'static>> = std::sync::LazyLock::new(Line::default);
&EMPTY
}
fn prefix(&self) -> Option<Line<'_>> {
Some(self.prefix.clone())
}
fn prefix_in_selection(&self) -> Option<Line<'_>> {
Some(self.prefix.clone())
}
fn prefix_cursor(&self) -> Option<Line<'_>> {
Some(self.prefix.clone())
}
fn stable_id(&self) -> u64 {
self.item_id
}
fn is_selectable(&self) -> bool {
false
}
fn search_text(&self) -> &str {
""
}
fn copy_text(&self) -> String {
String::new()
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
fn render(&self, area: Rect, buf: &mut Buffer, _selected: bool, _focused: bool) {
if area.height == 0 || area.width == 0 {
return;
}
// Blank prefix only — write via cell_mut so out-of-bounds coords
// cannot panic (Buffer::set_line indexes and panics on OOB).
let prefix_w = self.prefix_width().min(area.width);
let style = self
.prefix
.spans
.first()
.map(|s| s.style)
.unwrap_or_default();
for dx in 0..prefix_w {
let Some(cell) = buf.cell_mut((area.x.saturating_add(dx), area.y)) else {
break;
};
cell.set_char(' ');
cell.set_style(style);
}
}
}
// ── Plan viewer item ──────────────────────────────────────────────────
/// A viewer item: either a source line or an inline review comment.
/// Source line, review comment, or Mermaid affordance row.
pub enum PlanViewerItem {
Source(Box<SourceLine>),
Comment(CommentLine),
MermaidAffordance(MermaidAffordanceLine),
}
impl PlanViewerItem {
@ -434,14 +530,14 @@ impl PlanViewerItem {
pub fn line_number(&self) -> Option<usize> {
match self {
Self::Source(s) => Some(s.line_number),
Self::Comment(_) => None,
Self::Comment(_) | Self::MermaidAffordance(_) => None,
}
}
/// The comment ID, if this is a comment item.
pub fn comment_id(&self) -> Option<u64> {
match self {
Self::Source(_) => None,
Self::Source(_) | Self::MermaidAffordance(_) => None,
Self::Comment(c) => Some(c.comment_id),
}
}
@ -452,6 +548,7 @@ impl ListItem for PlanViewerItem {
match self {
Self::Source(s) => s.content(),
Self::Comment(c) => c.content(),
Self::MermaidAffordance(m) => m.content(),
}
}
@ -459,6 +556,7 @@ impl ListItem for PlanViewerItem {
match self {
Self::Source(s) => s.prefix(),
Self::Comment(c) => c.prefix(),
Self::MermaidAffordance(m) => m.prefix(),
}
}
@ -466,6 +564,7 @@ impl ListItem for PlanViewerItem {
match self {
Self::Source(s) => s.prefix_in_selection(),
Self::Comment(c) => c.prefix_in_selection(),
Self::MermaidAffordance(m) => m.prefix_in_selection(),
}
}
@ -473,6 +572,7 @@ impl ListItem for PlanViewerItem {
match self {
Self::Source(s) => s.prefix_cursor(),
Self::Comment(c) => c.prefix_cursor(),
Self::MermaidAffordance(m) => m.prefix_cursor(),
}
}
@ -480,17 +580,22 @@ impl ListItem for PlanViewerItem {
match self {
Self::Source(s) => s.stable_id(),
Self::Comment(c) => c.stable_id(),
Self::MermaidAffordance(m) => m.stable_id(),
}
}
fn is_selectable(&self) -> bool {
true
match self {
Self::Source(_) | Self::Comment(_) => true,
Self::MermaidAffordance(m) => m.is_selectable(),
}
}
fn search_text(&self) -> &str {
match self {
Self::Source(s) => s.search_text(),
Self::Comment(c) => c.search_text(),
Self::MermaidAffordance(m) => m.search_text(),
}
}
@ -498,6 +603,7 @@ impl ListItem for PlanViewerItem {
match self {
Self::Source(s) => s.copy_text(),
Self::Comment(c) => c.copy_text(),
Self::MermaidAffordance(m) => m.copy_text(),
}
}
@ -505,6 +611,7 @@ impl ListItem for PlanViewerItem {
match self {
Self::Source(s) => s.desired_height(width),
Self::Comment(c) => c.desired_height(width),
Self::MermaidAffordance(m) => m.desired_height(width),
}
}
@ -512,13 +619,14 @@ impl ListItem for PlanViewerItem {
match self {
Self::Source(s) => s.render(area, buf, selected, focused),
Self::Comment(c) => c.render(area, buf, selected, focused),
Self::MermaidAffordance(m) => m.render(area, buf, selected, focused),
}
}
fn goto_line_number(&self) -> Option<usize> {
match self {
Self::Source(s) => Some(s.line_number),
Self::Comment(_) => None,
Self::Comment(_) | Self::MermaidAffordance(_) => None,
}
}
}
@ -619,6 +727,8 @@ pub struct LineViewerState {
/// Copy of comments last applied via `rebuild_with_comments`, so that
/// a width-triggered rebuild can re-interleave them automatically.
last_comments: Vec<crate::views::plan_approval_view::PlanComment>,
/// `(source_lines index to follow, diagram source)` for affordance rows.
mermaid_after: Vec<(usize, String)>,
/// When `true`, the viewer uses the full overlay area instead of the
/// 75% centered popup. Toggled by Ctrl+F.
pub fullscreen: bool,
@ -669,6 +779,7 @@ impl LineViewerState {
markdown_content: None,
last_table_width: None,
last_comments: Vec::new(),
mermaid_after: Vec::new(),
fullscreen: false,
})
}
@ -730,6 +841,7 @@ impl LineViewerState {
markdown_content: Some(content),
last_table_width: None,
last_comments: Vec::new(),
mermaid_after: Vec::new(),
fullscreen: false,
})
}
@ -784,9 +896,11 @@ impl LineViewerState {
}
self.last_table_width = Some(content_width);
self.source_lines = build_markdown_lines(content, Some(content_width));
let built = build_markdown_lines(content, Some(content_width));
self.source_lines = built.source_lines;
self.mermaid_after = built.mermaid_after;
if self.last_comments.is_empty() {
if self.last_comments.is_empty() && self.mermaid_after.is_empty() {
self.lines = self
.source_lines
.iter()
@ -799,6 +913,64 @@ impl LineViewerState {
}
}
/// Screen rects for visible Mermaid affordance rows (for paint + hit-testing).
pub fn diagram_affordance_placements(
&self,
content_area: Rect,
) -> Vec<DiagramAffordancePlacement> {
if content_area.width == 0 || content_area.height == 0 {
return Vec::new();
}
let scroll = self.list_state.scroll_offset();
let layout = self.list_state.layout();
let visible = self.list_state.visible_range();
if visible.is_empty() {
return Vec::new();
}
let first_vi = visible.start;
let skip_first = self.list_state.first_item_skip_rows();
let mut placements = Vec::new();
for vi in visible {
let pi = self.list_state.to_physical(vi);
let Some(PlanViewerItem::MermaidAffordance(m)) = self.lines.get(pi) else {
continue;
};
let item_h = layout.item_height(vi);
let skip = if vi == first_vi { skip_first } else { 0 };
if skip >= item_h {
continue;
}
// Align with list-pane layout: first visible item may be top-clipped.
let screen_y_offset = layout
.virtual_y(vi)
.saturating_sub(scroll)
.saturating_add(skip as usize);
if screen_y_offset >= content_area.height as usize {
continue;
}
let prefix_w = m.prefix_width();
let text_w = content_area
.width
.saturating_sub(prefix_w)
.saturating_sub(SCROLLBAR_TOTAL_COLS);
if text_w == 0 {
continue;
}
placements.push(DiagramAffordancePlacement {
screen_rect: Rect {
x: content_area.x.saturating_add(prefix_w),
y: content_area.y.saturating_add(screen_y_offset as u16),
width: text_w,
height: 1,
},
source: m.source.clone(),
});
}
placements
}
#[cfg(test)]
pub(crate) fn markdown_content_for_test(&self) -> Option<&str> {
self.markdown_content.as_deref()
@ -889,9 +1061,19 @@ impl LineViewerState {
self.list_state.invalidate_layout();
}
/// Interleave source lines with comments without updating `last_comments`.
/// Interleave source lines with Mermaid affordance rows and comments
/// without updating `last_comments`.
///
/// `mermaid_after` is document-ordered; affordances sit under the
/// diagram art, before any comments on the same source line.
fn interleave_comments(&mut self, comments: &[crate::views::plan_approval_view::PlanComment]) {
let max_digits = digit_count(self.source_lines.len().max(1));
let max_digits = digit_count(
self.source_lines
.last()
.map(|s| s.line_number)
.unwrap_or(1)
.max(1),
);
let mut sorted: Vec<_> = comments.iter().collect();
sorted.sort_by_key(|c| c.line_range.end);
@ -906,13 +1088,26 @@ impl LineViewerState {
let mut items: Vec<PlanViewerItem> = Vec::new();
let mut comment_idx = 0;
let comment_id_base: u64 = 1_000_000;
let mut mermaid_i = 0usize;
for src in &self.source_lines {
for (src_idx, src) in self.source_lines.iter().enumerate() {
let ln = src.line_number;
let mut src = src.clone();
src.commented = commented_lines.contains(&ln);
items.push(PlanViewerItem::Source(Box::new(src)));
while mermaid_i < self.mermaid_after.len() && self.mermaid_after[mermaid_i].0 == src_idx
{
items.push(PlanViewerItem::MermaidAffordance(
MermaidAffordanceLine::new(
MERMAID_AFFORDANCE_ID_BASE + mermaid_i as u64,
self.mermaid_after[mermaid_i].1.clone(),
max_digits,
),
));
mermaid_i += 1;
}
while comment_idx < sorted.len() && sorted[comment_idx].line_range.end == ln + 1 {
let c = sorted[comment_idx];
let item_id = comment_id_base + c.id;
@ -1032,16 +1227,27 @@ fn source_line_count(content: &str) -> usize {
}
}
struct BuiltMarkdownLines {
source_lines: Vec<SourceLine>,
/// Document-ordered `(source_lines index to follow, diagram source)`.
mermaid_after: Vec<(usize, String)>,
}
/// Build markdown-rendered source lines from file content.
///
/// Uses `MarkdownContent` to render the full document, then groups rendered
/// lines by source line using `line_source_map`. Each source line becomes
/// one `SourceLine` item that may span multiple visual lines (e.g., a table
/// block renders as border + header + separator + data + border).
fn build_markdown_lines(content: &str, max_table_width: Option<usize>) -> Vec<SourceLine> {
///
/// With `render_mermaid` auto/on, also anchors affordance rows under each
/// closed mermaid fence.
fn build_markdown_lines(content: &str, max_table_width: Option<usize>) -> BuiltMarkdownLines {
let md = MarkdownContent::new_source_faithful(content, max_table_width);
let pre_wrap = md.pre_wrap_lines();
let source_map = md.line_source_map();
let mermaid = md.mermaid_content();
let mermaid_ranges = md.mermaid_block_ranges();
// Background colors come from each line's style (set by the renderer
// for code blocks etc.). pre_wrap_lines() returns owned Lines that
@ -1053,9 +1259,9 @@ fn build_markdown_lines(content: &str, max_table_width: Option<usize>) -> Vec<So
let slc = source_line_count(content);
let max_digits = digit_count(slc.max(1));
// Group rendered lines by source line number.
// source_map is indexed by rendered-line index, value is 0-based source line.
// Group by source line; track which group each pre-wrap line lands in.
let mut groups: Vec<(usize, Vec<Line<'static>>, Vec<Option<Color>>)> = Vec::new();
let mut prewrap_to_group: Vec<usize> = Vec::with_capacity(pre_wrap.len());
for (rendered_idx, rendered_line) in pre_wrap.into_iter().enumerate() {
let src_line = source_map.get(rendered_idx).copied().unwrap_or(0);
let bg = line_bgs.get(rendered_idx).copied().flatten();
@ -1064,11 +1270,15 @@ fn build_markdown_lines(content: &str, max_table_width: Option<usize>) -> Vec<So
{
last.1.push(rendered_line);
last.2.push(bg);
prewrap_to_group.push(groups.len() - 1);
continue;
}
groups.push((src_line, vec![rendered_line], vec![bg]));
prewrap_to_group.push(groups.len() - 1);
}
// group index → source_lines index after blank-line injection.
let mut group_to_source_idx: Vec<usize> = Vec::with_capacity(groups.len());
let mut source_lines = Vec::new();
let mut next_item_id = 0u64;
let mut next_blank_src = 0usize;
@ -1091,6 +1301,7 @@ fn build_markdown_lines(content: &str, max_table_width: Option<usize>) -> Vec<So
}
}
group_to_source_idx.push(source_lines.len());
source_lines.push(SourceLine::new_markdown(
next_item_id,
src_line_0based + 1,
@ -1120,7 +1331,31 @@ fn build_markdown_lines(content: &str, max_table_width: Option<usize>) -> Vec<So
}
}
source_lines
let show_affordances = mermaid_display(crate::appearance::cache::load_render_mermaid())
== MermaidDisplay::Affordances;
let mut mermaid_after = Vec::new();
if show_affordances {
for (i, range) in mermaid_ranges.iter().enumerate() {
if range.is_empty() {
continue;
}
let Some(&group_idx) = prewrap_to_group.get(range.end - 1) else {
continue;
};
let Some(&src_idx) = group_to_source_idx.get(group_idx) else {
continue;
};
let Some(source) = mermaid.source(i) else {
continue;
};
mermaid_after.push((src_idx, source.to_owned()));
}
}
BuiltMarkdownLines {
source_lines,
mermaid_after,
}
}
/// Convert syntect highlighting output to a ratatui Line.
@ -1726,7 +1961,7 @@ mod tests {
fn source_line(item: &PlanViewerItem) -> &SourceLine {
match item {
PlanViewerItem::Source(source) => source,
PlanViewerItem::Comment(_) => panic!("expected source line"),
_ => panic!("expected source line"),
}
}
@ -1741,8 +1976,9 @@ mod tests {
#[test]
fn build_markdown_lines_preserves_blank_source_lines() {
let lines = build_markdown_lines("# Plan\n\n- First\n\n- Second", Some(80));
let numbered_rows: Vec<(usize, Vec<String>)> = lines
let built = build_markdown_lines("# Plan\n\n- First\n\n- Second", Some(80));
let numbered_rows: Vec<(usize, Vec<String>)> = built
.source_lines
.iter()
.map(|line| {
(
@ -1766,8 +2002,8 @@ mod tests {
#[test]
fn markdown_source_blank_line_renders_as_numbered_empty_row() {
let lines = build_markdown_lines("# Plan\n\n- First", Some(80));
let blank = &lines[1];
let built = build_markdown_lines("# Plan\n\n- First", Some(80));
let blank = &built.source_lines[1];
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 1));
blank.render(Rect::new(0, 0, 20, 1), &mut buf, false, true);
@ -1776,6 +2012,39 @@ mod tests {
assert_eq!(row_text(&buf, 0), "2 ");
}
#[test]
fn mermaid_affordance_respects_render_setting() {
use crate::appearance::{RenderMermaid, cache};
const MD: &str = "# Plan\n\n```mermaid\nflowchart TD\n A --> B\n```\n\nDone.\n";
cache::set_render_mermaid(RenderMermaid::On);
let built = build_markdown_lines(MD, Some(80));
assert_eq!(built.mermaid_after.len(), 1);
assert!(built.mermaid_after[0].1.contains("A --> B"));
assert!(built.mermaid_after[0].0 < built.source_lines.len());
let mut viewer =
LineViewerState::open_markdown_content("plan.md", MD.to_owned(), None).unwrap();
viewer.prepare_layout(100, 40);
assert_eq!(
viewer
.lines
.iter()
.filter(|i| matches!(i, PlanViewerItem::MermaidAffordance(_)))
.count(),
1
);
let placements = viewer.diagram_affordance_placements(Rect::new(0, 0, 100, 40));
assert_eq!(placements.len(), 1);
assert_eq!(placements[0].screen_rect.height, 1);
assert!(placements[0].screen_rect.width > 0);
cache::set_render_mermaid(RenderMermaid::Off);
assert!(build_markdown_lines(MD, Some(80)).mermaid_after.is_empty());
cache::set_render_mermaid(RenderMermaid::Auto);
}
#[test]
fn markdown_viewer_selection_uses_source_line_numbers_with_blank_rows() {
let mut viewer = LineViewerState::open_markdown_content(

View file

@ -166,6 +166,14 @@ impl ListPaneState {
self.scrollbar_dragging
}
/// Whether a mouse position lands in the scrollbar's grab zone
/// ([`crate::render::scrollbar::scrollbar_grab_zone`]).
pub fn scrollbar_hit(&self, column: u16, row: u16) -> bool {
self.scrollbar_area().is_some_and(|sb| {
crate::render::scrollbar::scrollbar_grab_zone(sb).contains((column, row).into())
})
}
/// Whether search/filter is enabled in the config.
pub fn is_search_enabled(&self) -> bool {
self.config.search_enabled
@ -2208,14 +2216,15 @@ impl ListPaneState {
match kind {
MouseEventKind::Down(MouseButton::Left) => {
// Scrollbar click?
if let Some(sb) = self.scrollbar_area()
&& column >= sb.x
&& column < sb.x + sb.width
{
if self.scrollbar_hit(column, row) {
self.scrollbar_dragging = true;
return self.apply_scrollbar_click(row, items);
}
// A new press elsewhere ends any stale thumb latch (lost Up
// from terminal coalescing / SSH / focus loss). Callers that
// treat `is_scrollbar_dragging()` after this dispatch as
// "this Down hit the track" rely on that.
self.scrollbar_dragging = false;
// Content click → select item.
if pane_area.width > 0 && pane_area.height > 0 && row >= pane_area.y {
let ry = (row - pane_area.y) as usize;
@ -2252,12 +2261,7 @@ impl ListPaneState {
items: &[T],
) {
// Check if mouse is on scrollbar → percentage scroll.
if let Some(sb) = self.scrollbar_area()
&& column >= sb.x
&& column < sb.x + sb.width
&& row >= sb.y
&& row < sb.y + sb.height
{
if self.scrollbar_hit(column, row) {
let total = self.total_height();
let pct_delta = ((total as f64) * 0.0025).round() as i32;
let effective = pct_delta.max(lines.abs()) * lines.signum();

View file

@ -2775,4 +2775,42 @@ mod tests {
assert!(state.handle_paste("a\nb", &items));
assert_eq!(state.input_text(), "a\nb");
}
#[test]
fn content_down_clears_stale_scrollbar_drag_latch() {
use crossterm::event::{MouseButton, MouseEventKind};
use ratatui::layout::Rect;
let items: Vec<TestItem> = (0..40).map(TestItem::new).collect();
let mut state = ListPaneState::new(WrapMode::NoWrap, false);
let pane = Rect::new(0, 0, 80, 10);
let track = Rect::new(79, 0, 1, 10);
state.prepare_layout(&items, pane.width, pane.height);
state.set_scrollbar_area(Some(track));
assert!(state.handle_mouse_event(
MouseEventKind::Down(MouseButton::Left),
track.x,
5,
pane,
&items,
));
assert!(
state.is_scrollbar_dragging(),
"press on the track must latch a thumb drag"
);
// Lost Up, then a content press — latch must not stick.
assert!(state.handle_mouse_event(
MouseEventKind::Down(MouseButton::Left),
10,
4,
pane,
&items,
));
assert!(
!state.is_scrollbar_dragging(),
"a later content Down must clear a stale scrollbar latch"
);
}
}

View file

@ -54,6 +54,98 @@ pub enum PermissionFocus {
/// Esc exits back to Options (prompt text is preserved).
/// Enter submits the followup message.
FollowupInput,
/// User is editing a free-form "Always allow" command pattern (a glob).
/// Entered with `e` on a bash prompt; the buffer is [`PatternEditState`].
/// Esc discards it and returns to Options; Enter persists the pattern.
PatternEdit,
}
/// Single-line editor buffer for a free-form "Always allow" command pattern.
///
/// `cursor` is a byte offset into `buffer`, kept on a `char` boundary by every
/// mutation so slicing is always valid. Content mutations set `dirty`; cursor
/// moves do not. A confirmed grant is a glob only when dirty — unedited save
/// is a literal prefix of the pre-filled command.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PatternEditState {
pub buffer: String,
pub cursor: usize,
/// True after any content mutation (insert/delete/clear). Routes the grant
/// to `allowed_bash_globs` when the pattern is confirmed.
dirty: bool,
}
impl PatternEditState {
/// Start editing `initial` with the cursor at the end (clean).
pub fn new(initial: impl Into<String>) -> Self {
let buffer = initial.into();
let cursor = buffer.len();
Self {
buffer,
cursor,
dirty: false,
}
}
/// Whether the user has mutated the buffer since open.
pub fn is_dirty(&self) -> bool {
self.dirty
}
/// The trimmed pattern to persist, or `None` when blank.
pub fn trimmed(&self) -> Option<&str> {
let t = self.buffer.trim();
(!t.is_empty()).then_some(t)
}
pub fn insert_char(&mut self, ch: char) {
self.buffer.insert(self.cursor, ch);
self.cursor += ch.len_utf8();
self.dirty = true;
}
pub fn backspace(&mut self) {
if let Some(ch) = self.buffer[..self.cursor].chars().next_back() {
self.cursor -= ch.len_utf8();
self.buffer.remove(self.cursor);
self.dirty = true;
}
}
pub fn delete(&mut self) {
if self.cursor < self.buffer.len() {
self.buffer.remove(self.cursor);
self.dirty = true;
}
}
pub fn move_left(&mut self) {
if let Some(ch) = self.buffer[..self.cursor].chars().next_back() {
self.cursor -= ch.len_utf8();
}
}
pub fn move_right(&mut self) {
if let Some(ch) = self.buffer[self.cursor..].chars().next() {
self.cursor += ch.len_utf8();
}
}
pub fn move_home(&mut self) {
self.cursor = 0;
}
pub fn move_end(&mut self) {
self.cursor = self.buffer.len();
}
pub fn clear(&mut self) {
if !self.buffer.is_empty() {
self.dirty = true;
}
self.buffer.clear();
self.cursor = 0;
}
}
/// Currently selected scope for an MCP "Always allow" prompt.
@ -201,6 +293,19 @@ impl PermissionViewState {
.as_ref()
.is_some_and(|s| s.server_prefix.is_some())
}
/// Whether this prompt offers the free-form bash pattern editor (`e`): a
/// bash command with an `AllowAlways` row to persist the pattern to. The
/// height reservation, the render/hint gates, and the `e` key handler must
/// all use this so they cannot drift (a stale copy would mis-size the
/// overlay or advertise a key that does nothing).
pub fn has_editable_bash_pattern(&self) -> bool {
self.bash_highlights.is_some()
&& self
.options
.iter()
.any(|o| o.kind == acp::PermissionOptionKind::AllowAlways)
}
}
/// 1-based shortcut character for the given 0-based option index.
@ -269,9 +374,11 @@ fn permission_chrome_height(state: &PermissionViewState, content_w: usize) -> u1
.saturating_add(indicator as usize)
.min(u16::MAX as usize) as u16;
h = h.saturating_add(args_rows);
// Inline "← → choose permission scope" hint when there are highlighted
// words the user can narrow. Must match the render condition exactly.
if state.has_adjustable_scope() {
// Rows reserved for the hint / edit controls; must match the render below:
// two while editing (field + preview), else one when arrows or `e` show.
if state.focus == PermissionFocus::PatternEdit {
h = h.saturating_add(2);
} else if state.has_adjustable_scope() || state.has_editable_bash_pattern() {
h = h.saturating_add(1);
}
h.saturating_add(1) // gap before options
@ -419,6 +526,7 @@ pub fn render_permission_view(
area: Rect,
state: &PermissionViewState,
followup_text: &str,
pattern_edit: Option<&PatternEditState>,
hovered_item: Option<usize>,
theme: &Theme,
focused: bool,
@ -430,6 +538,8 @@ pub fn render_permission_view(
}
let is_followup = state.focus == PermissionFocus::FollowupInput;
// Editor is only active while focus is PatternEdit *and* the buffer exists.
let pattern_edit = pattern_edit.filter(|_| state.focus == PermissionFocus::PatternEdit);
// Fill background — same as the focused prompt (bg_light).
let bg = Style::default().bg(theme.bg_light);
@ -521,8 +631,17 @@ pub fn render_permission_view(
}
let show_scope_hint = state.has_adjustable_scope();
let scope_hint_h: u16 = if show_scope_hint { 1 } else { 0 };
let options_reserve = scope_hint_h + 1 + state.options.len() as u16 + 1;
// Editing needs two rows (field + preview); otherwise one hint row when the
// arrows or the `e` editor affordance is available.
let show_edit_hint = state.has_editable_bash_pattern();
let header_extra_h: u16 = if pattern_edit.is_some() {
2
} else if show_scope_hint || show_edit_hint {
1
} else {
0
};
let options_reserve = header_extra_h + 1 + state.options.len() as u16 + 1;
let max_bash_y = (area.y + area.height).saturating_sub(options_reserve);
let mut last_drawn_bash: Option<usize> = None;
@ -547,19 +666,38 @@ pub fn render_permission_view(
2,
);
}
if show_scope_hint && y < area.y + area.height {
// Readable secondary text, arrows highlighted in accent for
// scannability. Previously used `theme.gray` + `Modifier::DIM`,
// which was unreadable on several theme backgrounds.
if let Some(edit) = pattern_edit {
// ── Free-form pattern editor (two rows) ──
if y < area.y + area.height {
render_pattern_editor_line(buf, content_x, y, content_width, edit, theme);
y += 1;
}
if y < area.y + area.height {
let command = preview_command_text(state);
render_pattern_preview_line(buf, content_x, y, content_width, edit, &command, theme);
y += 1;
}
} else if (show_scope_hint || show_edit_hint) && y < area.y + area.height {
// Readable secondary text (accent-highlighted keys). Advertise the
// arrows only when there's a scope to move between, but always offer
// `e edit` on a bash prompt so the free-form option is discoverable.
let hint_style = Style::default()
.fg(theme.text_secondary)
.add_modifier(Modifier::DIM);
let hint_line = Line::from(vec![
Span::styled("Use ", hint_style),
Span::styled("\u{2190} \u{2192}", hint_style),
Span::styled(" to choose permission scope", hint_style),
]);
buf.set_line(content_x, y, &hint_line, content_width);
let key_style = Style::default().fg(theme.accent_user);
let mut spans: Vec<Span<'static>> = Vec::new();
if show_scope_hint {
spans.push(Span::styled("\u{2190} \u{2192}", key_style));
spans.push(Span::styled(" narrow scope", hint_style));
}
if show_edit_hint {
if show_scope_hint {
spans.push(Span::styled(" \u{00b7} ", hint_style));
}
spans.push(Span::styled("e", key_style));
spans.push(Span::styled(" edit pattern", hint_style));
}
buf.set_line(content_x, y, &Line::from(spans), content_width);
y += 1;
}
@ -691,6 +829,123 @@ pub fn render_permission_view(
}
}
/// The primary command text the session enforcer matches a bash grant against:
/// the primary segment's words with wrappers (`timeout`/`nice`/`env`) peeled.
/// Shared by the pattern editor's pre-fill and its live match preview so both
/// agree with enforcement. Falls back to the raw command when untokenized.
pub(crate) fn preview_command_text(state: &PermissionViewState) -> String {
match state.bash_highlights.as_ref() {
Some(h) => xai_grok_workspace::permission::bash_command_splitting::unwrap_command_wrappers(
&h.highlighted_words,
)
.join(" "),
None => state.bash_command_raw.clone().unwrap_or_default(),
}
}
/// Draw the single-line free-form pattern editor: an ` ` prompt followed by
/// the buffer text with a block caret. Horizontally scrolls to keep the cursor
/// visible so long patterns stay editable in a narrow overlay.
fn render_pattern_editor_line(
buf: &mut Buffer,
content_x: u16,
y: u16,
content_width: u16,
edit: &PatternEditState,
theme: &Theme,
) {
let prompt_style = Style::default().fg(theme.accent_user);
buf.set_span(content_x, y, &Span::styled("\u{276f} ", prompt_style), 2);
let text_x = content_x + 2;
let window = content_width.saturating_sub(2) as usize;
if window == 0 {
return;
}
let chars: Vec<char> = edit.buffer.chars().collect();
let cursor_idx = edit.buffer[..edit.cursor].chars().count();
// Reserve one column for the caret so an end-of-line cursor is visible.
let start = (cursor_idx + 1).saturating_sub(window);
let text_style = Style::default().fg(theme.text_primary);
let caret_style = Style::default().fg(theme.bg_light).bg(theme.accent_user);
let end = (start + window).min(chars.len());
let mut col: u16 = 0;
for (offset, ch) in chars[start..end].iter().enumerate() {
let idx = start + offset;
let style = if idx == cursor_idx {
caret_style
} else {
text_style
};
buf.set_span(text_x + col, y, &Span::styled(ch.to_string(), style), 1);
col += 1;
}
// Block caret past the final character (cursor at end of buffer).
if cursor_idx >= chars.len() && (col as usize) < window {
buf.set_span(text_x + col, y, &Span::styled(" ", caret_style), 1);
}
}
/// Draw the live preview line under the pattern editor: whether the edited
/// pattern still matches the command being approved (reuses the real evaluator
/// so it can't drift), a non-blocking "very broad" warning, and the key hints.
fn render_pattern_preview_line(
buf: &mut Buffer,
content_x: u16,
y: u16,
content_width: u16,
edit: &PatternEditState,
command: &str,
theme: &Theme,
) {
let dim = Style::default()
.fg(theme.text_secondary)
.add_modifier(Modifier::DIM);
let sep = Span::styled(" \u{00b7} ", dim);
let mut spans: Vec<Span<'static>> = Vec::new();
match edit.trimmed() {
None => {
spans.push(Span::styled(
"type a command pattern to allow (e.g. gh api repos/*)",
dim,
));
}
Some(pattern) => {
if xai_grok_workspace::permission::bash_pattern_matches_command(pattern, command) {
spans.push(Span::styled(
"\u{2713} matches this command",
Style::default().fg(theme.accent_success),
));
} else {
spans.push(Span::styled(
"\u{2717} won't match this command",
Style::default().fg(theme.accent_error),
));
}
if xai_grok_workspace::permission::bash_pattern_is_broad(pattern) {
spans.push(sep.clone());
spans.push(Span::styled(
"\u{26a0} very broad",
Style::default().fg(theme.warning),
));
}
spans.push(sep);
spans.push(Span::styled(
"Enter",
Style::default().fg(theme.accent_user),
));
spans.push(Span::styled(" save ", dim));
spans.push(Span::styled("Esc", Style::default().fg(theme.accent_user)));
spans.push(Span::styled(" cancel", dim));
}
}
buf.set_line(content_x, y, &Line::from(spans), content_width);
}
/// Wrap + syntax-highlight a bash command the same way the permission
/// overlay body does: preserve source newlines / `\` continuations, keep
/// heredoc bodies intact, quote-aware width wrap only — **no** soft-breaks
@ -1828,6 +2083,38 @@ mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn pattern_edit_edits_at_the_cursor() {
let mut e = PatternEditState::new("ghapi");
assert!(!e.is_dirty());
assert_eq!(e.cursor, "ghapi".len()); // new() starts at the end
e.move_home();
e.move_right();
e.move_right();
assert!(!e.is_dirty(), "cursor moves are not content mutations");
e.insert_char(' ');
assert!(e.is_dirty());
assert_eq!(e.buffer, "gh api");
e.delete();
assert_eq!(e.buffer, "gh pi");
e.move_home();
e.backspace(); // no-op at start
assert_eq!((e.buffer.as_str(), e.cursor), ("gh pi", 0));
e.clear();
assert_eq!(e.trimmed(), None);
assert!(e.is_dirty());
}
#[test]
fn pattern_edit_respects_char_boundaries() {
let mut e = PatternEditState::new("café");
e.backspace();
assert_eq!(e.buffer, "caf");
e.insert_char('é');
assert_eq!(e.buffer, "café");
assert!(e.is_dirty());
}
fn mcp_state(tool: &str, server: Option<&str>, selected: McpScope) -> McpScopeState {
McpScopeState {
tool_name: tool.to_owned(),
@ -1912,7 +2199,9 @@ mod tests {
let state = permission_state_with_title("Allow command?", 3);
let area = Rect::new(2, area_y, 145, area_h);
let mut buf = Buffer::empty(Rect::new(0, 0, 147, buf_h));
let _ = render_permission_view(&mut buf, area, &state, "", None, &theme, true);
let _ = render_permission_view(
&mut buf, area, &state, "", None, None, &theme, true,
);
}
}
}
@ -1936,7 +2225,7 @@ mod tests {
let area = Rect::new(0, area_y, buf_w, area_h);
let mut buf = Buffer::empty(Rect::new(0, 0, buf_w.max(1), 10));
let _ = render_permission_view(
&mut buf, area, &state, "follow", None, &theme, true,
&mut buf, area, &state, "follow", None, None, &theme, true,
);
}
}
@ -2227,7 +2516,7 @@ mod tests {
let theme = Theme::current();
let area = Rect::new(0, 0, 80, 20);
let mut buf = Buffer::empty(area);
let _ = render_permission_view(&mut buf, area, &state, "", None, &theme, true);
let _ = render_permission_view(&mut buf, area, &state, "", None, None, &theme, true);
let text: String = (0..area.height)
.map(|row| {
@ -2268,7 +2557,7 @@ mod tests {
fn render_to_text(state: &PermissionViewState, area: Rect) -> String {
let theme = Theme::current();
let mut buf = Buffer::empty(area);
let _ = render_permission_view(&mut buf, area, state, "", None, &theme, true);
let _ = render_permission_view(&mut buf, area, state, "", None, None, &theme, true);
(0..area.height)
.map(|row| {
(area.x..area.x + area.width)

View file

@ -56,6 +56,19 @@ pub enum QuestionSelection {
Multi(HashSet<usize>),
}
/// A cursor move within one question's answer rows.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CursorMotion {
Next,
Prev,
HalfPageDown,
HalfPageUp,
PageDown,
PageUp,
First,
Last,
}
/// Focus mode within the question view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuestionFocus {
@ -332,6 +345,53 @@ impl QuestionViewState {
.unwrap_or(0)
}
/// Move the cursor within the active question, clamped at both ends.
pub fn move_cursor(&mut self, motion: CursorMotion) {
let last = self.total_items(self.active_tab).saturating_sub(1);
let cursor = self.cursor();
let target = match motion {
CursorMotion::Next => cursor + 1,
CursorMotion::Prev => cursor.saturating_sub(1),
CursorMotion::HalfPageDown => cursor + (last / 2).max(1),
CursorMotion::HalfPageUp => cursor.saturating_sub((last.max(1) / 2).max(1)),
CursorMotion::PageDown => cursor + last.max(1),
CursorMotion::PageUp => cursor.saturating_sub(last.max(1)),
CursorMotion::First => 0,
CursorMotion::Last => last,
};
self.set_cursor(target.min(last));
}
pub fn is_on_first_row(&self) -> bool {
self.cursor() == 0
}
pub fn is_on_last_row(&self) -> bool {
self.cursor() + 1 >= self.total_items(self.active_tab)
}
/// Whether the question at `q_idx` has any answer marked.
pub fn has_selection(&self, q_idx: usize) -> bool {
let option_selected = !self.selected_labels(q_idx).is_empty();
let freeform_selected = self
.per_question_freeform_selected
.get(q_idx)
.copied()
.unwrap_or(false);
option_selected || freeform_selected
}
pub fn clear_selection(&mut self, q_idx: usize) {
match self.selections.get_mut(q_idx) {
Some(QuestionSelection::Multi(selected)) => selected.clear(),
Some(QuestionSelection::Single(selected)) => *selected = None,
None => {}
}
if let Some(freeform_selected) = self.per_question_freeform_selected.get_mut(q_idx) {
*freeform_selected = false;
}
}
/// Set cursor position for the active question, clamped to valid range.
pub fn set_cursor(&mut self, pos: usize) {
let max = self.total_items(self.active_tab).saturating_sub(1);
@ -813,14 +873,7 @@ impl QuestionViewState {
/// nothing is selected, `Esc` (which only clears the selection) has
/// nothing to do, so it can fall through to the dashboard back-out.
pub fn active_tab_has_selection(&self) -> bool {
let idx = self.active_tab;
let option_selected = !self.selected_labels(idx).is_empty();
let freeform_selected = self
.per_question_freeform_selected
.get(idx)
.copied()
.unwrap_or(false);
option_selected || freeform_selected
self.has_selection(self.active_tab)
}
}
@ -952,6 +1005,24 @@ impl QuestionViewState {
pub fn prev_question(&mut self) {
self.active_tab = self.active_tab.saturating_sub(1);
}
/// Advance to the next question (wraps past the last, back to the first).
pub fn wrapping_next_question(&mut self) {
let last = self.questions.len().saturating_sub(1);
self.active_tab = if self.active_tab < last {
self.active_tab + 1
} else {
0
};
}
/// Go to the previous question (wraps before the first, round to the last).
pub fn wrapping_prev_question(&mut self) {
self.active_tab = match self.active_tab.checked_sub(1) {
Some(prev) => prev,
None => self.questions.len().saturating_sub(1),
};
}
}
// ── Rendering ──────────────────────────────────────────────────────────
@ -2362,6 +2433,39 @@ mod tests {
assert_eq!(state.active_tab, 0); // clamped at start
}
#[test]
fn wrapping_question_cycling_loops_at_boundaries() {
let qs = vec![
make_question("Q1?", &["A"], false),
make_question("Q2?", &["B"], false),
];
let mut state = QuestionViewState::new("tc".into(), qs, StashedPrompt::default());
state.wrapping_next_question();
assert_eq!(state.active_tab, 1);
state.wrapping_next_question();
assert_eq!(
state.active_tab, 0,
"past the last question, back to the first"
);
state.wrapping_prev_question();
assert_eq!(
state.active_tab, 1,
"before the first question, round to the last"
);
let mut single = QuestionViewState::new(
"tc".into(),
vec![make_question("Only?", &["A"], false)],
StashedPrompt::default(),
);
single.wrapping_next_question();
assert_eq!(single.active_tab, 0);
single.wrapping_prev_question();
assert_eq!(single.active_tab, 0);
}
// ── compute_max_label_w ────────────────────────────────────────────
#[test]

View file

@ -20,6 +20,17 @@ use crate::views::picker::{PickerEntry, PickerField, PickerRow, PickerState};
/// they don't collide with fuzzy-entry indices.
pub const CONTENT_EXPAND_OFFSET: usize = 100_000;
/// Session id for free-text Enter (`SubmitQuery` with no selectable rows).
///
/// Only a trimmed UUID is loadable — pasted garbage must not call
/// `LoadSession` (that left the TUI stuck mid-load).
pub fn session_id_for_direct_load(query: &str) -> Option<&str> {
let q = query.trim();
// `Uuid::try_parse` rejects empty, multi-line, and non-UUID text.
uuid::Uuid::try_parse(q).ok()?;
Some(q)
}
/// Derive a short repo display name from a CWD path.
///
/// Uses the last 2 normal path components joined by `-`. For paths with
@ -1730,4 +1741,16 @@ mod tests {
Some(PickerItem::Fuzzy { original_index: 0 })
));
}
#[test]
fn session_id_for_direct_load_accepts_uuid_only() {
let sid = "019fb61a-85a5-7ba0-a4ec-24647dca1893";
assert_eq!(session_id_for_direct_load(sid), Some(sid));
assert_eq!(session_id_for_direct_load(&format!(" {sid} ")), Some(sid));
assert_eq!(session_id_for_direct_load("not-a-uuid"), None);
assert_eq!(session_id_for_direct_load(""), None);
assert_eq!(session_id_for_direct_load("pasted garbage!!!"), None);
assert_eq!(session_id_for_direct_load("hello\nworld"), None);
assert_eq!(session_id_for_direct_load(&format!("{sid}\nextra")), None);
}
}