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:
parent
a422116582
commit
780d1388ff
323 changed files with 12258 additions and 7226 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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(¬if).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(¬if).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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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), ®istry);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
460
crates/codegen/xai-grok-pager/src/app/agent_view/viewer_tests.rs
Normal file
460
crates/codegen/xai-grok-pager/src/app/agent_view/viewer_tests.rs
Normal 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),
|
||||
®istry,
|
||||
);
|
||||
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
let _ = agent.handle_input(
|
||||
&mouse(MouseEventKind::Drag(MouseButton::Left), 10, 6),
|
||||
®istry,
|
||||
);
|
||||
{
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
{
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
|
||||
// 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),
|
||||
®istry,
|
||||
);
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
|
||||
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),
|
||||
®istry,
|
||||
);
|
||||
let _ = agent.handle_input(
|
||||
&mouse(MouseEventKind::Up(MouseButton::Left), TRACK_X + 1, 9),
|
||||
®istry,
|
||||
);
|
||||
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})"
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<_>>()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue