docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
@@ -48,8 +48,6 @@ const MEASURE_TIME: Duration = Duration::from_secs(3);
|
||||
const MEASURE_TIME_HEAVY: Duration = Duration::from_secs(5);
|
||||
const RENDER_WIDTH: u16 = 120;
|
||||
|
||||
// ── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
struct Fixture {
|
||||
_dir: TempDir,
|
||||
path: PathBuf,
|
||||
@@ -170,8 +168,6 @@ fn make_hunk_at(file_lines: &[&str], close_i: usize) -> Option<DiffHunk> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Non-product prefix baseline ─────────────────────────────────────────────
|
||||
|
||||
fn own_ranges(ranges: Vec<(SyntectStyle, &str)>) -> Vec<(SyntectStyle, String)> {
|
||||
ranges.into_iter().map(|(s, t)| (s, t.to_owned())).collect()
|
||||
}
|
||||
@@ -233,8 +229,6 @@ fn estimate_style_map_bytes(map: &HashMap<usize, Vec<(ratatui::style::Style, Str
|
||||
bytes
|
||||
}
|
||||
|
||||
// ── Bench groups ────────────────────────────────────────────────────────────
|
||||
|
||||
fn configure_fast(group: &mut criterion::BenchmarkGroup<'_, criterion::measurement::WallTime>) {
|
||||
group
|
||||
.sample_size(SAMPLE_SIZE)
|
||||
@@ -287,7 +281,6 @@ fn bench_matrix(c: &mut Criterion) {
|
||||
let config = DiffRenderConfig::default();
|
||||
let syntect = get_syntect();
|
||||
|
||||
// ── 500L + prefix (own heavy config) ────────────────────────────────────
|
||||
{
|
||||
let fx = gen_python_fixture(500, 8);
|
||||
let file_lines: Vec<&str> = fx.file_text.lines().collect();
|
||||
@@ -308,7 +301,6 @@ fn bench_matrix(c: &mut Criterion) {
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ── 10kL session (no prefix) ────────────────────────────────────────────
|
||||
{
|
||||
let fx = gen_python_fixture(10_000, 40);
|
||||
let styles = compute_file_scoped_styles(fx.path(), &fx.file_text, &fx.hunks)
|
||||
|
||||
@@ -23,11 +23,9 @@ use kigi_tui::theme::Theme;
|
||||
|
||||
static BENCH_MD: &str = include_str!("bench.md");
|
||||
|
||||
/// Viewport dimensions for the benchmark.
|
||||
const VIEWPORT_WIDTH: u16 = 120;
|
||||
const VIEWPORT_HEIGHT: u16 = 50;
|
||||
|
||||
/// How many lines to advance per step in full_scroll.
|
||||
const SCROLL_STEP: u16 = 10;
|
||||
|
||||
/// Entry count for the reveal benchmarks. Approximates the ~3,200-entry
|
||||
@@ -76,8 +74,6 @@ fn compute_layouts(
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ─── Benchmarks ────────────────────────────────────────────────────
|
||||
|
||||
/// Render a single frame at scroll offset 0 (top of document).
|
||||
///
|
||||
/// The per-frame baseline with no top clipping — only bottom-clipped.
|
||||
@@ -152,11 +148,10 @@ fn bench_full_scroll(c: &mut Criterion) {
|
||||
let appearance = AppearanceConfig::default();
|
||||
let layouts = compute_layouts(&entries, &appearance);
|
||||
|
||||
// usize: scroll offset is usize in the render path.
|
||||
let total: usize = layouts
|
||||
.iter()
|
||||
.map(|l| l.height as usize + l.gap_after as usize)
|
||||
.sum(); // heights + gaps
|
||||
.sum();
|
||||
let max_scroll = total.saturating_sub(VIEWPORT_HEIGHT as usize);
|
||||
|
||||
// Prime the wrap cache
|
||||
@@ -271,8 +266,6 @@ fn bench_windowed_scroll(c: &mut Criterion) {
|
||||
g.finish();
|
||||
}
|
||||
|
||||
// ─── Reveal (scrollback-search n/N navigation) ─────────────────────
|
||||
|
||||
/// One paragraph of lorem-style body per entry (~1.7 KB), so the
|
||||
/// `REVEAL_ENTRIES`-entry corpus is on the order of the motivating session's
|
||||
/// searchable text (a few MB; the exact size is logged).
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
//! Criterion benchmarks for scrollback search.
|
||||
//!
|
||||
//! - `scan` measures the raw regex scan over a large corpus — the work that ran
|
||||
//! synchronously on the input thread on every keystroke before the background
|
||||
//! daemon, and now runs off-thread.
|
||||
//! - `query_steady` / `query_cold` measure the UI-thread cost of `update_query`
|
||||
//! after the daemon change: a steady keystroke only compiles the matcher and
|
||||
//! enqueues the query (the scan is off-thread), while the cold path also
|
||||
//! rebuilds and ships the corpus on a content change.
|
||||
//! - `scan` measures the raw regex scan over a large corpus — the work the
|
||||
//! daemon runs off the input thread, sparing the UI a per-keystroke scan.
|
||||
//! - `query_steady` / `query_cold` measure the UI-thread cost of `update_query`:
|
||||
//! a steady keystroke only compiles the matcher and enqueues the query (the
|
||||
//! scan is off-thread), while the cold path also rebuilds and ships the corpus
|
||||
//! on a content change.
|
||||
|
||||
use std::hint::black_box;
|
||||
use std::time::Duration;
|
||||
@@ -53,9 +52,8 @@ fn build_large_scrollback(entries: usize) -> ScrollbackState {
|
||||
state
|
||||
}
|
||||
|
||||
/// The regex scan itself — the work the daemon now runs off the input thread
|
||||
/// (previously this ran synchronously per keystroke). `fox` appears in every
|
||||
/// entry, the worst case for match collection.
|
||||
/// The regex scan itself — the corpus work the daemon runs off the input
|
||||
/// thread. `fox` appears in every entry, the worst case for match collection.
|
||||
fn bench_scan(c: &mut Criterion) {
|
||||
let state = build_large_scrollback(CORPUS_ENTRIES);
|
||||
let mut index = ScrollbackSearchIndex::new();
|
||||
@@ -71,9 +69,9 @@ fn bench_scan(c: &mut Criterion) {
|
||||
g.finish();
|
||||
}
|
||||
|
||||
/// Steady keystroke after the daemon: the corpus is already shipped, so
|
||||
/// `update_query` just compiles the matcher and enqueues the query, and `poll`
|
||||
/// picks up the async result — no scan on the UI thread.
|
||||
/// Steady keystroke: the corpus is already shipped, so `update_query` just
|
||||
/// compiles the matcher and enqueues the query, and `poll` picks up the async
|
||||
/// result — no scan on the UI thread.
|
||||
fn bench_query_steady(c: &mut Criterion) {
|
||||
let state = build_large_scrollback(CORPUS_ENTRIES);
|
||||
let mut search = ScrollbackSearchState::open();
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Simulate SubagentInfo with String fields (before optimization)
|
||||
#[derive(Clone)]
|
||||
struct SubagentInfoString {
|
||||
subagent_id: String,
|
||||
@@ -27,7 +26,6 @@ struct SubagentInfoString {
|
||||
tools_used: Vec<String>,
|
||||
}
|
||||
|
||||
/// Simulate SubagentInfo with Arc<str> fields (after optimization)
|
||||
#[derive(Clone)]
|
||||
struct SubagentInfoArc {
|
||||
subagent_id: Arc<str>,
|
||||
@@ -85,10 +83,10 @@ fn estimate_string_info_size(info: &SubagentInfoString) -> usize {
|
||||
fn estimate_arc_info_size(info: &SubagentInfoArc) -> usize {
|
||||
// Arc<str> has 16 bytes overhead (fat pointer) but shares the string data
|
||||
std::mem::size_of::<SubagentInfoArc>()
|
||||
+ 16 // subagent_id Arc overhead
|
||||
+ 16 // child_session_id Arc overhead
|
||||
+ 16 // description Arc overhead
|
||||
+ 16 // subagent_type Arc overhead
|
||||
+ 16
|
||||
+ 16
|
||||
+ 16
|
||||
+ 16
|
||||
+ info.persona.as_ref().map_or(0, |_| 16)
|
||||
+ info.role.as_ref().map_or(0, |_| 16)
|
||||
+ info.model.as_ref().map_or(0, |_| 16)
|
||||
@@ -99,7 +97,6 @@ fn estimate_arc_info_size(info: &SubagentInfoArc) -> usize {
|
||||
fn main() {
|
||||
println!("=== SubagentInfo Memory Benchmark ===\n");
|
||||
|
||||
// Test 1: Single instance
|
||||
println!("--- Single Instance ---");
|
||||
let string_info = create_string_info(1);
|
||||
let arc_info = create_arc_info(1);
|
||||
@@ -114,14 +111,11 @@ fn main() {
|
||||
);
|
||||
println!();
|
||||
|
||||
// Test 2: Many instances with shared strings
|
||||
println!("--- 1000 Instances (with string sharing potential) ---");
|
||||
let count = 1000;
|
||||
|
||||
// String-based: each instance has its own copy of shared strings
|
||||
let string_infos: Vec<SubagentInfoString> = (0..count).map(create_string_info).collect();
|
||||
|
||||
// Arc-based: shared strings are deduplicated
|
||||
let arc_infos: Vec<SubagentInfoArc> = (0..count).map(create_arc_info).collect();
|
||||
|
||||
let string_total: usize = string_infos.iter().map(estimate_string_info_size).sum();
|
||||
@@ -145,7 +139,6 @@ fn main() {
|
||||
);
|
||||
println!();
|
||||
|
||||
// Test 3: Clone performance
|
||||
println!("--- Clone Performance (100,000 clones) ---");
|
||||
let iterations = 100_000;
|
||||
|
||||
@@ -169,7 +162,6 @@ fn main() {
|
||||
);
|
||||
println!();
|
||||
|
||||
// Test 4: Memory with realistic subagent data
|
||||
println!("--- Realistic Scenario (100 subagents with varied data) ---");
|
||||
let subagent_types = ["general-purpose", "explore", "plan", "implementer"];
|
||||
let personas = ["researcher", "analyst", "reviewer", "implementer"];
|
||||
@@ -240,7 +232,6 @@ fn main() {
|
||||
);
|
||||
println!();
|
||||
|
||||
// Summary
|
||||
println!("=== Summary ===");
|
||||
println!("Arc<str> optimization provides:");
|
||||
println!("1. Memory savings through string deduplication (shared strings stored once)");
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Accurate memory benchmark for SubagentInfo Arc<str> optimization.
|
||||
//!
|
||||
//! This benchmark uses dhat for heap profiling to accurately measure memory usage.
|
||||
//!
|
||||
//! Run with: cargo run --release --example memory_benchmark_accurate
|
||||
|
||||
// Illustrative mock structs whose fields exist to model memory layout; not all
|
||||
@@ -11,7 +9,6 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Simulate SubagentInfo with String fields (before optimization)
|
||||
#[derive(Clone)]
|
||||
struct SubagentInfoString {
|
||||
subagent_id: String,
|
||||
@@ -25,7 +22,6 @@ struct SubagentInfoString {
|
||||
tools_used: Vec<String>,
|
||||
}
|
||||
|
||||
/// Simulate SubagentInfo with Arc<str> fields (after optimization)
|
||||
#[derive(Clone)]
|
||||
struct SubagentInfoArc {
|
||||
subagent_id: Arc<str>,
|
||||
@@ -83,7 +79,6 @@ fn create_arc_info(
|
||||
fn main() {
|
||||
println!("=== SubagentInfo Memory Benchmark (Accurate) ===\n");
|
||||
|
||||
// Test 1: Clone performance - the most impactful optimization
|
||||
println!("--- Clone Performance (1,000,000 clones) ---");
|
||||
let iterations = 1_000_000;
|
||||
|
||||
@@ -110,7 +105,6 @@ fn main() {
|
||||
);
|
||||
println!();
|
||||
|
||||
// Test 2: Memory with shared strings (realistic scenario)
|
||||
println!("--- Memory with Shared Strings (1000 subagents) ---");
|
||||
println!("Scenario: 1000 subagents sharing subagent_type, model, persona, status");
|
||||
|
||||
@@ -118,7 +112,6 @@ fn main() {
|
||||
let shared_models = ["kigi-3", "kigi-3-mini"];
|
||||
let shared_personas = ["researcher", "analyst", "reviewer"];
|
||||
|
||||
// Create string-based infos
|
||||
let mut string_infos: Vec<SubagentInfoString> = Vec::with_capacity(1000);
|
||||
for i in 0..1000 {
|
||||
let st = shared_types[i % shared_types.len()];
|
||||
@@ -127,7 +120,6 @@ fn main() {
|
||||
string_infos.push(create_string_info(i, st, m, p));
|
||||
}
|
||||
|
||||
// Create Arc-based infos (with string sharing)
|
||||
let mut arc_infos: Vec<SubagentInfoArc> = Vec::with_capacity(1000);
|
||||
for i in 0..1000 {
|
||||
let st = shared_types[i % shared_types.len()];
|
||||
@@ -136,8 +128,6 @@ fn main() {
|
||||
arc_infos.push(create_arc_info(i, st, m, p));
|
||||
}
|
||||
|
||||
// Calculate memory
|
||||
// For String: each instance has its own copy
|
||||
let string_mem: usize = string_infos
|
||||
.iter()
|
||||
.map(|info| {
|
||||
@@ -154,7 +144,6 @@ fn main() {
|
||||
.sum();
|
||||
|
||||
// For Arc<str>: shared strings are stored once
|
||||
// Count unique strings
|
||||
let mut unique_types = std::collections::HashSet::new();
|
||||
let mut unique_models = std::collections::HashSet::new();
|
||||
let mut unique_personas = std::collections::HashSet::new();
|
||||
@@ -177,26 +166,30 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Arc<str> memory: unique strings + Arc overhead per reference
|
||||
let shared_string_mem: usize = unique_types.iter().map(|s| s.len()).sum::<usize>()
|
||||
+ unique_models.iter().map(|s| s.len()).sum::<usize>()
|
||||
+ unique_personas.iter().map(|s| s.len()).sum::<usize>()
|
||||
+ unique_statuses.iter().map(|s| s.len()).sum::<usize>()
|
||||
+ unique_tools.iter().map(|s| s.len()).sum::<usize>();
|
||||
|
||||
// Per-instance memory for unique fields + Arc overhead
|
||||
let per_instance_mem: usize = arc_infos
|
||||
.iter()
|
||||
.map(|info| {
|
||||
info.subagent_id.len() + 16 + // Arc overhead
|
||||
info.child_session_id.len() + 16 +
|
||||
info.description.len() + 16 +
|
||||
16 + // subagent_type Arc (shared)
|
||||
info.persona.as_ref().map_or(0, |_| 16) + // Arc overhead
|
||||
info.role.as_ref().map_or(0, |_| 16) +
|
||||
info.model.as_ref().map_or(0, |_| 16) +
|
||||
info.status.as_ref().map_or(0, |_| 16) +
|
||||
info.tools_used.len() * 16
|
||||
// Unique fields add their bytes plus a 16-byte Arc pointer; shared
|
||||
// fields contribute only the pointer, their bytes counted once in
|
||||
// shared_string_mem.
|
||||
info.subagent_id.len()
|
||||
+ 16
|
||||
+ info.child_session_id.len()
|
||||
+ 16
|
||||
+ info.description.len()
|
||||
+ 16
|
||||
+ 16
|
||||
+ info.persona.as_ref().map_or(0, |_| 16)
|
||||
+ info.role.as_ref().map_or(0, |_| 16)
|
||||
+ info.model.as_ref().map_or(0, |_| 16)
|
||||
+ info.status.as_ref().map_or(0, |_| 16)
|
||||
+ info.tools_used.len() * 16
|
||||
})
|
||||
.sum();
|
||||
|
||||
@@ -222,7 +215,6 @@ fn main() {
|
||||
);
|
||||
println!();
|
||||
|
||||
// Test 3: HashMap key performance
|
||||
println!("--- HashMap Key Performance (100,000 lookups) ---");
|
||||
let iterations = 100_000;
|
||||
|
||||
@@ -256,7 +248,6 @@ fn main() {
|
||||
println!("Arc<str> key lookup time: {:?}", arc_lookup_time);
|
||||
println!();
|
||||
|
||||
// Summary
|
||||
println!("=== Summary ===");
|
||||
println!("Arc<str> optimization provides:");
|
||||
println!(
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Simulate SubagentInfo with String fields (before optimization)
|
||||
#[derive(Clone, Debug)]
|
||||
struct SubagentInfoString {
|
||||
subagent_id: String,
|
||||
@@ -26,7 +25,6 @@ struct SubagentInfoString {
|
||||
tools_used: Vec<String>,
|
||||
}
|
||||
|
||||
/// Simulate SubagentInfo with Arc<str> fields (after optimization)
|
||||
#[derive(Clone, Debug)]
|
||||
struct SubagentInfoArc {
|
||||
subagent_id: Arc<str>,
|
||||
@@ -40,7 +38,6 @@ struct SubagentInfoArc {
|
||||
tools_used: Vec<Arc<str>>,
|
||||
}
|
||||
|
||||
/// Parsed SubagentSpawned event from updates.jsonl
|
||||
#[derive(Debug)]
|
||||
struct SubagentSpawnedEvent {
|
||||
subagent_id: String,
|
||||
@@ -53,10 +50,8 @@ struct SubagentSpawnedEvent {
|
||||
}
|
||||
|
||||
fn parse_subagent_spawned(line: &str) -> Option<SubagentSpawnedEvent> {
|
||||
// Parse JSON line looking for SubagentSpawned events
|
||||
let value: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||
|
||||
// Check if this is a SubagentSpawned event
|
||||
// Format: {"params": {"update": {"sessionUpdate": "subagent_spawned", ...}}}
|
||||
let update = value.get("params")?.get("update")?;
|
||||
if update.get("sessionUpdate")?.as_str()? != "subagent_spawned" {
|
||||
@@ -126,13 +121,13 @@ fn estimate_string_size(info: &SubagentInfoString) -> usize {
|
||||
fn estimate_arc_size(info: &SubagentInfoArc) -> usize {
|
||||
// Arc<str> overhead is 16 bytes (fat pointer) per field
|
||||
// But shared strings are stored once
|
||||
16 * 4 + // subagent_id, child_session_id, description, subagent_type
|
||||
16 * 4 +
|
||||
info.persona.as_ref().map_or(0, |_| 16) +
|
||||
info.role.as_ref().map_or(0, |_| 16) +
|
||||
info.model.as_ref().map_or(0, |_| 16) +
|
||||
info.status.as_ref().map_or(0, |_| 16) +
|
||||
info.tools_used.len() * 16 +
|
||||
// Add actual string lengths (shared, so counted once per unique string)
|
||||
// Byte lengths for the per-instance unique fields only; shared fields' bytes are not counted per instance.
|
||||
info.subagent_id.len() +
|
||||
info.child_session_id.len() +
|
||||
info.description.len()
|
||||
@@ -167,7 +162,6 @@ fn main() {
|
||||
println!("File size: {:.1} MB", file_size as f64 / 1_000_000.0);
|
||||
println!();
|
||||
|
||||
// Parse SubagentSpawned events
|
||||
println!("--- Parsing SubagentSpawned events ---");
|
||||
let start = Instant::now();
|
||||
|
||||
@@ -192,11 +186,9 @@ fn main() {
|
||||
println!("No SubagentSpawned events found in this session.");
|
||||
println!("Trying to find any session with subagents...");
|
||||
|
||||
// Try a different approach - look for any session with subagents
|
||||
return;
|
||||
}
|
||||
|
||||
// Show sample events
|
||||
println!("--- Sample SubagentSpawned events ---");
|
||||
for (i, event) in events.iter().take(5).enumerate() {
|
||||
println!(
|
||||
@@ -209,14 +201,12 @@ fn main() {
|
||||
}
|
||||
println!();
|
||||
|
||||
// Create SubagentInfo instances
|
||||
println!("--- Creating SubagentInfo instances ---");
|
||||
|
||||
let string_infos: Vec<SubagentInfoString> = events.iter().map(create_string_info).collect();
|
||||
|
||||
let arc_infos: Vec<SubagentInfoArc> = events.iter().map(create_arc_info).collect();
|
||||
|
||||
// Calculate memory
|
||||
let string_mem: usize = string_infos.iter().map(estimate_string_size).sum();
|
||||
let arc_mem: usize = arc_infos.iter().map(estimate_arc_size).sum();
|
||||
|
||||
@@ -243,7 +233,6 @@ fn main() {
|
||||
}
|
||||
println!();
|
||||
|
||||
// Analyze string sharing
|
||||
println!("--- String Sharing Analysis ---");
|
||||
let mut unique_types = std::collections::HashSet::new();
|
||||
let mut unique_models = std::collections::HashSet::new();
|
||||
@@ -271,7 +260,6 @@ fn main() {
|
||||
println!(" Personas: {:?}", unique_personas);
|
||||
println!();
|
||||
|
||||
// Clone performance
|
||||
println!("--- Clone Performance (100,000 clones) ---");
|
||||
let iterations = 100_000;
|
||||
|
||||
@@ -299,7 +287,6 @@ fn main() {
|
||||
}
|
||||
println!();
|
||||
|
||||
// Summary
|
||||
println!("=== Summary ===");
|
||||
println!(
|
||||
"Session: {:?}",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! Strongly-typed notification metadata.
|
||||
//!
|
||||
//! Parses the `_meta` JSON from `SessionNotification` into a struct with
|
||||
//! typed fields. All fields are `Option` — gracefully degrades when
|
||||
//! kigi-shell hasn't been updated or meta is absent.
|
||||
//! Parses the `_meta` JSON from `SessionNotification` into typed fields that
|
||||
//! gracefully degrade when kigi-shell hasn't been updated or meta is absent.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ pub struct AcpConnection {
|
||||
pub rx: AcpClientRx,
|
||||
/// Available models and current selection.
|
||||
pub models: ModelState,
|
||||
/// Whether the agent is a kigi-shell instance.
|
||||
pub is_kigi_shell: bool,
|
||||
/// Auth methods advertised by the agent.
|
||||
pub auth_methods: Vec<acp::AuthMethod>,
|
||||
@@ -147,7 +146,6 @@ pub struct ConnectFlags {
|
||||
/// This is the main entry point for establishing an ACP connection.
|
||||
/// After this returns, the agent is ready to create sessions and receive prompts.
|
||||
pub async fn connect(cancel: &CancellationToken, flags: ConnectFlags) -> Result<AcpConnection> {
|
||||
// Load agent config from disk
|
||||
let raw_config = kigi_shell::config::load_effective_config()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load config: {}", e))?;
|
||||
let mut agent_config = AgentConfig::new_from_toml_cfg(&raw_config)
|
||||
@@ -181,13 +179,11 @@ pub async fn connect(cancel: &CancellationToken, flags: ConnectFlags) -> Result<
|
||||
|
||||
apply_config_writes(&flags);
|
||||
|
||||
// Spawn the agent
|
||||
let memory_config = agent_config.memory_config.clone();
|
||||
let spawned = spawn::spawn_kigi_shell(agent_config, cancel, memory_config).await?;
|
||||
let auth_manager = spawned.auth_manager.clone();
|
||||
let (tx, rx) = (spawned.channel.tx, spawned.channel.rx);
|
||||
|
||||
// Initialize
|
||||
let (
|
||||
models,
|
||||
is_kigi_shell,
|
||||
@@ -198,7 +194,6 @@ pub async fn connect(cancel: &CancellationToken, flags: ConnectFlags) -> Result<
|
||||
session_recap_available,
|
||||
) = initialize(&tx, &flags).await?;
|
||||
|
||||
// Determine whether interactive login is needed.
|
||||
let (needs_login, login_label, login_method_id, auth_start_mode) =
|
||||
startup_auth_metadata(&auth_methods);
|
||||
|
||||
@@ -482,7 +477,6 @@ async fn initialize(
|
||||
|
||||
let resp: acp::InitializeResponse = acp_send(req, tx).await?;
|
||||
|
||||
// Check if this is a kigi-shell agent
|
||||
let is_kigi_shell = resp
|
||||
.meta
|
||||
.as_ref()
|
||||
@@ -490,7 +484,6 @@ async fn initialize(
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Parse model state from response meta
|
||||
let models: ModelState = resp
|
||||
.meta
|
||||
.as_ref()
|
||||
@@ -566,7 +559,8 @@ pub fn startup_auth_metadata(
|
||||
return (false, None, None, AuthStartMode::Pending);
|
||||
}
|
||||
|
||||
let method = first_method.unwrap(); // safe: needs_login == true implies first_method.is_some()
|
||||
// safe: needs_login == true implies first_method.is_some()
|
||||
let method = first_method.unwrap();
|
||||
let login_label = Some(method.name().to_string());
|
||||
let login_method_id = Some(method.id().clone());
|
||||
|
||||
@@ -812,8 +806,6 @@ mod tests {
|
||||
assert!(!parse_session_recap_available(meta.as_object()));
|
||||
}
|
||||
|
||||
// ── startup_auth_metadata ──────────────────────────────────────
|
||||
|
||||
fn make_auth_method(id: &str, name: &str, meta: Option<serde_json::Value>) -> acp::AuthMethod {
|
||||
let mut agent = acp::AuthMethodAgent::new(acp::AuthMethodId::new(id), name.to_string());
|
||||
if let Some(m) = meta.and_then(|v| v.as_object().cloned()) {
|
||||
@@ -1001,8 +993,6 @@ mod tests {
|
||||
assert_eq!(mode, AuthStartMode::Pending);
|
||||
}
|
||||
|
||||
// ── unsupported_leader_flags ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn unsupported_leader_flags_empty_when_none_set() {
|
||||
let flags = ConnectFlags::default();
|
||||
|
||||
@@ -64,7 +64,6 @@ impl ModelState {
|
||||
self.available.is_empty()
|
||||
}
|
||||
|
||||
/// Display name for the current model.
|
||||
pub fn current_model_name(&self) -> Option<String> {
|
||||
let current = self.current.as_ref()?;
|
||||
if let Some(model_info) = self.available.get(current) {
|
||||
@@ -74,12 +73,10 @@ impl ModelState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Machine-readable model ID string for the current model (e.g. "kigi-4.5").
|
||||
pub fn current_model_id_str(&self) -> Option<&str> {
|
||||
Some(self.current.as_ref()?.0.as_ref())
|
||||
}
|
||||
|
||||
/// Total context window tokens for the current model (if available).
|
||||
fn current_context_window_tokens(&self) -> Option<u64> {
|
||||
let meta = self.available.get(self.current.as_ref()?)?.meta.as_ref()?;
|
||||
meta.get("totalContextTokens")
|
||||
@@ -118,20 +115,16 @@ impl ModelState {
|
||||
true
|
||||
}
|
||||
|
||||
/// Get the effective context window size (tokens).
|
||||
///
|
||||
/// Returns the override if set, otherwise reads from the current model's
|
||||
/// metadata. The override is set by `override_context_window()` when an
|
||||
/// external source (e.g., SubagentProgress) reports the actual window size.
|
||||
/// Effective context window (tokens): the override if set, otherwise the
|
||||
/// current model's metadata.
|
||||
pub fn get_context_window(&self) -> Option<u64> {
|
||||
self.context_window_override
|
||||
.or_else(|| self.current_context_window_tokens())
|
||||
}
|
||||
|
||||
/// Override the context window size.
|
||||
///
|
||||
/// Used for subagent views where the actual context window is reported
|
||||
/// via SubagentProgress and may differ from the inherited model's metadata.
|
||||
/// Set the context-window override for subagent views, where the real size
|
||||
/// comes from SubagentProgress and can differ from the inherited model's
|
||||
/// metadata.
|
||||
pub fn override_context_window(&mut self, tokens: u64) {
|
||||
self.context_window_override = Some(tokens);
|
||||
}
|
||||
@@ -288,7 +281,7 @@ impl ModelState {
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a user-supplied name to a `ModelId` via case-insensitive
|
||||
/// Resolve a user-supplied name or id to a `ModelId` via case-insensitive
|
||||
/// ASCII match against the catalog.
|
||||
pub fn resolve_by_name_or_id(&self, query: &str) -> Option<acp::ModelId> {
|
||||
self.available.iter().find_map(|(id, info)| {
|
||||
@@ -300,7 +293,6 @@ impl ModelState {
|
||||
})
|
||||
}
|
||||
|
||||
/// Look up the display name for a `ModelId` in the catalog.
|
||||
pub fn display_name_for(&self, id: &acp::ModelId) -> String {
|
||||
self.available
|
||||
.get(id)
|
||||
@@ -308,7 +300,6 @@ impl ModelState {
|
||||
.unwrap_or_else(|| id.0.to_string())
|
||||
}
|
||||
|
||||
/// Cycle to the next model.
|
||||
pub fn next_model(&self) -> Option<acp::ModelId> {
|
||||
if self.available.is_empty() {
|
||||
None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Agent spawning — creates the agent process and ACP channels.
|
||||
//!
|
||||
//! Simplified to only support KigiShell (in-process) mode.
|
||||
//! Subprocess and remote modes can be added later if needed.
|
||||
//! Only KigiShell (in-process) mode is supported; subprocess and remote modes
|
||||
//! can be added later if needed.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::thread;
|
||||
@@ -25,14 +25,12 @@ pub struct SpawnedAgent {
|
||||
pub _thread_handle: thread::JoinHandle<Result<()>>,
|
||||
pub channel: AcpClientChannel,
|
||||
pub cancel: CancellationToken,
|
||||
/// The agent's `AuthManager`, shared so pager-side consumers
|
||||
/// channel) resolve the same refreshing bearer as chat traffic.
|
||||
/// The agent's `AuthManager`, shared so pager-side consumers resolve the
|
||||
/// same refreshing bearer as chat traffic.
|
||||
pub auth_manager: std::sync::Arc<AuthManager>,
|
||||
}
|
||||
|
||||
/// Spawn a KigiShell agent in a background thread.
|
||||
///
|
||||
/// Returns the ACP client channel for communication and a cancellation token.
|
||||
pub async fn spawn_kigi_shell(
|
||||
agent_config: AgentConfig,
|
||||
cancel: &CancellationToken,
|
||||
@@ -82,7 +80,6 @@ pub async fn spawn_kigi_shell(
|
||||
})
|
||||
};
|
||||
|
||||
// Spawn the agent thread with direct dispatch
|
||||
let handle = spawn_agent_thread_direct(spawn_fn, acp_agent, agent_cancel.clone())?;
|
||||
|
||||
Ok(SpawnedAgent {
|
||||
@@ -113,12 +110,10 @@ fn spawn_agent_thread_direct(
|
||||
let client_tx = channel.tx.clone();
|
||||
let agent_rc = spawn_agent(client_tx)?;
|
||||
|
||||
// Direct dispatch: RPC requests go straight to the agent
|
||||
let gw_rx = AcpGatewayReceiver::new(channel.rx, agent_rc).with_tracing(true);
|
||||
tokio::task::spawn_local(gw_rx.run());
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
// Keep running until cancelled
|
||||
cancel.cancelled().await;
|
||||
anyhow::Result::Ok(())
|
||||
})
|
||||
|
||||
@@ -28,7 +28,6 @@ use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::debug;
|
||||
/// Convert a UTC millisecond timestamp to local time.
|
||||
fn utc_ms_to_local(ms: i64) -> DateTime<Local> {
|
||||
chrono::Utc
|
||||
.timestamp_millis_opt(ms)
|
||||
@@ -36,22 +35,14 @@ fn utc_ms_to_local(ms: i64) -> DateTime<Local> {
|
||||
.map(|utc| utc.with_timezone(&Local))
|
||||
.unwrap_or_else(Local::now)
|
||||
}
|
||||
/// What the agent is currently doing within a turn.
|
||||
///
|
||||
/// Derived from the tracker's internal state by [`AcpUpdateTracker::activity()`].
|
||||
/// Used by the turn status line widget to show context-appropriate indicators.
|
||||
///
|
||||
/// Note: `Idle` here means "the tracker has no in-flight work". The caller
|
||||
/// should check `TurnState` to distinguish true idle (no turn) from waiting
|
||||
/// (turn started, but no chunks received yet).
|
||||
/// Why a turn is open but nothing is streaming right now.
|
||||
///
|
||||
/// Replaces the old single, opaque "Waiting…" placeholder: instead of treating
|
||||
/// the absence of activity as one undifferentiated state, the turn-status line
|
||||
/// names *what* the agent is blocked on. Resolved partly by the tracker (the
|
||||
/// blocking tool waits it suppresses — see [`AcpUpdateTracker::activity`]) and
|
||||
/// partly at the view boundary (`Model`/`Subagent`, which need turn-state and
|
||||
/// the subagent registry the tracker doesn't own).
|
||||
/// Rather than treating the absence of activity as one undifferentiated
|
||||
/// "Waiting…" state, the turn-status line names *what* the agent is blocked on.
|
||||
/// Resolved partly by the tracker (the blocking tool waits it suppresses — see
|
||||
/// [`AcpUpdateTracker::activity`]) and partly at the view boundary
|
||||
/// (`Model`/`Subagent`, which need turn-state and the subagent registry the
|
||||
/// tracker doesn't own).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WaitingReason {
|
||||
/// Waiting for the model to (re)start streaming — the first token after the
|
||||
@@ -183,7 +174,7 @@ pub enum TurnActivity {
|
||||
reason: String,
|
||||
},
|
||||
/// Turn is open but nothing is streaming; `reason` says what we're waiting
|
||||
/// on. Replaces the implicit "no activity == generic Waiting…" placeholder.
|
||||
/// on.
|
||||
Waiting(WaitingReason),
|
||||
}
|
||||
impl TurnActivity {
|
||||
@@ -1404,8 +1395,8 @@ fn extract_skill_header_command(text: &str) -> Option<String> {
|
||||
/// 2. `SessionNotification._meta.promptId` classified via
|
||||
/// [`PromptOrigin::from_prompt_id`]
|
||||
///
|
||||
/// Legacy fallback (pre-meta sessions only): bare auto-wake text that used to
|
||||
/// be gated by the system-reminder prefix. Cron is handled earlier by
|
||||
/// Legacy fallback (pre-meta sessions only): bare auto-wake text gated by the
|
||||
/// system-reminder prefix. Cron is handled earlier by
|
||||
/// [`extract_cron_prompt_body`].
|
||||
fn user_message_hidden_from_scrollback(
|
||||
chunk: &acp::ContentChunk,
|
||||
@@ -2000,7 +1991,6 @@ fn tool_call_title(tc: &acp::ToolCall) -> Cow<'_, str> {
|
||||
Cow::Borrowed(&tc.title)
|
||||
}
|
||||
}
|
||||
/// Extract text content from a ContentBlock.
|
||||
fn extract_text_from_content(content: &acp::ContentBlock) -> String {
|
||||
match content {
|
||||
acp::ContentBlock::Text(t) => t.text.clone(),
|
||||
@@ -2863,9 +2853,9 @@ mod tests {
|
||||
}
|
||||
/// Regression test: two turns should create separate agent message entries.
|
||||
///
|
||||
/// Previously, handle_user_message() didn't reset current_agent_msg,
|
||||
/// so the second turn's agent message chunks got appended to the first
|
||||
/// turn's entry, producing concatenated text.
|
||||
/// Without resetting current_agent_msg in handle_user_message(), the second
|
||||
/// turn's agent message chunks append to the first turn's entry, producing
|
||||
/// concatenated text.
|
||||
#[test]
|
||||
fn two_turns_separate_agent_messages() {
|
||||
crate::appearance::cache::set_show_thinking_blocks(true);
|
||||
@@ -3767,8 +3757,8 @@ mod tests {
|
||||
/// 2. ToolCallUpdate in-progress with kind=search, title="fn main", rawInput
|
||||
/// 3. ToolCallUpdate completed with rawOutput containing GrepSearchOutput
|
||||
///
|
||||
/// This was broken: kind from in-progress update was lost, so the completed
|
||||
/// block rendered as "Other" with no search results.
|
||||
/// Without carrying the kind from the in-progress update, the completed
|
||||
/// block renders as "Other" with no search results.
|
||||
#[test]
|
||||
fn test_search_tool_call_flow() {
|
||||
use kigi_tools::types::output::{GrepFileMatch, GrepLineMatch, GrepSearchOutput};
|
||||
@@ -4836,7 +4826,7 @@ mod tests {
|
||||
assert_eq!(tracker.activity(), None);
|
||||
}
|
||||
/// The blocking bg-plumbing tools are kept out of scrollback but the turn
|
||||
/// IS blocked on them — `activity()` must name the wait instead of the old
|
||||
/// IS blocked on them — `activity()` must name the wait instead of a
|
||||
/// generic `None` (→ "Waiting…"). Task-output tools only advertise once
|
||||
/// raw_input proves them blocking (`timeout_ms > 0`); before that the
|
||||
/// wait is not shown (display mirrors interject eligibility).
|
||||
|
||||
@@ -35,7 +35,6 @@ pub fn default_actions(mouse_reporting_toggle_enabled: bool) -> Vec<ActionDef> {
|
||||
let ctrl_dot_unreliable = ctrl_dot_unreliable();
|
||||
|
||||
let mut actions = vec![
|
||||
// ── Navigation (scrollback) ─────────────────────────────────
|
||||
ActionDef {
|
||||
id: ActionId::SelectNext,
|
||||
label: "nav",
|
||||
@@ -222,7 +221,6 @@ pub fn default_actions(mouse_reporting_toggle_enabled: bool) -> Vec<ActionDef> {
|
||||
requires_confirmation: false,
|
||||
long_help: None,
|
||||
},
|
||||
// ── View (scrollback) ───────────────────────────────────────
|
||||
ActionDef {
|
||||
id: ActionId::Collapse,
|
||||
label: "fold",
|
||||
@@ -309,7 +307,6 @@ pub fn default_actions(mouse_reporting_toggle_enabled: bool) -> Vec<ActionDef> {
|
||||
"Switches the selected entry between rendered markdown and its raw source text.\nUse it to copy exact markdown, inspect a link target, or see formatting the renderer hides.\nPress again to return to the rendered view.",
|
||||
),
|
||||
},
|
||||
// ── Block content ────────────────────────────────────────────
|
||||
ActionDef {
|
||||
id: ActionId::CopyBlockContent,
|
||||
label: "copy",
|
||||
@@ -318,7 +315,8 @@ pub fn default_actions(mouse_reporting_toggle_enabled: bool) -> Vec<ActionDef> {
|
||||
alt_keys: vec![],
|
||||
category: Category::ConversationAction,
|
||||
context: When::ScrollbackFocused,
|
||||
hint_priority: None, // shown dynamically when block supports copy
|
||||
// shown dynamically when block supports copy
|
||||
hint_priority: None,
|
||||
hint_key_display: None,
|
||||
requires_confirmation: false,
|
||||
long_help: Some(
|
||||
@@ -355,7 +353,6 @@ pub fn default_actions(mouse_reporting_toggle_enabled: bool) -> Vec<ActionDef> {
|
||||
"Opens the selected block in a focused, scrollable full-screen viewer.\nBest for long tool output, large files, or code you want to read away from the surrounding transcript.\nEsc returns to the conversation.",
|
||||
),
|
||||
},
|
||||
// ── Link navigation ─────────────────────────────────────────
|
||||
ActionDef {
|
||||
id: ActionId::OpenNextLink,
|
||||
label: "link",
|
||||
@@ -382,7 +379,6 @@ pub fn default_actions(mouse_reporting_toggle_enabled: bool) -> Vec<ActionDef> {
|
||||
requires_confirmation: false,
|
||||
long_help: None,
|
||||
},
|
||||
// ── Scrollback (contextual — block-type-dependent) ────────────
|
||||
ActionDef {
|
||||
id: ActionId::Rewind,
|
||||
label: "rewind",
|
||||
@@ -413,7 +409,6 @@ pub fn default_actions(mouse_reporting_toggle_enabled: bool) -> Vec<ActionDef> {
|
||||
"Terminates the background task owned by the selected task block (e.g. a long shell command sent to the background).\nReach for it to stop a runaway or no-longer-needed process.\nApplies only to a live task; finished ones are unaffected.",
|
||||
),
|
||||
},
|
||||
// ── Essentials ────────────────────────────────────────────────
|
||||
ActionDef {
|
||||
id: ActionId::SendPrompt,
|
||||
label: "send",
|
||||
@@ -486,7 +481,6 @@ pub fn default_actions(mouse_reporting_toggle_enabled: bool) -> Vec<ActionDef> {
|
||||
"Steps the session mode: Normal -> Plan -> Always-Approve -> Normal.\nPlan keeps the agent planning first and writes no files; Always-Approve runs every tool call without asking.\nCtrl+O toggles auto-approve directly.",
|
||||
),
|
||||
},
|
||||
// ── Panes (agent-level — toggle side panes) ─────────────────
|
||||
ActionDef {
|
||||
id: ActionId::ToggleTodos,
|
||||
label: "todos",
|
||||
@@ -595,12 +589,8 @@ pub fn default_actions(mouse_reporting_toggle_enabled: bool) -> Vec<ActionDef> {
|
||||
"Detaches the running turn so it keeps working in the background while you read, queue prompts, or start something else.\nTrack and resume it from the tasks pane (Ctrl+B).\nOnly meaningful while a turn is actually running.",
|
||||
),
|
||||
},
|
||||
// ── Prompt ───────────────────────────────────────────────────
|
||||
ActionDef {
|
||||
id: ActionId::InterjectPrompt,
|
||||
// "send now" label: Enter queues a follow-up while a turn runs;
|
||||
// this chord is cancel-and-send — stop the current turn and run
|
||||
// the message as the next one ("send now").
|
||||
label: "send now",
|
||||
description: "Send now while running (cancels the current turn)",
|
||||
default_key: if in_apple_terminal {
|
||||
@@ -661,7 +651,6 @@ pub fn default_actions(mouse_reporting_toggle_enabled: bool) -> Vec<ActionDef> {
|
||||
"Runs a shell command without leaving the chat: type ! at the start of an empty prompt, then the command.\nThe command output is captured into the scrollback.\nDelete the leading ! to go back to a normal prompt.",
|
||||
),
|
||||
},
|
||||
// ── Agent ────────────────────────────────────────────────────
|
||||
ActionDef {
|
||||
id: ActionId::ToggleYolo,
|
||||
label: "yolo",
|
||||
@@ -808,8 +797,6 @@ pub fn default_actions(mouse_reporting_toggle_enabled: bool) -> Vec<ActionDef> {
|
||||
});
|
||||
}
|
||||
|
||||
// Agent Dashboard ----------------------------------------------------
|
||||
//
|
||||
// The `Ctrl+\` entry point AND every in-dashboard shortcut are registered
|
||||
// here. They all share the dedicated `Category::Dashboard` section so the
|
||||
// cheatsheet groups them under a single "Dashboard" header instead of
|
||||
@@ -930,11 +917,10 @@ pub fn default_actions(mouse_reporting_toggle_enabled: bool) -> Vec<ActionDef> {
|
||||
id: ActionId::DashboardToggleGrouping,
|
||||
label: "group",
|
||||
description: "Toggle row grouping",
|
||||
// `Ctrl+G` ("group"). `Ctrl+S` was reassigned to the peek /
|
||||
// dispatch "send + open" chord so `Shift+Enter` could be
|
||||
// freed for newline insertion. (`Ctrl+G` is also bound to
|
||||
// `SendToBackground`, but that lives in `When::AgentScreen`,
|
||||
// a context that never overlaps the dashboard.)
|
||||
// `Ctrl+G` ("group"). `Ctrl+S` is the peek / dispatch "send + open"
|
||||
// chord, so it is unavailable here. (`Ctrl+G` is also bound to
|
||||
// `SendToBackground`, but that lives in `When::AgentScreen`, a
|
||||
// context that never overlaps the dashboard.)
|
||||
default_key: key!('g', CONTROL),
|
||||
alt_keys: vec![],
|
||||
category: Category::Dashboard,
|
||||
@@ -1041,9 +1027,7 @@ pub fn default_actions(mouse_reporting_toggle_enabled: bool) -> Vec<ActionDef> {
|
||||
"Toggles auto-approve (YOLO) for the selected agent right from the dashboard, without attaching to it.\nWhile on, that agent runs every tool call with no per-action confirmation.\nThe per-session equivalent is Ctrl+O inside a session.",
|
||||
),
|
||||
},
|
||||
// Open the location picker — a floating modal to change the
|
||||
// working directory new dashboard sessions spawn in. Ctrl+L
|
||||
// ("location") is free under `DashboardFocused` (it only binds
|
||||
// Ctrl+L ("location") is free under `DashboardFocused` (it only binds
|
||||
// OpenExtensions under `AgentScreen`, a different context).
|
||||
ActionDef {
|
||||
id: ActionId::DashboardOpenLocationPicker,
|
||||
@@ -1060,11 +1044,8 @@ pub fn default_actions(mouse_reporting_toggle_enabled: bool) -> Vec<ActionDef> {
|
||||
"Opens a picker to set the working directory that newly dispatched dashboard agents run in.\nLaunch agents against a different repo or folder without leaving the dashboard.\nAffects new dispatches only, not agents already running.",
|
||||
),
|
||||
},
|
||||
// Toggle worktree-dispatch mode. Ctrl+W ("worktree") arms the next
|
||||
// dashboard-dispatched session to spawn in a fresh git worktree; the
|
||||
// dispatcher gates it on the cwd being a git repo. Free under
|
||||
// `DashboardFocused` (Ctrl+W only binds the overlay-exit fallback
|
||||
// under `DashboardOverlay`, a different context).
|
||||
// Ctrl+W ("worktree") is free under `DashboardFocused` (it only binds
|
||||
// the overlay-exit fallback under `DashboardOverlay`, a different context).
|
||||
ActionDef {
|
||||
id: ActionId::DashboardToggleWorktree,
|
||||
label: "worktree",
|
||||
|
||||
@@ -134,15 +134,11 @@ pub enum ActionId {
|
||||
pub enum When {
|
||||
/// Global — checked at the app level after all views.
|
||||
Always,
|
||||
/// Only when prompt pane is focused.
|
||||
PromptFocused,
|
||||
/// Only when scrollback pane is focused.
|
||||
ScrollbackFocused,
|
||||
/// Agent-level — checked after pane routing, before global.
|
||||
AgentScreen,
|
||||
/// Only on the welcome screen.
|
||||
WelcomeScreen,
|
||||
/// Only when the Agent Dashboard view is focused.
|
||||
DashboardFocused,
|
||||
/// Only inside the dashboard's session overlay (a dashboard-spawned agent
|
||||
/// rendered fullscreen). Distinguishes the detail-view shortcuts (back to
|
||||
@@ -175,13 +171,10 @@ pub struct ActionDef {
|
||||
/// Optional man-style help for the shortcuts cheatsheet detail/expand UI.
|
||||
/// Consumers should fall back to `description` when this is `None`.
|
||||
pub long_help: Option<&'static str>,
|
||||
/// Default key binding
|
||||
pub default_key: KeyShortcut,
|
||||
/// Optional second key binding (e.g., j/k both shown as "j/k:nav")
|
||||
pub alt_keys: Vec<KeyShortcut>,
|
||||
/// Category for grouping
|
||||
pub category: Category,
|
||||
/// When this action is available
|
||||
pub context: When,
|
||||
/// Priority for shortcuts bar. None = don't show. Some(0) = highest priority.
|
||||
pub hint_priority: Option<u8>,
|
||||
@@ -212,7 +205,6 @@ pub struct ActionRegistry {
|
||||
}
|
||||
|
||||
impl ActionRegistry {
|
||||
/// Create a registry with the given action definitions.
|
||||
pub fn new(actions: Vec<ActionDef>) -> Self {
|
||||
Self { actions }
|
||||
}
|
||||
@@ -375,7 +367,6 @@ impl ActionRegistry {
|
||||
None
|
||||
}
|
||||
|
||||
/// Find an action definition by ID.
|
||||
pub fn find(&self, id: ActionId) -> Option<&ActionDef> {
|
||||
self.actions.iter().find(|d| d.id == id)
|
||||
}
|
||||
@@ -642,16 +633,16 @@ mod tests {
|
||||
registry.lookup(&ctrl_m, When::PromptFocused),
|
||||
Some(ActionId::ToggleMultiline)
|
||||
);
|
||||
// Former mouse-toggle dual bindings removed from scrollback.
|
||||
// F9 is unbound on scrollback and agent.
|
||||
assert_eq!(registry.lookup(&f9, When::ScrollbackFocused), None);
|
||||
assert_eq!(registry.lookup(&f9, When::AgentScreen), None);
|
||||
// Ctrl+Shift+M is no longer the voice chord — it resolves to nothing.
|
||||
// Ctrl+Shift+M is unbound.
|
||||
assert_eq!(
|
||||
registry.lookup(&ctrl_shift_m, When::ScrollbackFocused),
|
||||
None
|
||||
);
|
||||
assert_eq!(registry.lookup(&ctrl_shift_m, When::Always), None);
|
||||
// Ctrl+Space is no longer bound (the voice chord was removed).
|
||||
// Ctrl+Space is unbound.
|
||||
assert_eq!(registry.lookup(&ctrl_space, When::Always), None);
|
||||
assert_eq!(registry.lookup(&ctrl_space, When::AgentScreen), None);
|
||||
let f8 = KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE);
|
||||
|
||||
@@ -10,18 +10,14 @@ pub(super) fn route_bg_task_stdout(
|
||||
) -> bool {
|
||||
let tc_id = tcu.tool_call_id.0.to_string();
|
||||
|
||||
// Check if this tool_call_id maps to a bg task
|
||||
let task_id = match session.bg_tool_call_to_task.get(&tc_id) {
|
||||
Some(tid) => tid.clone(),
|
||||
None => return false,
|
||||
};
|
||||
|
||||
// Extract stdout from the raw_output BashOutput
|
||||
if let Some(ref raw_output) = tcu.fields.raw_output {
|
||||
// The shell sends full cumulative output buffer — just overwrite.
|
||||
// Check for BashOutput type
|
||||
if raw_output.get("type").and_then(|v| v.as_str()) == Some("Bash") {
|
||||
// Try output_for_prompt first (pre-stripped string)
|
||||
let stdout =
|
||||
if let Some(s) = raw_output.get("output_for_prompt").and_then(|v| v.as_str()) {
|
||||
s.to_string()
|
||||
@@ -33,7 +29,8 @@ pub(super) fn route_bg_task_stdout(
|
||||
.collect();
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
} else {
|
||||
return true; // Consumed but no extractable output
|
||||
// Consumed but no extractable output
|
||||
return true;
|
||||
};
|
||||
|
||||
// Capture the shell-side `truncated` flag — once true, it stays
|
||||
@@ -59,7 +56,8 @@ pub(super) fn route_bg_task_stdout(
|
||||
}
|
||||
}
|
||||
|
||||
true // Consumed — don't pass to tracker
|
||||
// Consumed — don't pass to tracker
|
||||
true
|
||||
}
|
||||
|
||||
/// Handle `kigi/task_backgrounded` — a bash command transitioned to background.
|
||||
@@ -72,13 +70,11 @@ pub(super) fn route_bg_task_stdout(
|
||||
/// entry's running state is cleared. Otherwise a fresh `BgTask` block
|
||||
/// is pushed.
|
||||
pub(super) fn handle_task_backgrounded(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
// Parse the SessionNotification envelope
|
||||
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
|
||||
tracing::warn!("Failed to parse kigi/task_backgrounded");
|
||||
return false;
|
||||
};
|
||||
|
||||
// Extract TaskBackgrounded fields
|
||||
let (tool_call_id, task_id, command, cwd, output_file, monitor_description, notif_description) =
|
||||
match session_notif.update {
|
||||
XaiSessionUpdate::TaskBackgrounded {
|
||||
@@ -153,8 +149,8 @@ pub(super) fn handle_task_backgrounded(notif: &acp::ExtNotification, app: &mut A
|
||||
.or_else(|| non_blank(notif_description))
|
||||
.or_else(|| non_blank(deferred_description));
|
||||
|
||||
// Create central bg task state (description may still be filled from the
|
||||
// Execute block on demotion before we insert into the map).
|
||||
// description stays None here; it may still be filled from the Execute
|
||||
// block on demotion before we insert into the map.
|
||||
let mut bg_task = BgTaskState {
|
||||
task_id: task_id.clone(),
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
@@ -503,7 +499,7 @@ pub(super) fn handle_git_head_changed(notif: &acp::ExtNotification, app: &mut Ap
|
||||
return false;
|
||||
};
|
||||
|
||||
// Find the agent by ACP session id (not local AgentId) and update its git display cache
|
||||
// Match by ACP session id, not local AgentId.
|
||||
if let Some(agent) = app.agents.values_mut().find(|a| {
|
||||
a.session
|
||||
.session_id
|
||||
@@ -549,7 +545,6 @@ pub(super) fn handle_git_head_changed(notif: &acp::ExtNotification, app: &mut Ap
|
||||
}
|
||||
|
||||
pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
// The payload is a SessionNotification wrapping TaskCompleted { task_snapshot }
|
||||
let Ok(session_notif) = serde_json::from_str::<SessionNotification>(notif.params.get()) else {
|
||||
tracing::warn!("Failed to parse kigi/task_completed");
|
||||
return false;
|
||||
@@ -579,7 +574,6 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV
|
||||
"Background task completed"
|
||||
);
|
||||
|
||||
// Determine success once, reused for both bg_task status and scrollback block.
|
||||
let success = exit_code == Some(0) || (exit_code.is_none() && signal.is_none());
|
||||
|
||||
// Synthetic completion emitted by the agent's cold-load reconciliation
|
||||
@@ -594,7 +588,6 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV
|
||||
return false;
|
||||
};
|
||||
|
||||
// Compute elapsed duration from the bg task state (if we have it).
|
||||
// Prefer the human description for "Task completed/failed: …" labels
|
||||
// (same as "Task started"), falling back to the raw command only when
|
||||
// no description was supplied.
|
||||
|
||||
@@ -18,7 +18,6 @@ pub(crate) fn handle_ask_user_question(
|
||||
AskUserQuestionExtRequest, AskUserQuestionExtResponse,
|
||||
};
|
||||
|
||||
// Parse the typed request from the ext-method params.
|
||||
let ext_req: AskUserQuestionExtRequest = match serde_json::from_str(ext.request.params.get()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -54,7 +53,6 @@ pub(crate) fn handle_ask_user_question(
|
||||
return false;
|
||||
};
|
||||
|
||||
// If a question is already active, cancel it before replacing.
|
||||
if let Some(mut old_qv) = agent.question_view.take() {
|
||||
agent.turn_paused_duration += old_qv.opened_at.elapsed();
|
||||
tracing::warn!(
|
||||
@@ -89,7 +87,6 @@ pub(crate) fn handle_ask_user_question(
|
||||
}
|
||||
}
|
||||
|
||||
// Stash the current prompt and create the question view.
|
||||
agent.question_view = Some(QuestionViewState::with_response_tx(
|
||||
ext_req.tool_call_id,
|
||||
ext_req.questions,
|
||||
@@ -98,7 +95,6 @@ pub(crate) fn handle_ask_user_question(
|
||||
ext_req.mode,
|
||||
));
|
||||
|
||||
// Clear prompt for question interaction.
|
||||
agent.prompt.set_text("");
|
||||
|
||||
// Stamp the "last activity" anchor so the
|
||||
@@ -131,7 +127,6 @@ pub(super) fn handle_exit_plan_mode(
|
||||
) -> bool {
|
||||
use crate::views::plan_approval_view::{ExitPlanModeExtRequest, PlanApprovalViewState};
|
||||
|
||||
// 1. Parse typed request from raw JSON params.
|
||||
let params: ExitPlanModeExtRequest = match serde_json::from_str(ext.request.params.get()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -146,7 +141,7 @@ pub(super) fn handle_exit_plan_mode(
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Route by the request's session id (like `session/update`), so a
|
||||
// Route by the request's session id (like `session/update`), so a
|
||||
// plan-approval raised by a BACKGROUND session lands on its own view even
|
||||
// when the user isn't currently focused on it — rather than failing.
|
||||
let Some(id) = interaction_target_agent(app, ¶ms.session_id) else {
|
||||
|
||||
@@ -78,12 +78,6 @@ pub(super) fn handle_mcp_init_progress(notif: &acp::ExtNotification, app: &mut A
|
||||
pub(super) fn handle_mcp_tools_changed(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
|
||||
let method = notif.method.as_ref();
|
||||
|
||||
// Both `kigi/mcp_initialized` and (newer shell)
|
||||
// `kigi/mcp/tools_changed` carry `sessionId`. Route by it so a
|
||||
// background agent's notification updates *its* state — not
|
||||
// whichever agent is foregrounded. Unknown and subagent (child)
|
||||
// sessions are dropped; a missing sessionId falls back to the
|
||||
// active agent (legacy shells).
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Payload {
|
||||
@@ -127,7 +121,7 @@ pub(super) fn handle_mcp_tools_changed(notif: &acp::ExtNotification, app: &mut A
|
||||
}
|
||||
|
||||
// Modal refresh: schedule a debounced refetch for the OWNING agent
|
||||
// (routed by sessionId — was active_view), per-agent coalesced.
|
||||
// (routed by sessionId), per-agent coalesced.
|
||||
let modal_open = app
|
||||
.agents
|
||||
.get(&id)
|
||||
@@ -149,11 +143,9 @@ pub(super) fn handle_mcp_tools_changed(notif: &acp::ExtNotification, app: &mut A
|
||||
redraw
|
||||
}
|
||||
|
||||
/// Per-agent coalescing test for [`Effect::FetchMcpsList`].
|
||||
/// An earlier approach used `matches!(e, FetchMcpsList { .. })`
|
||||
/// which collapsed across agents — a pending fetch on agent A would
|
||||
/// drop the push for agent B. Now we key on `agent_id` so each
|
||||
/// agent's refetch is independently debounced.
|
||||
/// Per-agent coalescing test for [`Effect::FetchMcpsList`]. Keyed on
|
||||
/// `agent_id` so a pending fetch on agent A does not drop the push for
|
||||
/// agent B; each agent's refetch is independently debounced.
|
||||
pub(super) fn agent_has_pending_mcps_fetch(app: &AppView, agent_id: AgentId) -> bool {
|
||||
app.pending_effects.iter().any(|e| {
|
||||
matches!(
|
||||
@@ -243,9 +235,7 @@ pub(super) fn handle_mcp_server_status(notif: &acp::ExtNotification, app: &mut A
|
||||
// `Option<serde_json::Value>` (always `null` today;
|
||||
// reserved). If the value is present but not an array of
|
||||
// `McpToolEntry`-isomorphic objects we drop ONLY the tools
|
||||
// update and still apply the status — the previous strict
|
||||
// typing would have dropped the whole push on any shape
|
||||
// mismatch.
|
||||
// update and still apply the status.
|
||||
let new_tools = payload.tools.and_then(|raw| {
|
||||
match serde_json::from_value::<Vec<McpToolEntry>>(raw) {
|
||||
Ok(entries) => Some(
|
||||
@@ -279,12 +269,11 @@ pub(super) fn handle_mcp_server_status(notif: &acp::ExtNotification, app: &mut A
|
||||
/// on config reload (`crates/codegen/kigi-shell/src/agent/mvp_agent.rs`
|
||||
/// → `notify_servers_updated`). The shell's
|
||||
/// `McpServersUpdated` wire shape (`{ mcpServers: [...] }`) is
|
||||
/// intentionally session-agnostic by design
|
||||
/// An attempt to route by
|
||||
/// `sessionId` therefore always fell back to `app.active_view` and
|
||||
/// re-created the multi-agent bug.
|
||||
/// intentionally session-agnostic by design. Routing by `sessionId`
|
||||
/// would therefore always fall back to `app.active_view` and
|
||||
/// reintroduce the multi-agent bug.
|
||||
///
|
||||
/// Routing now correctly broadcasts: every agent with an open
|
||||
/// Routing broadcasts: every agent with an open
|
||||
/// extensions modal gets a per-agent debounced [`Effect::FetchMcpsList`].
|
||||
/// Per-agent coalescing keeps a second push from displacing an
|
||||
/// in-flight fetch on the same agent. Agents without an open modal
|
||||
|
||||
@@ -394,7 +394,6 @@ pub(crate) fn handle(msg: AcpClientMessage, app: &mut AppView) -> bool {
|
||||
// the turn is current.
|
||||
agent.flush_pending_follow_ups(notif_pid);
|
||||
}
|
||||
// Detect plan mode transitions from tool call completions.
|
||||
plan_mode_modal_refresh_needed |=
|
||||
detect_plan_mode_change(¬if.request.update, agent);
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permission request handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Route a permission request to the agent that owns its `session_id`, queue
|
||||
/// it on that agent's view, and return whether the active view needs a redraw.
|
||||
///
|
||||
@@ -21,7 +17,6 @@ pub(super) fn handle_permission_request(
|
||||
perm: kigi_acp_lib::AcpArgs<acp::RequestPermissionRequest>,
|
||||
app: &mut AppView,
|
||||
) -> bool {
|
||||
// 1. Look up the owning agent by session_id (root or subagent view).
|
||||
let matched = match find_session_match(app, &perm.request.session_id) {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
@@ -40,12 +35,12 @@ pub(super) fn handle_permission_request(
|
||||
return false;
|
||||
};
|
||||
|
||||
// 2. YOLO mode: auto-approve immediately on the owning agent so background
|
||||
// turns aren't blocked waiting for the user to switch back.
|
||||
// YOLO mode: auto-approve immediately on the owning agent so background
|
||||
// turns aren't blocked waiting for the user to switch back.
|
||||
//
|
||||
// If no `AllowOnce` option exists, falls through to
|
||||
// `enqueue_permission` even in YOLO mode (won't pick
|
||||
// `AllowAlways` by default).
|
||||
// If no `AllowOnce` option exists, falls through to
|
||||
// `enqueue_permission` even in YOLO mode (won't pick
|
||||
// `AllowAlways` by default).
|
||||
if agent.session.is_yolo()
|
||||
&& let Some(allow) = perm
|
||||
.request
|
||||
@@ -61,13 +56,13 @@ pub(super) fn handle_permission_request(
|
||||
)),
|
||||
)))
|
||||
.ok();
|
||||
return false; // no redraw needed
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. Fire notification so the user notices the pending approval.
|
||||
// Rate-limit: only fire the bell/popup on the empty→non-empty
|
||||
// transition to avoid stacking notifications during concurrent
|
||||
// permission requests.
|
||||
// Fire notification so the user notices the pending approval.
|
||||
// Rate-limit: only fire the bell/popup on the empty→non-empty
|
||||
// transition to avoid stacking notifications during concurrent
|
||||
// permission requests.
|
||||
if !app
|
||||
.notification_service
|
||||
.should_suppress_permission_notification()
|
||||
@@ -81,9 +76,9 @@ pub(super) fn handle_permission_request(
|
||||
app.notification_service.mark_permission_notified();
|
||||
}
|
||||
|
||||
// 4. Queue on the owning agent's view. Subagent provenance for display
|
||||
// is still resolved via subagent_sessions in enqueue_permission().
|
||||
// Redraw is only needed when the owning agent is currently visible.
|
||||
// Queue on the owning agent's view. Subagent provenance for display
|
||||
// is still resolved via subagent_sessions in enqueue_permission().
|
||||
// Redraw is only needed when the owning agent is currently visible.
|
||||
let needs_redraw = enqueue_permission(perm, agent);
|
||||
needs_redraw && is_active
|
||||
}
|
||||
@@ -96,7 +91,6 @@ fn enqueue_permission(
|
||||
perm: kigi_acp_lib::AcpArgs<acp::RequestPermissionRequest>,
|
||||
agent: &mut AgentView,
|
||||
) -> bool {
|
||||
// 1. Parse bash highlights from request meta (imported from kigi-shell).
|
||||
let bash_highlights: Option<BashCommandHighlights> = perm
|
||||
.request
|
||||
.meta
|
||||
@@ -107,9 +101,8 @@ fn enqueue_permission(
|
||||
.map(|h| kigi_workspace::permission::default_always_allow_scope(&h.highlighted_words))
|
||||
.unwrap_or(0);
|
||||
|
||||
// 1b. Parse MCP scope state from the `allow-always-mcp` option's meta.
|
||||
// Mutually exclusive with the bash flow at the per-request level —
|
||||
// the same prompt cannot carry both.
|
||||
// The `allow-always-mcp` option's meta is mutually exclusive with the bash
|
||||
// flow at the per-request level — the same prompt cannot carry both.
|
||||
let mcp_scope = perm
|
||||
.request
|
||||
.options
|
||||
@@ -128,35 +121,30 @@ fn enqueue_permission(
|
||||
selected: McpScope::Tool,
|
||||
});
|
||||
|
||||
// 2. Build subagent provenance label.
|
||||
// If session_id differs from the root session, look up subagent info.
|
||||
let subagent_label = resolve_subagent_label(agent, &perm.request.session_id);
|
||||
|
||||
// 3. Build title and description from the tool call.
|
||||
let (title, description, bash_command_raw) =
|
||||
build_permission_display(&perm.request, bash_highlights.as_ref());
|
||||
|
||||
// 4. Assign a monotonic ID.
|
||||
let perm_id = agent.next_perm_req_id;
|
||||
agent.next_perm_req_id += 1;
|
||||
|
||||
// 5. Stash prompt on queue transition: empty -> non-empty.
|
||||
// Do NOT stash again if the queue is already non-empty (that would
|
||||
// capture followup text from the current permission as the "original"
|
||||
// prompt, losing the user's real input).
|
||||
// Stash prompt on queue transition: empty -> non-empty.
|
||||
// Do NOT stash again if the queue is already non-empty (that would
|
||||
// capture followup text from the current permission as the "original"
|
||||
// prompt, losing the user's real input).
|
||||
if agent.permission_queue.is_empty() && agent.permission_stashed_prompt.is_none() {
|
||||
agent.permission_stashed_prompt = Some(agent.prompt.stash());
|
||||
agent.prompt.set_text("");
|
||||
}
|
||||
|
||||
// 6. Clone options before moving perm into the struct.
|
||||
let options = perm.request.options.clone();
|
||||
|
||||
// 7. Cursor preselection (sticky last-used → configured default → the
|
||||
// enable-always-approve row → index 0). See `permission_cursor`.
|
||||
// Cursor preselection (sticky last-used → configured default → the
|
||||
// enable-always-approve row → index 0). See `permission_cursor`.
|
||||
let active_idx = crate::appearance::permission_cursor::resolve_initial_cursor(&options);
|
||||
|
||||
// 8. Queue the request FIFO (do NOT replace/cancel existing requests).
|
||||
// Queue the request FIFO (do NOT replace/cancel existing requests).
|
||||
agent.permission_queue.push_back(PermissionViewState {
|
||||
request: perm,
|
||||
id: perm_id,
|
||||
@@ -182,7 +170,7 @@ fn enqueue_permission(
|
||||
// turn ended". The same field powers the dashboard relative-time label.
|
||||
agent.last_active_at = Some(std::time::Instant::now());
|
||||
|
||||
true // needs redraw
|
||||
true
|
||||
}
|
||||
|
||||
/// Build a subagent provenance label for display.
|
||||
@@ -200,20 +188,17 @@ fn enqueue_permission(
|
||||
/// Returns `None` for root session (no provenance needed).
|
||||
fn resolve_subagent_label(agent: &AgentView, session_id: &acp::SessionId) -> Option<String> {
|
||||
let sid = session_id.0.as_ref();
|
||||
// Check if this is the root session (no provenance needed).
|
||||
if let Some(ref root_sid) = agent.session.session_id
|
||||
&& root_sid.0.as_ref() == sid
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Tier 1: tracked subagent with full metadata.
|
||||
if let Some(info) = agent.subagent_sessions.get(sid) {
|
||||
return Some(format!(
|
||||
"Subagent \"{}\" ({}):",
|
||||
info.description, info.subagent_type
|
||||
));
|
||||
}
|
||||
// Tier 2: non-root session with no tracked info.
|
||||
Some("Child session (untracked):".to_string())
|
||||
}
|
||||
|
||||
@@ -364,7 +349,6 @@ fn is_edit_permission(req: &acp::RequestPermissionRequest) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Cancel a permission request by sending `Cancelled` on the response channel.
|
||||
fn cancel_permission(perm: kigi_acp_lib::AcpArgs<acp::RequestPermissionRequest>) {
|
||||
perm.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
|
||||
@@ -16,13 +16,8 @@ pub(super) enum SessionMatch {
|
||||
}
|
||||
|
||||
impl SessionMatch {
|
||||
/// The owning agent's id, regardless of variant.
|
||||
///
|
||||
/// For `Root`, this is the agent whose root session matched. For `Child`,
|
||||
/// this is the parent agent that owns the matching `subagent_views` entry.
|
||||
/// Callers that only need to look up the owning agent (without
|
||||
/// distinguishing root vs child) should use this instead of duplicating
|
||||
/// the `match { Root(id) | Child(id) => id }` pattern.
|
||||
/// The owning agent's id, regardless of variant — for `Child`, the parent
|
||||
/// agent that owns the matching `subagent_views` entry.
|
||||
pub(super) fn agent_id(self) -> AgentId {
|
||||
match self {
|
||||
SessionMatch::Root(id) | SessionMatch::Child(id) => id,
|
||||
@@ -32,9 +27,6 @@ impl SessionMatch {
|
||||
|
||||
/// Resolve the agent that owns a notification's `session_id` and whether the
|
||||
/// active view is affected.
|
||||
///
|
||||
/// Convenience wrapper around `find_session_match` + `is_matched_agent_active`
|
||||
/// + `agents.get_mut()`, used by the bg-task notification handlers.
|
||||
pub(super) fn resolve_notif_agent<'a>(
|
||||
app: &'a mut AppView,
|
||||
session_id: &acp::SessionId,
|
||||
@@ -126,16 +118,15 @@ pub(super) fn find_session_match(
|
||||
app: &AppView,
|
||||
session_id: &acp::SessionId,
|
||||
) -> Option<SessionMatch> {
|
||||
// Single pass over `app.agents`: prefer an exact root match (returned
|
||||
// immediately, since root takes precedence) but track the first child
|
||||
// match seen as a fallback used after the full scan completes.
|
||||
// Single pass over `app.agents`: an exact root match is returned
|
||||
// immediately (root takes precedence), while the first child match seen is
|
||||
// tracked as a fallback applied after the full scan — so root still wins
|
||||
// when both could match.
|
||||
//
|
||||
// Comparing `Option<&SessionId>` to `Some(&session_id)` borrows both
|
||||
// sides -- no SessionId clone. The HashMap lookup uses the inner `&str`
|
||||
// directly via the `Borrow<str>` impl on `String`, so no allocation
|
||||
// either. This preserves the previous two-pass semantics (root wins
|
||||
// when both could match) while halving the iteration cost on the hot
|
||||
// notification path.
|
||||
// either.
|
||||
let child_key: &str = session_id.0.as_ref();
|
||||
let mut child_match: Option<AgentId> = None;
|
||||
for (id, agent) in &app.agents {
|
||||
|
||||
@@ -140,7 +140,7 @@ pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut App
|
||||
// shell always publishes this field from its live remote tier, so None
|
||||
// means remote settings cleared it (or an older shell that cannot deliver the
|
||||
// remote tier at all) — either way resolving without a remote value is
|
||||
// correct, and it reverts a previously cached remote enable back to the
|
||||
// correct, and it reverts a cached remote enable back to the
|
||||
// local/default (off) resolution instead of leaving Some(true) stuck
|
||||
// until restart.
|
||||
let remote = kigi_shell::util::config::RemoteSettings {
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
/// background `monitor`/bash task (`TaskBackgrounded`) must restore into
|
||||
/// `bg_tasks` on a resumed / second terminal — not be dropped by the
|
||||
/// default match arm — so the idle "watching" status line and the Tasks pane
|
||||
/// match the originating terminal. (Before this routing only subagents
|
||||
/// survived resume.)
|
||||
/// match the originating terminal.
|
||||
#[test]
|
||||
fn ext_session_update_replay_restores_bg_task() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
@@ -129,7 +128,6 @@
|
||||
setup_pending_execute_tool(&mut app, tc_id);
|
||||
send_late_bg_detection(&mut app, tc_id);
|
||||
|
||||
// Tool should be in BOTH pending_tools and bg_deferred_tools
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.tracker.pending_tool_entry_id(tc_id).is_some());
|
||||
assert!(agent.session.tracker.bg_deferred_tools.contains_key(tc_id));
|
||||
@@ -260,14 +258,12 @@
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
// Parent scrollback must NOT have the bg task block.
|
||||
assert_eq!(agent.scrollback.len(), 0, "parent scrollback must be empty");
|
||||
assert!(
|
||||
agent.session.bg_tasks.is_empty(),
|
||||
"parent session must not have the bg task"
|
||||
);
|
||||
|
||||
// Child view must have the bg task.
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(child.scrollback.len(), 1);
|
||||
assert!(child.session.bg_tasks.contains_key("task-child-1"));
|
||||
@@ -403,25 +399,21 @@
|
||||
fn task_completed_routes_to_child_session() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// First, background a task on the child.
|
||||
let bg_notif =
|
||||
make_task_backgrounded_notif("child-sess", "tc-child-2", "task-child-2", "echo hi");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
// Now complete it.
|
||||
let notif = make_task_completed_notif("child-sess", "task-child-2", "echo hi", Some(0));
|
||||
let changed = handle_task_completed(¬if, &mut app);
|
||||
assert!(changed);
|
||||
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
// Parent must NOT have a completion block.
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
0,
|
||||
"parent scrollback must not have completion block"
|
||||
);
|
||||
|
||||
// Child must have both the started and completed blocks.
|
||||
let child = agent.subagent_views.get("child-sess").unwrap();
|
||||
assert_eq!(child.scrollback.len(), 2, "child: started + completed");
|
||||
let bg = child.session.bg_tasks.get("task-child-2").unwrap();
|
||||
@@ -432,7 +424,6 @@
|
||||
fn task_completed_root_still_routes_to_parent() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// Background and complete a task on the parent.
|
||||
let bg_notif =
|
||||
make_task_backgrounded_notif("parent-sess", "tc-root-2", "task-root-2", "echo root");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
@@ -510,7 +501,6 @@
|
||||
fn monitor_event_root_still_routes_to_parent() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// Background a task on the parent.
|
||||
let bg_notif =
|
||||
make_task_backgrounded_notif("parent-sess", "tc-root-3", "task-root-3", "tail -f");
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
@@ -560,7 +550,6 @@
|
||||
fn task_completed_child_inactive_returns_false_but_mutates_state() {
|
||||
let mut app = make_app_with_parent_and_child("parent-sess", "child-sess");
|
||||
|
||||
// Background a task on the child first.
|
||||
let bg_notif = make_task_backgrounded_notif(
|
||||
"child-sess",
|
||||
"tc-compl-inact",
|
||||
@@ -569,7 +558,6 @@
|
||||
);
|
||||
handle_task_backgrounded(&bg_notif, &mut app);
|
||||
|
||||
// Now switch away.
|
||||
let other = make_agent(Some("other-sess"));
|
||||
app.agents.insert(AgentId(1), other);
|
||||
crate::app::dispatch::switch_to_agent(
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
.session
|
||||
.current_prompt_id = Some("p1".into());
|
||||
|
||||
// Active turn (p1) chips applied via the wire.
|
||||
assert!(handle_ext_notification(
|
||||
&follow_ups_ext_with_prompt("resp-1", "p1", &["a"]),
|
||||
&mut app
|
||||
@@ -39,7 +38,6 @@
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().clear_follow_ups();
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
|
||||
// (a) Re-delivery of the active turn re-renders.
|
||||
assert!(
|
||||
handle_ext_notification(
|
||||
&follow_ups_ext_with_prompt("resp-1", "p1", &["a"]),
|
||||
@@ -56,7 +54,6 @@
|
||||
"resp-1"
|
||||
);
|
||||
|
||||
// Adopt a new turn p2; clear.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
@@ -64,7 +61,6 @@
|
||||
.current_prompt_id = Some("p2".into());
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().clear_follow_ups();
|
||||
|
||||
// (b) Prior turn (p1) replay must NOT revive.
|
||||
assert!(
|
||||
!handle_ext_notification(
|
||||
&follow_ups_ext_with_prompt("resp-1", "p1", &["a"]),
|
||||
@@ -199,7 +195,6 @@
|
||||
let affected = handle_ext_notification(&follow_ups_ext(&big, &["x"]), &mut app);
|
||||
assert!(!affected, "an oversized response_id must be rejected");
|
||||
assert!(app.agents[&AgentId(0)].follow_ups.is_none());
|
||||
// A sane-length id still works.
|
||||
let ok = "r".repeat(super::MAX_RESPONSE_ID_LEN);
|
||||
assert!(handle_ext_notification(
|
||||
&follow_ups_ext(&ok, &["x"]),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
// ── derive_child_cwd ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn derive_child_cwd_uses_child_cwd_from_info() {
|
||||
let parent_cwd = PathBuf::from("/parent/cwd");
|
||||
@@ -89,7 +87,6 @@
|
||||
);
|
||||
assert!(child_view.is_worktree);
|
||||
assert_eq!(child_view.main_repo.as_deref(), Some("main-repo"));
|
||||
// Parent must not be affected.
|
||||
assert!(parent.current_branch.is_none());
|
||||
assert!(!parent.is_worktree);
|
||||
}
|
||||
|
||||
@@ -45,11 +45,6 @@
|
||||
"last_event": "verify_started",
|
||||
"last_event_detail": "round 2 of 3",
|
||||
"last_event_timestamp": "2026-05-24T00:00:00Z",
|
||||
// Field absent on today's `SessionUpdate::GoalUpdated` — simulates
|
||||
// a future shell adding a new wire field. With trailing `..` in
|
||||
// the destructure and no `deny_unknown_fields` on the variant,
|
||||
// this must parse and the pager must still produce a
|
||||
// GoalDisplayState mapped from the known subset.
|
||||
"future_field_for_pr5": "ignored-by-todays-pager"
|
||||
}
|
||||
});
|
||||
@@ -107,7 +102,6 @@
|
||||
Some("2026-05-24T00:00:00Z")
|
||||
);
|
||||
assert_eq!(goal.pause_message, None);
|
||||
// Classifier fields default to `None` / `false` when absent.
|
||||
assert_eq!(goal.classifier_runs_attempted, None);
|
||||
assert_eq!(goal.classifier_max_runs, None);
|
||||
assert_eq!(goal.last_classifier_verdict, None);
|
||||
@@ -177,8 +171,6 @@
|
||||
"transition to Complete pushes one e2e marker with the goal's total time",
|
||||
);
|
||||
|
||||
// A repeat Complete update (e.g. a late notification) must not
|
||||
// duplicate the marker.
|
||||
send(&mut app, "complete", 620_000);
|
||||
assert_eq!(
|
||||
goal_markers(&app).len(),
|
||||
@@ -238,7 +230,6 @@
|
||||
"chip cleared on cleared status"
|
||||
);
|
||||
|
||||
// A stale late update for the cleared goal must not resurrect it.
|
||||
let affected = send_goal_update(&mut app, "g1", "complete", 5_000);
|
||||
assert!(
|
||||
app.agents.get(&AgentId(0)).unwrap().goal_state.is_none(),
|
||||
@@ -268,7 +259,6 @@
|
||||
// the prior goal's carried elapsed floor.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
send_goal_update(&mut app, "g1", "active", 10_000);
|
||||
// Switch directly to a different goal with a small elapsed base.
|
||||
send_goal_update(&mut app, "g2", "active", 500);
|
||||
let elapsed = app
|
||||
.agents
|
||||
@@ -290,7 +280,6 @@
|
||||
// on receipt into the cached bool (no per-frame stat).
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
// A real on-disk path → cached exists = true.
|
||||
let f = tempfile::NamedTempFile::new().unwrap();
|
||||
let real_path = f.path().to_string_lossy().into_owned();
|
||||
let mut update = goal_update_value("g1", "active", 0);
|
||||
@@ -312,7 +301,6 @@
|
||||
Some(real_path.as_str())
|
||||
);
|
||||
|
||||
// A missing path → cached exists = false (modal renders "(unavailable)").
|
||||
let mut update = goal_update_value("g1", "active", 0);
|
||||
update["last_classifier_details_path"] = serde_json::json!("/no/such/details-xyz.md");
|
||||
dispatch_goal_update(&mut app, update);
|
||||
@@ -336,9 +324,7 @@
|
||||
// be omitted from the wire payload and must surface as `None` in
|
||||
// the destructured arm — i.e. the pager keeps mapping the known
|
||||
// subset cleanly when the shell-side struct grows or when an
|
||||
// older shell omits newer optional fields. Drop a handful of
|
||||
// optional keys from the payload and assert they materialise as
|
||||
// `None` on the resulting `GoalDisplayState`.
|
||||
// older shell omits newer optional fields.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
|
||||
let raw_payload = serde_json::json!({
|
||||
@@ -349,26 +335,14 @@
|
||||
"objective": "minimal payload",
|
||||
"status": "active",
|
||||
"phase": "idle",
|
||||
// token_budget omitted — Option<i64> must default to None.
|
||||
"tokens_used": 0,
|
||||
"elapsed_ms": 0,
|
||||
"total_deliverables": 0,
|
||||
"completed_deliverables": 0,
|
||||
// current_deliverable_idx omitted — Option<u32> -> None.
|
||||
// current_deliverable_title omitted — Option<String> -> None.
|
||||
// current_subagent_role omitted — Option<String> -> None.
|
||||
"total_worker_rounds": 0,
|
||||
"total_verify_rounds": 0,
|
||||
"token_baseline": 0,
|
||||
"finished_subagent_tokens": 0,
|
||||
// live_subagent_tokens omitted — Option<u64> -> None.
|
||||
// live_context_pct omitted — Option<u8> -> None.
|
||||
// live_turn_count omitted — Option<u32> -> None.
|
||||
// live_tool_call_count omitted — Option<u32> -> None.
|
||||
// last_event omitted — Option<String> -> None.
|
||||
// last_event_detail omitted — Option<String> -> None.
|
||||
// last_event_timestamp omitted — Option<String> -> None.
|
||||
// pause_message omitted — Option<String> -> None.
|
||||
}
|
||||
});
|
||||
let raw = serde_json::value::to_raw_value(&raw_payload).unwrap();
|
||||
@@ -391,7 +365,6 @@
|
||||
.as_ref()
|
||||
.expect("GoalUpdated must populate goal_state even with all Option fields omitted");
|
||||
|
||||
// Required fields landed as sent.
|
||||
assert_eq!(goal.goal_id, "g-min");
|
||||
assert_eq!(goal.objective, "minimal payload");
|
||||
assert_eq!(goal.status, GoalDisplayStatus::Active);
|
||||
@@ -405,9 +378,6 @@
|
||||
assert_eq!(goal.token_baseline, 0);
|
||||
assert_eq!(goal.finished_subagent_tokens, 0);
|
||||
|
||||
// Every omitted Option<T> wire field must surface as None — this
|
||||
// is the property that keeps the destructure stable as the shell
|
||||
// grows additive optional fields.
|
||||
assert_eq!(goal.token_budget, None, "token_budget");
|
||||
assert_eq!(goal.current_deliverable_id, None, "current_deliverable_id");
|
||||
assert_eq!(
|
||||
|
||||
@@ -75,9 +75,8 @@
|
||||
|
||||
#[test]
|
||||
fn permission_for_inactive_agent_queues_on_owning_agent() {
|
||||
// The headline behavior change in handle_permission_request:
|
||||
// permissions for an inactive owning agent now QUEUE (not cancel)
|
||||
// so the user sees them on switching back.
|
||||
// A permission for an inactive owning agent queues on that agent (rather
|
||||
// than being cancelled), so the user sees it on switching back.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
@@ -101,8 +100,6 @@
|
||||
!affected,
|
||||
"permission queued on a non-active agent must not request a redraw"
|
||||
);
|
||||
// Permission is still pending; the response_tx must still be alive
|
||||
// (no auto-cancel was sent).
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"permission must NOT have been answered yet (queued, not cancelled)"
|
||||
@@ -255,8 +252,6 @@
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plan approval persistence tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn close_viewer_preserves_plan_approval_state() {
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
@@ -279,11 +274,9 @@
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.plan_approval_view.is_some(), "approval should be set");
|
||||
|
||||
// Close the viewer (simulates Esc / close button).
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.cancel_line_viewer();
|
||||
|
||||
// Approval state must survive the close.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(
|
||||
agent.plan_approval_view.is_some(),
|
||||
@@ -291,7 +284,6 @@
|
||||
);
|
||||
assert!(agent.line_viewer.is_none(), "viewer should be closed");
|
||||
|
||||
// Response must NOT have been sent (still waiting for user).
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"response must not be sent on viewer close"
|
||||
@@ -323,12 +315,10 @@
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Close viewer.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.cancel_line_viewer();
|
||||
assert!(agent.line_viewer.is_none());
|
||||
|
||||
// Reopen plan preview — inline content is in plan_approval_view.plan_content.
|
||||
agent.show_plan_preview();
|
||||
|
||||
assert!(agent.line_viewer.is_some(), "viewer should reopen");
|
||||
@@ -361,11 +351,9 @@
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Close viewer.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.cancel_line_viewer();
|
||||
|
||||
// User types new text in the prompt while viewer is closed.
|
||||
agent.prompt.set_text("my new prompt text");
|
||||
|
||||
agent.reopen_plan_approval();
|
||||
@@ -378,7 +366,6 @@
|
||||
"stashed prompt should be restored after reopen + approve"
|
||||
);
|
||||
|
||||
// Response should be approved.
|
||||
let response = rx.blocking_recv().expect("should have sent response");
|
||||
let raw = response.expect("should be Ok");
|
||||
let parsed: serde_json::Value = serde_json::from_str(raw.0.get()).unwrap();
|
||||
|
||||
@@ -64,7 +64,6 @@
|
||||
assert_eq!(count_parked(agent), 0, "no marker on screen");
|
||||
}
|
||||
|
||||
// A task completing in the still-parked window must stay silent.
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
@@ -101,7 +100,6 @@
|
||||
assert!(agent.renders_parked());
|
||||
}
|
||||
|
||||
// sleep 10 exits → full marker with "2 commands still running."
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
@@ -111,12 +109,10 @@
|
||||
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
// sleep 15 exits → full marker with "1 command still running."
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t15", "sleep 15", Some(0)),
|
||||
&mut app,
|
||||
);
|
||||
// sleep 20 exits → nothing left; no "0 commands" line.
|
||||
handle_ext_notification(
|
||||
&make_task_completed_notif("sess-park", "t20", "sleep 20", Some(0)),
|
||||
&mut app,
|
||||
@@ -438,7 +434,6 @@
|
||||
),
|
||||
&mut app,
|
||||
);
|
||||
// Only the initial parked marker — no countdown re-push.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
assert_eq!(parked_marker_messages(agent).len(), 1);
|
||||
}
|
||||
@@ -465,8 +460,6 @@
|
||||
assert!(parked_marker_messages(agent).is_empty());
|
||||
}
|
||||
|
||||
// -- imminent waits do not park (awaited work already finished) ----------
|
||||
|
||||
/// Waiting on a task that already completed: no marker, slot stays free.
|
||||
#[test]
|
||||
fn wait_on_already_completed_task_pushes_no_parked_marker() {
|
||||
@@ -715,9 +708,9 @@
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_pushes_block_to_matching_session() {
|
||||
// Multi-client fix: an interjection typed in one pane is broadcast by
|
||||
// the shell as kigi/session/interjection; EVERY attached pane (incl.
|
||||
// the originator, which no longer pushes a local block) renders it.
|
||||
// An interjection typed in one pane is broadcast by the shell as
|
||||
// kigi/session/interjection; every attached pane — including the
|
||||
// originator — renders it from the broadcast rather than a local push.
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
let affected =
|
||||
handle_ext_notification(&interjection_ext("sess-view", "also add tests"), &mut app);
|
||||
@@ -746,8 +739,6 @@
|
||||
|
||||
#[test]
|
||||
fn interjection_notification_renders_for_a_viewer() {
|
||||
// A viewer (attached_as_viewer) watching another client's session must
|
||||
// also render interjections broadcast for that session.
|
||||
let mut app = make_app_with_agent("sess-view");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().attached_as_viewer = true;
|
||||
let affected =
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
|
||||
#[test]
|
||||
fn mcp_init_progress_updates_seeded_progress_in_place() {
|
||||
// When a session is seeded with mcp_init_progress{0,0}, a
|
||||
// subsequent init_progress notification must update total and
|
||||
// connected IN PLACE — preserving started_at for timer accuracy.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
@@ -66,7 +63,6 @@
|
||||
"owner is background — mutation must not request a redraw"
|
||||
);
|
||||
|
||||
// Owner mutated.
|
||||
let owner_modal = app
|
||||
.agents
|
||||
.get(&AgentId(0))
|
||||
@@ -81,7 +77,6 @@
|
||||
assert_eq!(owner_servers[0].tool_count, 2);
|
||||
assert_eq!(owner_servers[0].tools.len(), 2);
|
||||
|
||||
// Active agent's modal must be untouched.
|
||||
let active_modal = app
|
||||
.agents
|
||||
.get(&AgentId(1))
|
||||
@@ -101,8 +96,6 @@
|
||||
|
||||
#[test]
|
||||
fn mcp_init_progress_creates_when_none() {
|
||||
// When mcp_init_progress is None (no seed), init_progress
|
||||
// creates a fresh McpInitProgress.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
assert!(app.agents[&AgentId(0)].mcp_init_progress.is_none());
|
||||
|
||||
@@ -117,7 +110,6 @@
|
||||
|
||||
#[test]
|
||||
fn mcp_initialized_clears_progress() {
|
||||
// kigi/mcp_initialized must set mcp_init_progress to None.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
@@ -137,9 +129,6 @@
|
||||
|
||||
#[test]
|
||||
fn mcp_full_lifecycle_seed_to_clear() {
|
||||
// Full N-server lifecycle:
|
||||
// seed(0/0) → init_progress(0/3) → init_progress(2/3)
|
||||
// → init_progress(3/3) → mcp_initialized → None
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
@@ -148,22 +137,18 @@
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
|
||||
// Shell reports real count.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(3, 0), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (3, 0));
|
||||
|
||||
// Incremental progress.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(3, 2), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (3, 2));
|
||||
|
||||
// All connected.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(3, 3), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (3, 3));
|
||||
|
||||
// mcp_initialized clears everything.
|
||||
handle_ext_notification(&make_mcp_initialized_notif("sess-1"), &mut app);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_none(),
|
||||
@@ -173,10 +158,10 @@
|
||||
|
||||
#[test]
|
||||
fn mcp_zero_server_lifecycle() {
|
||||
// 0-server lifecycle (the bug scenario):
|
||||
// 0-server bug scenario: for 0 servers the shell must still emit
|
||||
// mcp_initialized; without that terminal event the progress
|
||||
// indicator sticks forever.
|
||||
// seed(0/0) → init_progress(0/0) → mcp_initialized → None
|
||||
// Previously mcp_initialized was never sent for 0 servers,
|
||||
// leaving a stuck progress indicator.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||
@@ -185,12 +170,10 @@
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
|
||||
// Shell sends 0/0 for the 0-server case.
|
||||
handle_ext_notification(&make_mcp_init_progress_notif(0, 0), &mut app);
|
||||
let p = app.agents[&AgentId(0)].mcp_init_progress.as_ref().unwrap();
|
||||
assert_eq!((p.total, p.connected), (0, 0));
|
||||
|
||||
// Shell now sends mcp_initialized (root-cause fix).
|
||||
handle_ext_notification(&make_mcp_initialized_notif("sess-1"), &mut app);
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].mcp_init_progress.is_none(),
|
||||
@@ -200,9 +183,6 @@
|
||||
|
||||
#[test]
|
||||
fn mcp_init_progress_routes_to_background_session() {
|
||||
// init_progress carrying a background session's sessionId must update
|
||||
// *that* agent's indicator, not the foregrounded one, and must not
|
||||
// force a redraw (the background spinner isn't visible).
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.insert(AgentId(1), make_agent(Some("sess-B")));
|
||||
|
||||
@@ -223,10 +203,9 @@
|
||||
|
||||
#[test]
|
||||
fn mcp_initialized_routes_to_background_session() {
|
||||
// mcp_initialized for a background session must clear *that* agent's
|
||||
// indicator while leaving the foreground agent's intact. Previously
|
||||
// the clear was applied to whichever agent was active, so a
|
||||
// background agent's spinner could stick forever.
|
||||
// mcp_initialized must clear the indicator of the agent named by
|
||||
// sessionId, not whichever agent is active — else a background
|
||||
// agent's spinner sticks forever.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.insert(AgentId(1), make_agent(Some("sess-B")));
|
||||
for id in [AgentId(0), AgentId(1)] {
|
||||
@@ -256,8 +235,6 @@
|
||||
|
||||
#[test]
|
||||
fn mcp_initialized_unknown_session_is_dropped() {
|
||||
// An mcp_initialized for a session that matches no agent must not
|
||||
// clear anyone's indicator (no misrouting to the active agent).
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().mcp_init_progress =
|
||||
Some(crate::app::agent_view::McpInitProgress {
|
||||
@@ -289,7 +266,6 @@
|
||||
connected: 1,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
// Register a subagent child view keyed by the child session id.
|
||||
app.agents
|
||||
.get_mut(&AgentId(0))
|
||||
.unwrap()
|
||||
@@ -299,7 +275,6 @@
|
||||
Box::new(make_agent(Some("child-sess"))),
|
||||
);
|
||||
|
||||
// init_progress for the child session must leave the parent untouched.
|
||||
let changed = handle_ext_notification(
|
||||
&make_mcp_init_progress_notif_for(5, 0, "child-sess"),
|
||||
&mut app,
|
||||
@@ -315,7 +290,6 @@
|
||||
"parent spinner must be untouched by a subagent's init",
|
||||
);
|
||||
|
||||
// mcp_initialized for the child session must not clear the parent.
|
||||
let changed =
|
||||
handle_ext_notification(&make_mcp_initialized_notif_for("child-sess"), &mut app);
|
||||
assert!(!changed);
|
||||
@@ -329,8 +303,6 @@
|
||||
fn server_status_handler_noop_when_modal_closed_background() {
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
// Owner is background and has NO modal open. server_status
|
||||
// must be a silent no-op (no Effect scheduling, no redraw).
|
||||
let notif = make_server_status_notif("sess-owner", "alpha", McpServerStatus::Ready, None);
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(!redraw, "closed-modal cheap path must not request a redraw");
|
||||
@@ -355,8 +327,6 @@
|
||||
fn server_status_handler_noop_when_modal_closed_foreground() {
|
||||
use kigi_shell::extensions::mcp::McpServerStatus;
|
||||
let mut app = make_app_two_agents();
|
||||
// Foreground = agent 1 (sess-active). Send a push targeting
|
||||
// the foregrounded agent, no modal open.
|
||||
let notif = make_server_status_notif("sess-active", "alpha", McpServerStatus::Ready, None);
|
||||
let redraw = handle_mcp_server_status(¬if, &mut app);
|
||||
assert!(
|
||||
@@ -504,7 +474,6 @@
|
||||
#[test]
|
||||
fn servers_updated_broadcasts_to_every_agent_with_open_modal() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Open modals on BOTH agents — broadcast must hit both.
|
||||
seed_owner_agent_with_open_modal(&mut app);
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
@@ -552,13 +521,9 @@
|
||||
assert_eq!(targets, vec![0, 1]);
|
||||
}
|
||||
|
||||
/// Agents without an open modal must NOT receive a refetch (cheap
|
||||
/// path) even though they are eligible to receive the broadcast in
|
||||
/// principle.
|
||||
#[test]
|
||||
fn servers_updated_skips_agents_with_closed_modal() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Only agent 1 (foregrounded) has a modal.
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(Vec::new()));
|
||||
@@ -623,9 +588,6 @@
|
||||
fn mcp_initialized_clears_init_progress_on_owner() {
|
||||
use crate::app::agent_view::McpInitProgress;
|
||||
let mut app = make_app_two_agents();
|
||||
// Seed init progress on the OWNER (agent 0) and on the
|
||||
// active view (agent 1). The push must clear only the
|
||||
// owner's overlay.
|
||||
{
|
||||
let owner = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
owner.mcp_init_progress = Some(McpInitProgress {
|
||||
@@ -698,7 +660,6 @@
|
||||
#[test]
|
||||
fn tools_changed_pre_h2_falls_back_to_active_view() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Active agent (agent 1) gets a modal; owner (agent 0) does not.
|
||||
{
|
||||
let active = app.agents.get_mut(&AgentId(1)).unwrap();
|
||||
active.extensions_modal = Some(make_mcps_modal_with_servers(Vec::new()));
|
||||
|
||||
@@ -122,7 +122,6 @@ pub(super) fn compressed_entry(
|
||||
compressed_height: 1018,
|
||||
}
|
||||
}
|
||||
/// Most recent `SessionEvent` pushed to the scrollback, if any.
|
||||
pub(super) fn last_session_event(sb: &ScrollbackState) -> Option<SessionEvent> {
|
||||
(0..sb.len())
|
||||
.rev()
|
||||
@@ -160,7 +159,6 @@ pub(super) fn interjection_broadcast(
|
||||
),
|
||||
)
|
||||
}
|
||||
/// A Running background task registered on the agent's root session.
|
||||
pub(super) fn insert_running_task(agent: &mut AgentView, task_id: &str, command: &str) {
|
||||
agent
|
||||
.session
|
||||
@@ -363,7 +361,6 @@ pub(super) fn queue_changed_ext(session_id: &str, ids: &[&str]) -> acp::ExtNotif
|
||||
std::sync::Arc::from(serde_json::value::to_raw_value(¶ms).unwrap()),
|
||||
)
|
||||
}
|
||||
/// Build a `kigi/queue/changed` notification carrying `runningPromptId`.
|
||||
pub(super) fn queue_changed_running(
|
||||
session_id: &str,
|
||||
ids: &[&str],
|
||||
@@ -407,7 +404,6 @@ pub(super) fn app_with_running_p1_and_stashed_b1() -> AppView {
|
||||
assert!(app.pending_running_adoptions.contains_key(& AgentId(0)));
|
||||
app
|
||||
}
|
||||
/// Drive a live Execute tool_call `session/update` through the full handler.
|
||||
pub(super) fn send_tool_call_update(
|
||||
app: &mut AppView,
|
||||
prompt_id: &str,
|
||||
@@ -438,7 +434,6 @@ pub(super) fn send_tool_call_update(
|
||||
app,
|
||||
);
|
||||
}
|
||||
/// Dispatch an `Ok(EndTurn)` PromptResponse for `prompt_id`.
|
||||
pub(super) fn prompt_response(app: &mut AppView, prompt_id: &str) {
|
||||
use crate::app::actions::{Action, TaskResult};
|
||||
crate::app::dispatch::dispatch(
|
||||
@@ -567,7 +562,6 @@ pub(super) fn make_token_notification_message(
|
||||
})
|
||||
}
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
/// Build an `AgentMessageChunk` notification carrying `text` for `session_id`.
|
||||
pub(super) fn make_agent_chunk_message(
|
||||
session_id: &str,
|
||||
text: &str,
|
||||
@@ -584,7 +578,6 @@ pub(super) fn make_agent_chunk_message(
|
||||
response_tx: tx,
|
||||
})
|
||||
}
|
||||
/// `AgentMessageChunk` with `promptId`/`isReplay` + optional `eventId`.
|
||||
pub(super) fn make_agent_chunk_meta(
|
||||
session_id: &str,
|
||||
text: &str,
|
||||
@@ -613,7 +606,6 @@ pub(super) fn make_agent_chunk_meta(
|
||||
response_tx: tx,
|
||||
})
|
||||
}
|
||||
/// `promptId`-tagged chunk (no `eventId`) — drives the viewer live-delta path.
|
||||
pub(super) fn make_agent_chunk_message_with_prompt(
|
||||
session_id: &str,
|
||||
text: &str,
|
||||
@@ -622,7 +614,6 @@ pub(super) fn make_agent_chunk_message_with_prompt(
|
||||
) -> AcpClientMessage {
|
||||
make_agent_chunk_meta(session_id, text, prompt_id, None, is_replay)
|
||||
}
|
||||
/// Live (`isReplay=false`) chunk with an optional `eventId`, for dedup tests.
|
||||
pub(super) fn make_agent_chunk_with_event(
|
||||
session_id: &str,
|
||||
text: &str,
|
||||
@@ -631,7 +622,6 @@ pub(super) fn make_agent_chunk_with_event(
|
||||
) -> AcpClientMessage {
|
||||
make_agent_chunk_meta(session_id, text, prompt_id, event_id, false)
|
||||
}
|
||||
/// Replay-marked chunk with an eventId, as `session/load` emits.
|
||||
pub(super) fn replay_chunk(
|
||||
session_id: &str,
|
||||
text: &str,
|
||||
@@ -650,7 +640,6 @@ pub(super) fn scrollback_has_system_text(agent: &mut AgentView, needle: &str) ->
|
||||
)
|
||||
})
|
||||
}
|
||||
/// `Plan` update message with the given entry contents.
|
||||
pub(super) fn plan_update_msg(
|
||||
session_id: &str,
|
||||
entries: &[&str],
|
||||
@@ -715,8 +704,6 @@ pub(super) fn xai_unhandled_notif(
|
||||
std::sync::Arc::from(serde_json::value::to_raw_value(&payload).unwrap()),
|
||||
)
|
||||
}
|
||||
/// Build an `agent_message_chunk` notification carrying both `totalTokens`
|
||||
/// and an explicit `eventId`, for context/dedup interaction tests.
|
||||
pub(super) fn make_token_notification_with_event(
|
||||
session_id: &str,
|
||||
total_tokens: u64,
|
||||
@@ -741,7 +728,6 @@ pub(super) fn make_token_notification_with_event(
|
||||
response_tx: tx,
|
||||
})
|
||||
}
|
||||
/// Build an `kigi/session/prompt_complete` ext-notification for `session_id`.
|
||||
pub(super) fn prompt_complete_ext(session_id: &str) -> acp::ExtNotification {
|
||||
let raw = serde_json::value::to_raw_value(
|
||||
&serde_json::json!({ "sessionId" : session_id, "stopReason" : "end_turn", }),
|
||||
@@ -749,12 +735,9 @@ pub(super) fn prompt_complete_ext(session_id: &str) -> acp::ExtNotification {
|
||||
.unwrap();
|
||||
acp::ExtNotification::new("kigi/session/prompt_complete", std::sync::Arc::from(raw))
|
||||
}
|
||||
/// Insert a fresh agent at `id` with an optional pre-assigned session id.
|
||||
pub(super) fn insert_agent(app: &mut AppView, id: AgentId, session_id: Option<&str>) {
|
||||
app.agents.insert(id, make_agent(session_id));
|
||||
}
|
||||
/// Build an `kigi/session/prompt_complete` ext-notification with an explicit
|
||||
/// `stopReason` and optional `agentResult`.
|
||||
pub(super) fn prompt_complete_ext_with_reason(
|
||||
session_id: &str,
|
||||
stop_reason: &str,
|
||||
@@ -872,7 +855,6 @@ pub(super) fn xai_wake_turn_completed_notif(
|
||||
std::sync::Arc::from(serde_json::value::to_raw_value(&payload).unwrap()),
|
||||
)
|
||||
}
|
||||
/// The newest turn-marker block on the agent's scrollback.
|
||||
pub(super) fn last_marker_block(
|
||||
sb: &ScrollbackState,
|
||||
) -> &crate::scrollback::blocks::SessionEventBlock {
|
||||
@@ -884,8 +866,6 @@ pub(super) fn last_marker_block(
|
||||
})
|
||||
.expect("a turn-end marker must exist")
|
||||
}
|
||||
/// Build a `HookExecution` update (one successful run) on the
|
||||
/// `kigi/session/update` rail, optionally stamped `isReplay`.
|
||||
/// `prompt_id == None` models pre-attribution shells.
|
||||
pub(super) fn xai_hook_execution_notif_for_prompt(
|
||||
session_id: &str,
|
||||
@@ -932,7 +912,6 @@ pub(super) fn count_lifecycle_blocks(
|
||||
})
|
||||
.count()
|
||||
}
|
||||
/// Stop-hook groups on the last turn-terminal session-event marker, if any.
|
||||
pub(super) fn last_marker_stop_hook_groups(
|
||||
sb: &crate::scrollback::state::ScrollbackState,
|
||||
) -> Option<usize> {
|
||||
@@ -945,7 +924,6 @@ pub(super) fn last_marker_stop_hook_groups(
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
/// Work-only status lines ("N … still running") pushed as system rows.
|
||||
pub(super) fn work_status_lines(sb: &ScrollbackState) -> Vec<String> {
|
||||
(0..sb.len())
|
||||
.filter_map(|i| match sb.get(i).map(|e| &e.block) {
|
||||
@@ -970,12 +948,10 @@ pub(super) fn seed_two_bg_tasks_and_announce(app: &mut AppView, session_id: &str
|
||||
);
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().end_work_announced = true;
|
||||
}
|
||||
/// Build an `kigi/session/interjection` ext-notification (no id).
|
||||
pub(super) fn interjection_ext(session_id: &str, text: &str) -> acp::ExtNotification {
|
||||
interjection_ext_with_id(session_id, text, None)
|
||||
}
|
||||
/// Build an `kigi/session/interjection` ext-notification with an optional
|
||||
/// `interjectionId` (the originator-dedup key).
|
||||
/// `interjectionId` is the originator-dedup key.
|
||||
pub(super) fn interjection_ext_with_id(
|
||||
session_id: &str,
|
||||
text: &str,
|
||||
@@ -988,7 +964,6 @@ pub(super) fn interjection_ext_with_id(
|
||||
let raw = serde_json::value::to_raw_value(&payload).unwrap();
|
||||
acp::ExtNotification::new("kigi/session/interjection", std::sync::Arc::from(raw))
|
||||
}
|
||||
/// Text of the most recent user prompt block in scrollback, if any.
|
||||
/// Interjections render as standard user prompt blocks.
|
||||
pub(super) fn last_interjection_text(sb: &ScrollbackState) -> Option<String> {
|
||||
(0..sb.len())
|
||||
@@ -1008,7 +983,6 @@ pub(super) fn switch_active_to(app: &mut AppView, id: AgentId) {
|
||||
crate::app::dispatch::SwitchCause::Picker,
|
||||
);
|
||||
}
|
||||
/// Concatenate the text of every `AgentMessage` block in this view's scrollback.
|
||||
pub(super) fn agent_message_text(view: &AgentView) -> String {
|
||||
let mut out = String::new();
|
||||
for i in 0..view.scrollback.len() {
|
||||
@@ -1020,7 +994,6 @@ pub(super) fn agent_message_text(view: &AgentView) -> String {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Build a `Plan` notification with one entry per `entries` string.
|
||||
pub(super) fn make_plan_message(session_id: &str, entries: &[&str]) -> AcpClientMessage {
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let plan_entries = entries
|
||||
@@ -1040,7 +1013,6 @@ pub(super) fn make_plan_message(session_id: &str, entries: &[&str]) -> AcpClient
|
||||
response_tx: tx,
|
||||
})
|
||||
}
|
||||
/// Build an `AvailableCommandsUpdate` notification with the given command names.
|
||||
pub(super) fn make_commands_update_message(
|
||||
session_id: &str,
|
||||
names: &[&str],
|
||||
@@ -1061,8 +1033,6 @@ pub(super) fn make_commands_update_message(
|
||||
response_tx: tx,
|
||||
})
|
||||
}
|
||||
/// Build a `ToolCallUpdate` notification carrying a Bash `raw_output`
|
||||
/// chunk for `tool_call_id`. Used to drive the bg-task stdout route.
|
||||
pub(super) fn make_bash_stdout_message(
|
||||
session_id: &str,
|
||||
tool_call_id: &str,
|
||||
@@ -1090,7 +1060,6 @@ pub(super) fn make_bash_stdout_message(
|
||||
response_tx: tx,
|
||||
})
|
||||
}
|
||||
/// Build an `ExtNotification` envelope for `kigi/session_notification`.
|
||||
pub(super) fn make_ext_session_notification(
|
||||
session_id: &str,
|
||||
update: XaiSessionUpdate,
|
||||
@@ -1101,7 +1070,6 @@ pub(super) fn make_ext_session_notification(
|
||||
update,
|
||||
)
|
||||
}
|
||||
/// Build an `ExtNotification` envelope with an explicit xAI session method.
|
||||
pub(super) fn make_ext_session_notification_with_method(
|
||||
session_id: &str,
|
||||
method: &str,
|
||||
@@ -1179,7 +1147,6 @@ pub(super) fn test_subagent_progress(
|
||||
error_count: 0,
|
||||
}
|
||||
}
|
||||
/// Snapshot of subagent state after SubagentSpawned for method-parity tests.
|
||||
pub(super) struct SubagentSpawnSnapshot {
|
||||
description: String,
|
||||
subagent_type: String,
|
||||
@@ -1210,7 +1177,6 @@ pub(super) fn snapshot_after_subagent_spawn(
|
||||
scrollback_entry_id: info.scrollback_entry_id,
|
||||
}
|
||||
}
|
||||
/// Snapshot after SubagentFinished for method-parity tests.
|
||||
pub(super) struct SubagentFinishSnapshot {
|
||||
finished: bool,
|
||||
status: Option<String>,
|
||||
@@ -1427,8 +1393,6 @@ pub(super) fn dispatch_goal_update(
|
||||
app,
|
||||
)
|
||||
}
|
||||
/// Build + dispatch a `GoalUpdated` for `sess-A` with the given id /
|
||||
/// status / elapsed; returns whether the notification requested a redraw.
|
||||
pub(super) fn send_goal_update(
|
||||
app: &mut AppView,
|
||||
goal_id: &str,
|
||||
@@ -1437,8 +1401,6 @@ pub(super) fn send_goal_update(
|
||||
) -> bool {
|
||||
dispatch_goal_update(app, goal_update_value(goal_id, status, elapsed_ms))
|
||||
}
|
||||
/// Build a minimal `RequestPermission` message that carries `session_id`
|
||||
/// and one `AllowOnce` option.
|
||||
pub(super) fn make_permission_message(
|
||||
session_id: &str,
|
||||
) -> (
|
||||
@@ -1543,8 +1505,6 @@ pub(super) fn make_replayed_task_backgrounded_notif(
|
||||
let raw = serde_json::value::to_raw_value(¬if).unwrap();
|
||||
acp::ExtNotification::new("kigi/session/update", std::sync::Arc::from(raw))
|
||||
}
|
||||
/// Register a pending Execute tool call in the tracker and send an InProgress
|
||||
/// update to create the scrollback entry. Returns the agent for further use.
|
||||
pub(super) fn setup_pending_execute_tool(app: &mut AppView, tc_id: &str) {
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
let meta = crate::acp::meta::NotificationMeta::default();
|
||||
@@ -1568,7 +1528,6 @@ pub(super) fn setup_pending_execute_tool(app: &mut AppView, tc_id: &str) {
|
||||
);
|
||||
agent.session.tracker.handle_update(update, &meta, &mut agent.scrollback);
|
||||
}
|
||||
/// Send a late InProgress update with is_background=true to trigger late bg detection.
|
||||
pub(super) fn send_late_bg_detection(app: &mut AppView, tc_id: &str) {
|
||||
use serde_json::json;
|
||||
use kigi_tools::types::output::{BashOutput, ToolOutput};
|
||||
@@ -1728,9 +1687,7 @@ pub(super) fn make_reasoning_models_update_notif(
|
||||
acp::ExtNotification::new("kigi/models/update", std::sync::Arc::from(raw))
|
||||
}
|
||||
/// Seed a session's model catalog with the given ids and mark
|
||||
/// `current_model_id` as the active one (must be in the list). Used by
|
||||
/// the `ModelChanged` broadcast tests to set up a starting state that
|
||||
/// the simulated remote/local switch then transitions away from.
|
||||
/// `current_model_id` as the active one (must be in the list).
|
||||
pub(super) fn seed_models(agent: &mut AgentView, current: &str, available: &[&str]) {
|
||||
for id in available {
|
||||
let model_id = acp::ModelId::new(std::sync::Arc::from(*id));
|
||||
@@ -1796,7 +1753,6 @@ pub(super) fn make_current_mode_update(mode_id: &str) -> acp::SessionUpdate {
|
||||
acp::CurrentModeUpdate::new(acp::SessionModeId::new(mode_id)),
|
||||
)
|
||||
}
|
||||
/// Helper: build an `kigi/mcp/init_progress` notification.
|
||||
pub(super) fn make_mcp_init_progress_notif(
|
||||
total: u32,
|
||||
connected: u32,
|
||||
@@ -1865,8 +1821,6 @@ pub(super) fn make_servers_updated_notif() -> acp::ExtNotification {
|
||||
let raw = serde_json::value::to_raw_value(&payload).unwrap();
|
||||
acp::ExtNotification::new("kigi/mcp/servers_updated", std::sync::Arc::from(raw))
|
||||
}
|
||||
/// Real post-handshake / auth-recovery wire shape:
|
||||
/// `McpToolsChanged { sessionId, serverName, tools }`.
|
||||
pub(super) fn make_tools_changed_notif_post_h2(
|
||||
session_id: &str,
|
||||
) -> acp::ExtNotification {
|
||||
@@ -1886,8 +1840,6 @@ pub(super) fn make_tools_changed_notif_pre_h2() -> acp::ExtNotification {
|
||||
let raw = serde_json::value::to_raw_value(&payload).unwrap();
|
||||
acp::ExtNotification::new("kigi/mcp/tools_changed", std::sync::Arc::from(raw))
|
||||
}
|
||||
/// Real `mcp_initialized` wire shape:
|
||||
/// `{ sessionId, mcpToolCount, elapsedMs }`.
|
||||
pub(super) fn make_mcp_initialized_notif(session_id: &str) -> acp::ExtNotification {
|
||||
let payload = serde_json::json!(
|
||||
{ "sessionId" : session_id, "mcpToolCount" : 12_u64, "elapsedMs" : 250_u64, }
|
||||
@@ -1895,7 +1847,6 @@ pub(super) fn make_mcp_initialized_notif(session_id: &str) -> acp::ExtNotificati
|
||||
let raw = serde_json::value::to_raw_value(&payload).unwrap();
|
||||
acp::ExtNotification::new("kigi/mcp_initialized", std::sync::Arc::from(raw))
|
||||
}
|
||||
/// Helper: `init_progress` notification carrying an explicit sessionId.
|
||||
pub(super) fn make_mcp_init_progress_notif_for(
|
||||
total: u32,
|
||||
connected: u32,
|
||||
@@ -1909,7 +1860,6 @@ pub(super) fn make_mcp_init_progress_notif_for(
|
||||
.unwrap();
|
||||
acp::ExtNotification::new("kigi/mcp/init_progress", std::sync::Arc::from(raw))
|
||||
}
|
||||
/// Helper: `mcp_initialized` notification for a specific sessionId.
|
||||
pub(super) fn make_mcp_initialized_notif_for(session_id: &str) -> acp::ExtNotification {
|
||||
let raw = serde_json::value::to_raw_value(
|
||||
&serde_json::json!(
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
agent_b.session.models.current = Some(id_5);
|
||||
}
|
||||
|
||||
// kigi-5 removed from catalog.
|
||||
// kigi-4.5 removed from catalog.
|
||||
let notif = make_models_update_notif("kigi-4", &["kigi-3", "kigi-4"]);
|
||||
handle_models_update(¬if, &mut app);
|
||||
|
||||
@@ -206,7 +206,7 @@
|
||||
"agent A's model must be preserved"
|
||||
);
|
||||
|
||||
// B's kigi-5 was removed — must fall back to shell's kigi-4, not A's kigi-3.
|
||||
// B's kigi-4.5 was removed — must fall back to shell's kigi-4, not A's kigi-3.
|
||||
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert_eq!(
|
||||
agent_b
|
||||
@@ -230,7 +230,6 @@
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
|
||||
let scrollback_before = agent.scrollback.len();
|
||||
// Follower: no local switch in flight.
|
||||
assert!(!agent.session.model_switch_pending);
|
||||
|
||||
let notif = model_changed_ext("sess-1", "kigi-4", None);
|
||||
@@ -326,8 +325,6 @@
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
|
||||
// Invoker: a local switch is in flight (set by Action::SwitchModel /
|
||||
// set_default_model before the SetSessionModelRequest is sent).
|
||||
agent.session.model_switch_pending = true;
|
||||
let scrollback_before = agent.scrollback.len();
|
||||
|
||||
|
||||
@@ -45,8 +45,6 @@
|
||||
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
|
||||
// Establish the shared queue with p1 present, then put the agent in
|
||||
// EditingQueued{server_id: Some("p1")}.
|
||||
assert!(handle_ext_notification(
|
||||
&queue_changed_ext("sess-1", &["p1"]),
|
||||
&mut app
|
||||
@@ -1018,8 +1016,9 @@
|
||||
"adoption-on-load must not grow the scrollback"
|
||||
);
|
||||
|
||||
// A live (non-replay) chunk stamped with the adopted prompt id now
|
||||
// passes the gate and renders (previously dropped → the viewer froze).
|
||||
// A live (non-replay) chunk stamped with the adopted prompt id
|
||||
// passes the gate and renders; without the adoption the gate would
|
||||
// drop it and the viewer would freeze.
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let request = acp::SessionNotification::new(
|
||||
acp::SessionId::new("sess-1"),
|
||||
@@ -1750,8 +1749,7 @@
|
||||
);
|
||||
}
|
||||
|
||||
/// The credit-limit early return discards the popped adoption's buffer.
|
||||
/// A stash whose pid replayed a durable terminal is discarded, never adopted.
|
||||
/// A stash whose pid replayed a durable terminal is discarded, never adopted.
|
||||
#[test]
|
||||
fn terminal_in_replay_stash_is_discarded_not_adopted() {
|
||||
let mut app = app_with_running_p1_and_stashed_b1();
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
let mut app = make_app_with_agent("sess-dedup");
|
||||
let id = AgentId(0);
|
||||
|
||||
// First event applies (active agent → affected==true) and sets highwater.
|
||||
let a1 = handle(
|
||||
make_agent_chunk_with_event("sess-dedup", "hello", "p1", Some("sess-dedup-5")),
|
||||
&mut app,
|
||||
@@ -59,7 +58,6 @@
|
||||
assert!(a1, "first event must apply");
|
||||
assert_eq!(app.agents[&id].last_applied_event_seq, Some(5));
|
||||
|
||||
// Exact duplicate eventId → dropped (not affected), highwater unchanged.
|
||||
let a2 = handle(
|
||||
make_agent_chunk_with_event("sess-dedup", "hello", "p1", Some("sess-dedup-5")),
|
||||
&mut app,
|
||||
@@ -67,7 +65,6 @@
|
||||
assert!(!a2, "a duplicate eventId must be dropped");
|
||||
assert_eq!(app.agents[&id].last_applied_event_seq, Some(5));
|
||||
|
||||
// Stale lower eventId → dropped.
|
||||
let a3 = handle(
|
||||
make_agent_chunk_with_event("sess-dedup", "hello", "p1", Some("sess-dedup-3")),
|
||||
&mut app,
|
||||
@@ -75,7 +72,6 @@
|
||||
assert!(!a3, "a lower (already-passed) eventId must be dropped");
|
||||
assert_eq!(app.agents[&id].last_applied_event_seq, Some(5));
|
||||
|
||||
// New higher eventId → applies, highwater advances.
|
||||
let a4 = handle(
|
||||
make_agent_chunk_with_event("sess-dedup", "world", "p1", Some("sess-dedup-9")),
|
||||
&mut app,
|
||||
@@ -83,7 +79,6 @@
|
||||
assert!(a4, "a new (higher) eventId must apply");
|
||||
assert_eq!(app.agents[&id].last_applied_event_seq, Some(9));
|
||||
|
||||
// No eventId (older shell) → always applies; highwater untouched.
|
||||
let a5 = handle(
|
||||
make_agent_chunk_with_event("sess-dedup", "again", "p1", None),
|
||||
&mut app,
|
||||
@@ -101,7 +96,6 @@
|
||||
fn replayed_history_with_event_id_resets_does_not_break_resume() {
|
||||
let mut app = make_app_with_agent("sess-resume");
|
||||
let id = AgentId(0);
|
||||
// Replay arrives inside a `session/load` window.
|
||||
app.agents.get_mut(&id).unwrap().session.loading_replay = true;
|
||||
|
||||
// eventIds climb (5, 9) then reset below the peak (2, 4): resumed twice.
|
||||
@@ -125,7 +119,6 @@
|
||||
Some("sess-resume-4"),
|
||||
"the reconnect cursor follows the last APPLIED event id, replay included"
|
||||
);
|
||||
// SessionLoaded completes the window.
|
||||
app.agents.get_mut(&id).unwrap().session.loading_replay = false;
|
||||
|
||||
assert!(
|
||||
@@ -228,13 +221,10 @@
|
||||
agent.begin_session_reload(1);
|
||||
}
|
||||
|
||||
// Partial replay lands before the load fails...
|
||||
assert!(!handle(
|
||||
replay_chunk("sess-rc", "h1", "sess-rc-1"),
|
||||
&mut app
|
||||
));
|
||||
// ...along with live post-cursor traffic on BOTH streams, advancing
|
||||
// both highwaters inside the doomed staging.
|
||||
let _ = handle(
|
||||
make_agent_chunk_with_event("sess-rc", "live tail", "p9", Some("sess-rc-40")),
|
||||
&mut app,
|
||||
@@ -303,7 +293,6 @@
|
||||
agent.begin_session_reload(1);
|
||||
}
|
||||
|
||||
// Post-cursor tail arrives as LIVE updates (no isReplay).
|
||||
assert!(!handle(
|
||||
make_agent_chunk_with_event("sess-rc", "tail", "p2", Some("sess-rc-4")),
|
||||
&mut app,
|
||||
@@ -375,12 +364,10 @@
|
||||
1,
|
||||
"gen-1 partial replay is discarded; gen-2 staging holds only the placeholder"
|
||||
);
|
||||
// Finalizing the dead gen-1 window is rejected.
|
||||
assert!(!agent.finish_session_reload(1, true));
|
||||
assert!(agent.session.loading_replay);
|
||||
}
|
||||
|
||||
// Gen-2 load fails → the ORIGINAL transcript comes back.
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
assert!(agent.finish_session_reload(2, false));
|
||||
assert!(scrollback_has_system_text(agent, "pre-outage content"));
|
||||
@@ -401,7 +388,6 @@
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system("pre-outage content"));
|
||||
// In-flight fresh-view load: open batch + placeholder + replay flag.
|
||||
agent.scrollback.begin_batch();
|
||||
let pid = agent
|
||||
.scrollback
|
||||
@@ -468,7 +454,6 @@
|
||||
);
|
||||
app.agents.get_mut(&id).unwrap().begin_session_reload(1);
|
||||
|
||||
// Replayed Plan overwrites the (fresh) live pane during the window.
|
||||
let _ = handle(
|
||||
plan_update_msg("sess-todo", &["replayed-task"], Some("sess-todo-2"), true),
|
||||
&mut app,
|
||||
@@ -494,7 +479,6 @@
|
||||
);
|
||||
app.agents.get_mut(&id).unwrap().begin_session_reload(1);
|
||||
|
||||
// A LIVE tail Plan applied in-window is newer than the stash.
|
||||
let _ = handle(
|
||||
plan_update_msg("sess-todo", &["tail-task"], Some("sess-todo-2"), false),
|
||||
&mut app,
|
||||
@@ -544,7 +528,6 @@
|
||||
assert_eq!(app.agents[&id].scrollback.len(), 1);
|
||||
assert_eq!(app.agents[&id].last_applied_xai_event_seq, Some(10));
|
||||
|
||||
// Exact re-delivery: dropped, nothing re-applied, cursor unchanged.
|
||||
assert!(!handle_ext_notification(
|
||||
&xai_model_switch_notif("sess-xdup", "sess-xdup-10"),
|
||||
&mut app
|
||||
@@ -559,7 +542,6 @@
|
||||
Some("sess-xdup-10")
|
||||
);
|
||||
|
||||
// A newer event still applies.
|
||||
assert!(handle_ext_notification(
|
||||
&xai_model_switch_notif("sess-xdup", "sess-xdup-11"),
|
||||
&mut app
|
||||
@@ -603,7 +585,6 @@
|
||||
"an unhandled xAI update must not advance the dedup highwater"
|
||||
);
|
||||
|
||||
// An applied kind (ModelAutoSwitched) advances both.
|
||||
assert!(handle_ext_notification(
|
||||
&xai_model_switch_notif("sess-ig", "sess-ig-8"),
|
||||
&mut app
|
||||
@@ -759,7 +740,6 @@
|
||||
Some("sess-cur-5")
|
||||
);
|
||||
|
||||
// Duplicate (deduped) — cursor unchanged.
|
||||
assert!(!handle(
|
||||
make_agent_chunk_with_event("sess-cur", "a", "p1", Some("sess-cur-5")),
|
||||
&mut app,
|
||||
@@ -810,7 +790,6 @@
|
||||
let mut app = make_app_with_agent("sess-xai");
|
||||
let id = AgentId(0);
|
||||
|
||||
// Replay-stamped with no load in flight → dropped, nothing pushed.
|
||||
let replay_meta = serde_json::json!({ "isReplay": true, "eventId": "sess-xai-7" });
|
||||
assert!(!handle_ext_notification(
|
||||
&model_switch_notif(Some(replay_meta.clone())),
|
||||
@@ -822,8 +801,6 @@
|
||||
assert!(agent.last_seen_event_id.is_none());
|
||||
}
|
||||
|
||||
// Same update inside a reload window → applied and marks the window
|
||||
// as full-replay (finishing keeps the staged state, drops the stash).
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent
|
||||
@@ -875,7 +852,6 @@
|
||||
agent.begin_session_reload(1);
|
||||
}
|
||||
|
||||
// Replayed spawn, no finish (mirrors a mid-subagent reconnect replay).
|
||||
let payload = SessionNotification {
|
||||
session_id: acp::SessionId::new("sess-sub"),
|
||||
update: test_subagent_spawned("sess-sub", "child-sub"),
|
||||
@@ -905,8 +881,6 @@
|
||||
"the child view exists and is tracked"
|
||||
);
|
||||
|
||||
// A live child delta after the swap still renders into the child view:
|
||||
// pager-side routing is intact when the leader delivers it.
|
||||
let child_len_before = app.agents[&id].subagent_views["child-sub"].scrollback.len();
|
||||
let _ = handle(
|
||||
make_agent_chunk_with_event("child-sub", "child live text", "p-child", None),
|
||||
@@ -929,7 +903,6 @@
|
||||
let mut app = make_app_with_agent("sess-ctx");
|
||||
let id = AgentId(0);
|
||||
|
||||
// Fresh live delta: high eventId, high token count.
|
||||
let _ = handle(
|
||||
make_token_notification_with_event("sess-ctx", 500_000, "sess-ctx-20"),
|
||||
&mut app,
|
||||
@@ -940,7 +913,6 @@
|
||||
);
|
||||
assert_eq!(app.agents[&id].last_applied_event_seq, Some(20));
|
||||
|
||||
// Stale historical replay delta: lower eventId (deduped), lower tokens.
|
||||
let _ = handle(
|
||||
make_token_notification_with_event("sess-ctx", 120_000, "sess-ctx-7"),
|
||||
&mut app,
|
||||
@@ -950,7 +922,6 @@
|
||||
Some(500_000),
|
||||
"a deduped stale delta must not regress context_used to its lower value"
|
||||
);
|
||||
// Highwater unchanged by the deduped event.
|
||||
assert_eq!(app.agents[&id].last_applied_event_seq, Some(20));
|
||||
}
|
||||
|
||||
@@ -961,10 +932,8 @@
|
||||
fn reconnect_finalize_reload_skips_adoption_when_terminal_in_replay() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
let id = AgentId(0);
|
||||
// Open a reconnect reload window (enters the replay window, clean set).
|
||||
app.agents.get_mut(&id).unwrap().begin_session_reload(1);
|
||||
|
||||
// The running turn's terminal arrives in the reconnect replay → recorded.
|
||||
let _ = handle_ext_notification(
|
||||
&xai_turn_completed_notif("sess-1", "p-run", "end_turn", true),
|
||||
&mut app,
|
||||
@@ -999,7 +968,6 @@
|
||||
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
|
||||
}
|
||||
|
||||
// Unknown model → ignored → both markers untouched.
|
||||
assert!(!handle_ext_notification(
|
||||
&model_changed_ext_with_event("sess-1", "kigi-99-unknown", "sess-1-7"),
|
||||
&mut app
|
||||
@@ -1013,7 +981,6 @@
|
||||
"an ignored ModelChanged must not advance the dedup highwater"
|
||||
);
|
||||
|
||||
// Known model → applied → both markers advance.
|
||||
assert!(handle_ext_notification(
|
||||
&model_changed_ext_with_event("sess-1", "kigi-4", "sess-1-8"),
|
||||
&mut app
|
||||
|
||||
@@ -15,16 +15,12 @@
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(result);
|
||||
|
||||
// Agent should now be in TurnRunning (drain happened, prompt was sent).
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.state.is_turn_running());
|
||||
assert!(agent.session.pending_prompts.is_empty());
|
||||
|
||||
// Scrollback should have a cron prompt block.
|
||||
assert!(!agent.scrollback.is_empty());
|
||||
|
||||
// pending_effects should contain a SendPromptBlocks with system-reminder framing,
|
||||
// displayText/displayAsCron meta, and a scheduler-fired- prompt_id prefix.
|
||||
match &app.pending_effects[0] {
|
||||
Effect::SendPromptBlocks {
|
||||
blocks, prompt_id, ..
|
||||
@@ -54,10 +50,9 @@
|
||||
// The leader routes `kigi/scheduled_task_inject_prompt` to the SINGLE
|
||||
// session driver, so any client that receives it IS the driver and must
|
||||
// enqueue + run it — even one that attached via `session/load`
|
||||
// (`attached_as_viewer == true`). Previously this handler latched on
|
||||
// `attached_as_viewer` and skipped, which stranded the cron loop with no
|
||||
// output whenever the designated driver was an attacher (the sticky-flag
|
||||
// bug). Pin the corrected behavior: the inject drives the turn.
|
||||
// (`attached_as_viewer == true`). Gating the inject on that flag would
|
||||
// strand the cron loop with no output when the designated driver is an
|
||||
// attacher (the sticky-flag bug): the inject must drive the turn.
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
app.agents.get_mut(&AgentId(0)).unwrap().attached_as_viewer = true;
|
||||
|
||||
@@ -110,7 +105,6 @@
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(!result);
|
||||
// Nothing should be enqueued.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.pending_prompts.is_empty());
|
||||
}
|
||||
@@ -126,7 +120,6 @@
|
||||
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(!result);
|
||||
// Agent should still be idle, nothing enqueued.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert!(agent.session.state.is_idle());
|
||||
}
|
||||
@@ -134,7 +127,6 @@
|
||||
#[test]
|
||||
fn inject_prompt_busy_agent_enqueues_without_draining() {
|
||||
let mut app = make_app_with_agent("sess-1");
|
||||
// Make the agent busy.
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
|
||||
@@ -147,14 +139,12 @@
|
||||
let result = handle_scheduled_task_inject_prompt(¬if, &mut app);
|
||||
assert!(result);
|
||||
|
||||
// Prompt should be queued but not drained (agent was busy).
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
assert_eq!(agent.session.pending_prompts.len(), 1);
|
||||
assert_eq!(
|
||||
agent.session.pending_prompts[0].kind,
|
||||
crate::app::agent::QueueEntryKind::Cron
|
||||
);
|
||||
// No effects produced (drain was a no-op since agent was busy).
|
||||
assert!(app.pending_effects.is_empty());
|
||||
}
|
||||
|
||||
@@ -185,7 +175,6 @@
|
||||
1
|
||||
);
|
||||
|
||||
// A re-fire of the same task while it is still queued must not pile up.
|
||||
assert!(handle_scheduled_task_inject_prompt(
|
||||
&make_inject_notif(&payload),
|
||||
&mut app
|
||||
@@ -212,7 +201,6 @@
|
||||
"humanSchedule": "every 1m",
|
||||
});
|
||||
|
||||
// First fire on an idle agent drains into a running cron turn.
|
||||
assert!(handle_scheduled_task_inject_prompt(
|
||||
&make_inject_notif(&payload),
|
||||
&mut app
|
||||
@@ -221,7 +209,6 @@
|
||||
assert!(agent.session.state.is_turn_running());
|
||||
assert!(agent.session.pending_prompts.is_empty());
|
||||
|
||||
// A re-fire while that same loop turn is running must be skipped, not queued.
|
||||
assert!(handle_scheduled_task_inject_prompt(
|
||||
&make_inject_notif(&payload),
|
||||
&mut app
|
||||
@@ -379,7 +366,6 @@
|
||||
#[test]
|
||||
fn fired_updates_correct_agent_when_active_view_differs() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Seed a known task on agent 0.
|
||||
{
|
||||
let agent0 = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent0.session.scheduled_tasks.insert(
|
||||
@@ -411,7 +397,6 @@
|
||||
"non-active agent mutation should not trigger redraw"
|
||||
);
|
||||
|
||||
// Agent 0's next_fire_at must be updated.
|
||||
let agent0 = app.agents.get(&AgentId(0)).unwrap();
|
||||
let info = agent0.session.scheduled_tasks.get("task-owner").unwrap();
|
||||
assert_eq!(
|
||||
@@ -420,7 +405,6 @@
|
||||
"next_fire_at must update on the owning agent, not the active one"
|
||||
);
|
||||
|
||||
// Agent 1 must be completely untouched.
|
||||
let agent1 = app.agents.get(&AgentId(1)).unwrap();
|
||||
assert!(
|
||||
agent1.session.scheduled_tasks.is_empty(),
|
||||
@@ -460,7 +444,6 @@
|
||||
#[test]
|
||||
fn deleted_removes_from_correct_agent_when_active_view_differs() {
|
||||
let mut app = make_app_two_agents();
|
||||
// Seed task on agent 0.
|
||||
{
|
||||
let agent0 = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent0.session.scheduled_tasks.insert(
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
|
||||
// ── apply_session_event ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn apply_compaction_started_sets_activity() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
@@ -232,7 +230,6 @@
|
||||
Some("legacy_auth"),
|
||||
"Unauthorized (401) ... deprecated authentication method"
|
||||
));
|
||||
// Unrelated failures must not be treated as re-authable.
|
||||
assert!(!is_reauthable_failure(
|
||||
Some("server_error"),
|
||||
"internal server error"
|
||||
@@ -339,7 +336,6 @@
|
||||
));
|
||||
}
|
||||
|
||||
/// Non-auth terminal failures still render the standard RetryFailed.
|
||||
#[test]
|
||||
fn apply_retry_state_generic_failure_still_shows_retry_failed() {
|
||||
let mut session = make_session(Some("s1"));
|
||||
@@ -544,8 +540,6 @@
|
||||
assert!(!apply_session_event(&update, &mut session, &mut scrollback, false));
|
||||
}
|
||||
|
||||
// ── handle_child_session_notification ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn child_compact_completed_updates_subagent_info() {
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
@@ -616,7 +610,6 @@
|
||||
#[test]
|
||||
fn child_notification_without_view_returns_false() {
|
||||
let mut agent = make_agent(Some("root-sess"));
|
||||
// No child view registered.
|
||||
let update = XaiSessionUpdate::AutoCompactStarted {
|
||||
tokens_used: 90000,
|
||||
context_window: 131072,
|
||||
@@ -659,8 +652,6 @@
|
||||
assert!(!changed);
|
||||
}
|
||||
|
||||
// ── apply_retry_state ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn retry_failed_encrypted_content_sets_model_incompatible() {
|
||||
use kigi_shell::extensions::notification::RetryState;
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
#[test]
|
||||
fn acp_chunk_for_inactive_agent_lands_in_its_scrollback() {
|
||||
// Regression: switching away from a streaming agent must not
|
||||
// discard chunks bound for that agent. Before this fix, only
|
||||
// `TaskResult::PromptResponse` survived, so the user saw a bare
|
||||
// "Worked for X.Xs" with no body text.
|
||||
// discard chunks bound for that agent. If only
|
||||
// `TaskResult::PromptResponse` were routed, the user would see a
|
||||
// bare "Worked for X.Xs" with no body text.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
@@ -32,7 +32,6 @@
|
||||
|
||||
#[test]
|
||||
fn acp_chunk_for_active_agent_returns_affected_true() {
|
||||
// Baseline: chunk for the visible agent triggers a redraw.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
@@ -46,9 +45,6 @@
|
||||
|
||||
#[test]
|
||||
fn acp_chunk_for_subagent_routes_through_parent() {
|
||||
// Subagent (child) chunk must land in the parent's
|
||||
// `subagent_views[child_sid]` even when a different agent is
|
||||
// currently active.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
@@ -169,7 +165,6 @@
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
// Sanity: A's todo starts empty.
|
||||
assert_eq!(
|
||||
app.agents.get(&AgentId(0)).unwrap().todo.counts().total(),
|
||||
0,
|
||||
@@ -277,9 +272,6 @@
|
||||
|
||||
#[test]
|
||||
fn acp_chunks_for_two_agents_dont_cross_contaminate() {
|
||||
// Send chunks to both A and B in sequence; each landing in its own
|
||||
// scrollback proves the demux works in both directions regardless
|
||||
// of which agent is currently active.
|
||||
let mut app = make_app_with_agent("sess-A");
|
||||
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
||||
switch_active_to(&mut app, AgentId(1));
|
||||
|
||||
@@ -154,8 +154,8 @@
|
||||
}
|
||||
|
||||
/// The live-refresh flip mirrors `set_group_tool_verbs_inner`'s stale
|
||||
/// group-expansion cleanup: a previously expanded verb slot must not
|
||||
/// survive a remote flip as an expanded header.
|
||||
/// group-expansion cleanup: a verb slot expanded before the flip must
|
||||
/// not survive it as an expanded header.
|
||||
#[test]
|
||||
fn settings_update_flip_resets_stale_group_expansion() {
|
||||
crate::appearance::cache::set_group_tool_verbs(true);
|
||||
@@ -204,16 +204,16 @@
|
||||
// Two agents both in auto; the active tab's global mirror reads "ask"
|
||||
// (a tab switch / Shift+Tab re-anchored it away from auto). A
|
||||
// mid-session gate kill-switch (`auto_permission_mode_enabled=false`)
|
||||
// must clear the per-session auto flag on BOTH agents. The old code
|
||||
// gated this fan-out on `current_ui.permission_mode == "auto"`, so it
|
||||
// skipped background agents and left stale `auto_mode` that
|
||||
// must clear the per-session auto flag on BOTH agents. Gating the
|
||||
// fan-out on `current_ui.permission_mode == "auto"` would skip
|
||||
// background agents and leave stale `auto_mode` that
|
||||
// `switch_to_agent` could re-anchor back to "auto" on return.
|
||||
let mut app = make_app_two_agents();
|
||||
app.auto_mode_gate = true;
|
||||
for agent in app.agents.values_mut() {
|
||||
agent.session.auto_mode = true;
|
||||
}
|
||||
// Active tab's mirror is NOT "auto" — the old bug's skip condition.
|
||||
// Active tab's mirror is NOT "auto" — reproduces the skip condition.
|
||||
app.current_ui.permission_mode = Some("ask".into());
|
||||
|
||||
let killswitch = acp::ExtNotification::new(
|
||||
@@ -263,7 +263,6 @@
|
||||
let _ = handle_ext_notification(&killswitch, &mut app);
|
||||
|
||||
assert!(!app.auto_mode_gate, "gate must be off after kill-switch");
|
||||
// Sibling always-approve is untouched — the kill-switch clears only auto.
|
||||
assert!(
|
||||
app.agents[&AgentId(2)].session.is_yolo(),
|
||||
"sibling always-approve must stay yolo after the auto kill-switch"
|
||||
|
||||
@@ -538,7 +538,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
/// Live spawn: meta prompt without updates.jsonl still injects the task once.
|
||||
#[test]
|
||||
fn subagent_spawn_live_injects_meta_prompt_once_without_updates() {
|
||||
with_replay_disk_home(|home| {
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
agent.session.start_turn(&mut agent.scrollback);
|
||||
agent.session.current_prompt_id = Some("pid-stuck".into());
|
||||
agent.session.cancel_turn(&mut agent.scrollback); // CancelTurn → TurnCancelling
|
||||
agent.session.cancel_turn(&mut agent.scrollback);
|
||||
assert!(!agent.attached_as_viewer);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,8 @@ use super::agent::AgentId;
|
||||
use crate::scrollback::entry::EntryId;
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_shell::sampling::types::ReasoningEffort;
|
||||
/// Typed error for model switch failures. Replaces the raw `String` in
|
||||
/// `TaskResult::SwitchModelComplete` so dispatch can match on the variant
|
||||
/// instead of parsing strings.
|
||||
/// Typed error for model switch failures: lets dispatch match on the
|
||||
/// variant instead of parsing an error string.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SwitchModelError {
|
||||
/// The target model requires a different agent harness than the
|
||||
@@ -36,7 +35,6 @@ pub enum SwitchModelError {
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum Action {
|
||||
/// Quit the application.
|
||||
Quit,
|
||||
/// Restart the binary to pick up a downloaded update.
|
||||
QuitForUpdate,
|
||||
@@ -217,9 +215,7 @@ pub enum Action {
|
||||
/// live on [`Effect::QueueInterject`].
|
||||
new_text: Option<String>,
|
||||
},
|
||||
/// Focus the prompt pane.
|
||||
FocusPrompt,
|
||||
/// Focus the scrollback pane (leave prompt).
|
||||
FocusScrollback,
|
||||
/// Clear the prompt (history-aware). Armed by idle Esc double-press via
|
||||
/// [`super::app_view::InputOutcome::ArmPending`] (no ActionDef; not a keybinding).
|
||||
@@ -229,39 +225,26 @@ pub enum Action {
|
||||
/// `/` goes to the prompt) reach the same search as the vim `/` key.
|
||||
/// Carries the optional `/find <word>` argument to pre-fill the bar.
|
||||
OpenScrollbackSearch(Option<String>),
|
||||
/// Select next entry in scrollback.
|
||||
SelectNext,
|
||||
/// Select previous entry.
|
||||
SelectPrev,
|
||||
/// Jump to next turn boundary.
|
||||
NextTurn,
|
||||
/// Jump to previous turn boundary.
|
||||
PrevTurn,
|
||||
/// Jump to next assistant response.
|
||||
NextResponse,
|
||||
/// Jump to previous assistant response.
|
||||
PrevResponse,
|
||||
/// Scroll up by N lines.
|
||||
ScrollUp(u16),
|
||||
/// Scroll down by N lines.
|
||||
ScrollDown(u16),
|
||||
/// Go to top of scrollback.
|
||||
GotoTop,
|
||||
/// Go to bottom of scrollback.
|
||||
GotoBottom,
|
||||
/// Half page up.
|
||||
HalfPageUp,
|
||||
/// Half page down.
|
||||
HalfPageDown,
|
||||
/// Full page up.
|
||||
PageUp,
|
||||
/// Full page down.
|
||||
PageDown,
|
||||
/// Collapse selected entry (no-op if already collapsed or not foldable).
|
||||
Collapse,
|
||||
/// Expand selected entry (no-op if already expanded or not foldable).
|
||||
Expand,
|
||||
/// Toggle fold on selected entry.
|
||||
ToggleFold,
|
||||
/// Smart expand/collapse all: expand all if any collapsed, else collapse all.
|
||||
ToggleExpandAll,
|
||||
@@ -771,7 +754,7 @@ pub enum Action {
|
||||
/// Exit the dashboard's session-overlay (the bordered
|
||||
/// `[Prev] [Next] [✗]` chrome wrapped around an attached
|
||||
/// agent view). Returns to the dashboard with the cursor on
|
||||
/// the previously attached row. Bound to Esc / Ctrl+\\ /
|
||||
/// the row that was attached. Bound to Esc / Ctrl+\\ /
|
||||
/// `[✗]` click inside the overlay.
|
||||
DashboardOverlayExit,
|
||||
/// Cycle the dashboard's session-overlay to the previous
|
||||
@@ -1064,11 +1047,6 @@ impl PlanModeKind {
|
||||
if b { Self::On } else { Self::Off }
|
||||
}
|
||||
}
|
||||
/// Async side effect produced by [`super::dispatch::dispatch`].
|
||||
///
|
||||
/// The event loop spawns these into a `JoinSet`. When they complete,
|
||||
/// the result is wrapped in [`TaskResult`] and fed back through
|
||||
/// `Action::TaskComplete`.
|
||||
/// What user gesture triggered a turn cancel. Recorded on `session/cancel`'s
|
||||
/// `_meta.cancelTrigger` so the agent's `mid_turn_abort` telemetry can tell
|
||||
/// ESC from Ctrl+C (and a mouse click on the cancel button) apart. Free-form
|
||||
@@ -1285,6 +1263,11 @@ pub enum ProbedAttachment {
|
||||
/// The attachment probe task failed or timed out.
|
||||
ProbeFailed,
|
||||
}
|
||||
/// Async side effect produced by [`super::dispatch::dispatch`].
|
||||
///
|
||||
/// The event loop spawns these into a `JoinSet`. When they complete,
|
||||
/// the result is wrapped in [`TaskResult`] and fed back through
|
||||
/// `Action::TaskComplete`.
|
||||
#[derive(Debug)]
|
||||
pub enum Effect {
|
||||
/// Create a new ACP session.
|
||||
@@ -2085,8 +2068,7 @@ pub enum TaskResult {
|
||||
/// A send-now `session/prompt` RPC failed at the transport/RPC layer —
|
||||
/// the prompt never reached the shell's queue. Carries the payload so
|
||||
/// dispatch can requeue it locally (the producer already consumed the
|
||||
/// composer/queue row, so dropping it would silently lose the message —
|
||||
/// the same contract the removed `InterjectFailed` requeue had).
|
||||
/// composer/queue row, so dropping it would silently lose the message).
|
||||
SendPromptNowFailed {
|
||||
agent_id: AgentId,
|
||||
session_id: acp::SessionId,
|
||||
|
||||
@@ -52,7 +52,6 @@ pub struct QueuedPrompt {
|
||||
pub id: u64,
|
||||
/// The prompt text (or command text, e.g. "/compact").
|
||||
pub text: String,
|
||||
/// Whether this is a prompt or a slash command.
|
||||
pub kind: QueueEntryKind,
|
||||
/// Optional separate payload for the wire. When `Some`, this is sent
|
||||
/// instead of `text`. Used for skill injection where the display
|
||||
@@ -117,10 +116,6 @@ impl QueuedPrompt {
|
||||
/// which execute immediately without going through the queue or agent.
|
||||
///
|
||||
/// Each variant carries the data needed for execution and display.
|
||||
/// Using an enum instead of a String gives us:
|
||||
/// - Type safety (can't misspell command names)
|
||||
/// - Variant-specific data (e.g., `/model` would carry target model)
|
||||
/// - Proper rendering per command type
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AgentCommand {
|
||||
/// `/compact` — compact conversation history.
|
||||
@@ -163,7 +158,6 @@ 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;
|
||||
/// Status of a background task.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BgTaskStatus {
|
||||
/// Currently running.
|
||||
|
||||
@@ -56,7 +56,7 @@ impl AgentView {
|
||||
/// (newest-response-wins).
|
||||
///
|
||||
/// Monotonic accept-the-newer: a never-seen `response_id` is strictly newer
|
||||
/// than any previously accepted one, so it supersedes the shown chips; a
|
||||
/// than any earlier-accepted one, so it supersedes the shown chips; a
|
||||
/// re-delivery of an already-accepted (hence older) response is ignored, so
|
||||
/// a buffer-replay or duplicate cannot clobber the newest chips on any
|
||||
/// turn-boundary path, with no reliance on a clear being wired there and no
|
||||
|
||||
@@ -225,7 +225,7 @@ impl AgentView {
|
||||
/// 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).
|
||||
/// Shift-X kill (only explicit way to dismiss).
|
||||
/// - **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};
|
||||
@@ -837,7 +837,6 @@ impl AgentView {
|
||||
_ => InputOutcome::Changed,
|
||||
}
|
||||
}
|
||||
/// Apply a scroll delta to the question view options.
|
||||
pub(super) fn apply_question_scroll(&mut self, delta: i32) {
|
||||
let Some(ref mut qv) = self.question_view else {
|
||||
return;
|
||||
@@ -1006,8 +1005,7 @@ impl AgentView {
|
||||
)
|
||||
.filter(|&idx| !qv.no_freeform || idx < question.options.len())
|
||||
}
|
||||
/// Save the current prompt text into `per_question_freeform[active_tab]`
|
||||
/// and load the text for the new `active_tab` into the prompt widget.
|
||||
/// Save the current prompt text into `per_question_freeform[active_tab]`.
|
||||
/// Call this BEFORE changing `active_tab`.
|
||||
fn swap_question_freeform(&mut self) {
|
||||
let Some(ref mut qv) = self.question_view else {
|
||||
@@ -1035,7 +1033,7 @@ impl AgentView {
|
||||
///
|
||||
/// Restores the original prompt text that was stashed when the question
|
||||
/// view opened, so typed "additional context" doesn't leak into the
|
||||
/// main prompt. Also clears any stashed (tab-hidden) question view.
|
||||
/// main prompt.
|
||||
fn dismiss_question_view(&mut self) {
|
||||
if let Some(qv) = self.question_view.take() {
|
||||
self.turn_paused_duration += qv.opened_at.elapsed();
|
||||
@@ -1130,9 +1128,6 @@ impl AgentView {
|
||||
InputOutcome::Changed
|
||||
}
|
||||
/// Map a screen position to a permission option index.
|
||||
///
|
||||
/// Uses the prompt area and permission chrome height to determine which
|
||||
/// option row the mouse is over. Returns `None` if outside the options.
|
||||
pub(super) fn permission_item_at(&self, _col: u16, row: u16) -> Option<usize> {
|
||||
let perm = self.permission_queue.front()?;
|
||||
let prompt_area = self.pane_areas.prompt;
|
||||
@@ -1156,8 +1151,6 @@ impl AgentView {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// Clean up question-related visual state after the question view is
|
||||
/// dismissed (submit, cancel, or replacement).
|
||||
fn cleanup_question_state(&mut self) {
|
||||
self.hovered_question_item = None;
|
||||
self.question_scrollbar_dragging = false;
|
||||
|
||||
@@ -56,7 +56,6 @@ impl AgentView {
|
||||
}
|
||||
});
|
||||
}
|
||||
/// Return the URL of the currently highlighted link, if any.
|
||||
pub fn highlighted_link_url(&self) -> Option<&str> {
|
||||
self.highlighted_link_idx
|
||||
.and_then(|idx| self.visible_link_map.links().get(idx))
|
||||
@@ -208,7 +207,6 @@ mod link_click_tests {
|
||||
agent.pane_areas.scrollback = area;
|
||||
agent.active_pane = AgentPane::Scrollback;
|
||||
}
|
||||
/// Add a link to the visible_link_map covering (col_start..col_end, row).
|
||||
fn add_visible_link(agent: &mut AgentView, row: u16, col_start: u16, col_end: u16, url: &str) {
|
||||
let mut overlay = LinkOverlay::new();
|
||||
overlay.push(OverlayLink {
|
||||
@@ -936,7 +934,7 @@ mod link_click_tests {
|
||||
assert!(agent.inline_edit.is_some(), "Enter must start inline edit");
|
||||
}
|
||||
/// Bash prompts are not inline-editable: Enter falls through to the
|
||||
/// registry (OpenBlockViewer) exactly as before.
|
||||
/// registry (OpenBlockViewer).
|
||||
#[test]
|
||||
fn enter_on_selected_bash_prompt_falls_through() {
|
||||
let mut agent = make_agent();
|
||||
@@ -954,8 +952,6 @@ mod link_click_tests {
|
||||
"expected fall-through to OpenBlockViewer, got {outcome:?}"
|
||||
);
|
||||
}
|
||||
/// Double-click on a user prompt enters inline edit mode (replacing the
|
||||
/// old fold-toggle for editable prompts).
|
||||
#[test]
|
||||
fn double_click_on_user_prompt_enters_inline_edit() {
|
||||
let mut agent = make_agent();
|
||||
|
||||
@@ -12,9 +12,6 @@ use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
|
||||
impl AgentView {
|
||||
// -- Image viewer input --------------------------------------------------
|
||||
|
||||
/// Handle a key event in the image viewer modal.
|
||||
pub(super) fn handle_image_viewer_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
@@ -24,9 +21,7 @@ impl AgentView {
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => {
|
||||
// Clear the Kitty image before closing.
|
||||
// Old code bypassed STDERR_OUTPUT_LOCK which could interleave
|
||||
// mid-frame. Safe to revert: content is valid escapes, not raw text.
|
||||
// Kitty images outlive the dropped viewer state; clear before close.
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
let clear = PostFlush::from(overlay::clear_kitty());
|
||||
let _ = clear.write_to(stderr);
|
||||
@@ -43,8 +38,6 @@ impl AgentView {
|
||||
InputOutcome::Changed
|
||||
}
|
||||
|
||||
// -- Inline media rendering -----------------------------------------------
|
||||
|
||||
/// Build Kitty/iTerm2 escape sequences for an inline media placement.
|
||||
pub(super) fn build_inline_media_escapes(
|
||||
&mut self,
|
||||
@@ -85,7 +78,6 @@ impl AgentView {
|
||||
let mut transmit_esc = String::new();
|
||||
|
||||
if needs_transmit {
|
||||
// Load bytes from disk (or use cached bytes if available).
|
||||
if !self.inline_media_cache.contains_key(path) {
|
||||
let bytes = if placement.info.is_video {
|
||||
let (frame_bytes, _, _) = crate::prompt_images::extract_poster_frame(path)?;
|
||||
@@ -184,8 +176,6 @@ impl AgentView {
|
||||
screen_rect: rect,
|
||||
source,
|
||||
} = aff;
|
||||
// The transient `rendering…` hint shows only while an on-click render
|
||||
// for this diagram is in flight.
|
||||
let rendering = self.diagram_is_rendering(&source);
|
||||
let row = affordance_row(rendering);
|
||||
// A segment is drawn only if it fits wholly within the row width
|
||||
@@ -194,7 +184,6 @@ impl AgentView {
|
||||
let fits =
|
||||
|col: u16, label: &str| col + UnicodeWidthStr::width(label) as u16 <= rect.width;
|
||||
|
||||
// Leading dim, non-clickable `◇ mermaid` label.
|
||||
let (label_col, label_text) = row.label;
|
||||
if fits(label_col, label_text) {
|
||||
buf.set_string_safe(
|
||||
@@ -245,7 +234,6 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing dim `rendering…` hint after the buttons (not clickable).
|
||||
if let Some((col, status)) = row.status
|
||||
&& fits(col, status)
|
||||
{
|
||||
@@ -259,13 +247,10 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the diagram with `source` has an on-click render in flight (drives
|
||||
/// the affordance row's transient `rendering…` hint).
|
||||
fn diagram_is_rendering(&self, source: &str) -> bool {
|
||||
self.mermaid_is_rendering(source)
|
||||
}
|
||||
|
||||
/// Get or allocate a Kitty image ID for the given media path.
|
||||
fn get_or_alloc_media_id(&mut self, path: &std::path::Path) -> u32 {
|
||||
if let Some(&id) = self.inline_media_ids.get(path) {
|
||||
return id;
|
||||
@@ -401,7 +386,6 @@ impl AgentView {
|
||||
/// path, restarts from the beginning. Frames are extracted via ffmpeg in
|
||||
/// a background thread so the UI never blocks.
|
||||
pub(crate) fn start_inline_video_playback(&mut self, path: &std::path::Path) {
|
||||
// If already loaded for this path, just restart.
|
||||
if let Some(ref mut video) = self.inline_video
|
||||
&& video.path == path
|
||||
{
|
||||
@@ -410,7 +394,6 @@ impl AgentView {
|
||||
video.last_frame_time = std::time::Instant::now();
|
||||
return;
|
||||
}
|
||||
// Extract frames in a background thread to avoid blocking the UI.
|
||||
let path_owned = path.to_path_buf();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
self.video_load_rx = Some(rx);
|
||||
@@ -431,8 +414,6 @@ impl AgentView {
|
||||
});
|
||||
}
|
||||
|
||||
// -- Inline media click handling -----------------------------------------
|
||||
|
||||
/// Handle a click on inline media buttons. Returns `Some(InputOutcome)` if
|
||||
/// the click was consumed, `None` to fall through to normal handling.
|
||||
pub(in crate::app) fn handle_inline_media_click(
|
||||
@@ -456,7 +437,6 @@ impl AgentView {
|
||||
return Some(InputOutcome::Changed);
|
||||
}
|
||||
|
||||
// [Play] button or video poster → start/restart inline playback.
|
||||
let play_target = self
|
||||
.inline_media_hits
|
||||
.play_buttons
|
||||
@@ -469,7 +449,6 @@ impl AgentView {
|
||||
return Some(InputOutcome::Changed);
|
||||
}
|
||||
|
||||
// [Copy] button → copy image to clipboard (async).
|
||||
if let Some((_, path)) = self
|
||||
.inline_media_hits
|
||||
.copy_image_buttons
|
||||
@@ -486,7 +465,6 @@ impl AgentView {
|
||||
return Some(InputOutcome::Changed);
|
||||
}
|
||||
|
||||
// Click on filepath line → copy path to clipboard.
|
||||
if let Some((_, path)) = self
|
||||
.inline_media_hits
|
||||
.filepath_areas
|
||||
@@ -498,9 +476,8 @@ impl AgentView {
|
||||
return Some(InputOutcome::Changed);
|
||||
}
|
||||
|
||||
// Mermaid affordance row → render-on-click (Open/Copy path) or copy
|
||||
// source. Resolve the kind + source index first so the `mermaid_buttons`
|
||||
// borrow ends before the `&mut self` dispatch below.
|
||||
// Resolve the kind + source index first so the `mermaid_buttons` borrow
|
||||
// ends before the `&mut self` dispatch below.
|
||||
let mermaid_hit = self
|
||||
.inline_media_hits
|
||||
.mermaid_buttons
|
||||
@@ -552,9 +529,6 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Video viewer input --------------------------------------------------
|
||||
|
||||
/// Handle a key event in the video viewer modal.
|
||||
pub(super) fn handle_video_viewer_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
@@ -564,7 +538,7 @@ impl AgentView {
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => {
|
||||
// Clear the Kitty image before closing.
|
||||
// Kitty images outlive the dropped viewer state; clear before close.
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
let clear = PostFlush::from(overlay::clear_kitty());
|
||||
let _ = clear.write_to(stderr);
|
||||
@@ -588,17 +562,13 @@ impl AgentView {
|
||||
InputOutcome::Changed
|
||||
}
|
||||
|
||||
// -- /gboom easter egg input ------------------------------------------------
|
||||
|
||||
/// Handle a key event in the `/gboom` game modal.
|
||||
pub(super) fn handle_gboom_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
let Some(ref mut gboom) = self.gboom else {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
match gboom.handle_key(key) {
|
||||
crate::gboom::GboomKeyOutcome::Close => {
|
||||
// Clear the kitty image before closing (same as the video
|
||||
// viewer) so no stale frame lingers in the cell grid.
|
||||
// Kitty images outlive the dropped game state; clear before close.
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
let clear = PostFlush::from(overlay::clear_kitty());
|
||||
let _ = clear.write_to(stderr);
|
||||
|
||||
@@ -183,10 +183,6 @@ pub(super) fn active_contexts_for_pane(pane: ActivePane) -> Vec<crate::actions::
|
||||
///
|
||||
/// This will grow as we add more panes (tasks, review files, etc.).
|
||||
pub type AgentPane = ActivePane;
|
||||
/// Per-agent view-model.
|
||||
///
|
||||
/// Owns both business state (session, entries) and UI state (scroll,
|
||||
/// selection, pane focus). See module docs for future split plans.
|
||||
/// MCP server initialization progress, received from the shell.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpInitProgress {
|
||||
@@ -254,11 +250,9 @@ impl HitArea {
|
||||
self.hovered = new;
|
||||
changed
|
||||
}
|
||||
/// Check if a position is inside the rect.
|
||||
pub fn contains(&self, col: u16, row: u16) -> bool {
|
||||
self.rect.is_some_and(|r| r.contains((col, row).into()))
|
||||
}
|
||||
/// Set the rect (called during render).
|
||||
pub fn set(&mut self, rect: Option<Rect>) {
|
||||
self.rect = rect;
|
||||
}
|
||||
@@ -266,7 +260,6 @@ impl HitArea {
|
||||
pub fn set_unless_dropdown(&mut self, rect: Option<Rect>, dropdown_open: bool) {
|
||||
self.set(if dropdown_open { None } else { rect });
|
||||
}
|
||||
/// Clear rect and hover.
|
||||
pub fn clear(&mut self) {
|
||||
self.rect = None;
|
||||
self.hovered = false;
|
||||
@@ -641,7 +634,7 @@ pub struct AgentView {
|
||||
/// [`Self::self_originated_prompt_ids`] in the ACP gate / turn-start shim:
|
||||
/// a client that has driven a turn can still go on to VIEW a turn another
|
||||
/// client drives (e.g. a `/loop` cron, or a plain prompt typed in another
|
||||
/// pane), so this flag is no longer a one-way latch.
|
||||
/// pane), so this flag is not a one-way latch.
|
||||
pub attached_as_viewer: bool,
|
||||
/// Prompt ids of turns THIS client originated (sent to the agent as the
|
||||
/// turn driver). The ACP gate consults this to keep `attached_as_viewer`
|
||||
@@ -694,7 +687,6 @@ pub struct AgentView {
|
||||
pub active_pane: AgentPane,
|
||||
/// Current mode of the prompt widget (normal vs editing a queued prompt).
|
||||
pub prompt_mode: PromptMode,
|
||||
/// Current special prompt input mode (Normal/Bash/Feedback/Remember).
|
||||
pub prompt_input_mode: PromptInputMode,
|
||||
/// Multiline input mode: swap Enter (insert newline) and Shift+Enter (send).
|
||||
/// Toggled by `Ctrl+M` or `/multiline`. Not persisted across sessions.
|
||||
@@ -992,9 +984,8 @@ pub struct AgentView {
|
||||
/// so switching between images is a cheap re-place (~80 bytes) instead
|
||||
/// of a full re-transmit. ID 1 is reserved for modal overlays.
|
||||
pub(crate) inline_media_ids: std::collections::HashMap<std::path::PathBuf, u32>,
|
||||
/// Paths whose iTerm2 inline data has already been emitted this placement
|
||||
/// cycle. Avoids re-sending full base64 image data every TUI frame.
|
||||
/// Last iTerm2 placement per path — re-emit when `screen_rect` changes.
|
||||
/// Last iTerm2 placement rect per path. Avoids re-sending full base64 image
|
||||
/// data every TUI frame; re-emitted only when a path's `screen_rect` changes.
|
||||
pub(crate) inline_media_iterm_emitted:
|
||||
std::collections::HashMap<std::path::PathBuf, ratatui::layout::Rect>,
|
||||
/// Counter for allocating the next Kitty image ID.
|
||||
@@ -1176,8 +1167,8 @@ pub struct AgentView {
|
||||
/// cancel falls back to that UI/config field, then the prompt panel.
|
||||
pub(crate) cancel_subagents_preference: Option<bool>,
|
||||
/// What gesture triggered the pending turn-cancel (Ctrl+C / mouse; Esc
|
||||
/// only via the cancel-retry path while TurnCancelling — a bare Esc no
|
||||
/// longer starts a cancel).
|
||||
/// only via the cancel-retry path while TurnCancelling — a bare Esc does
|
||||
/// not start a cancel).
|
||||
/// Set by the key/mouse handler, consumed by `do_cancel_turn` / the
|
||||
/// cancel-retry path so `session/cancel` carries `_meta.cancelTrigger`.
|
||||
pub(crate) cancel_trigger_hint: Option<crate::app::actions::CancelTrigger>,
|
||||
@@ -1364,7 +1355,7 @@ pub struct AgentView {
|
||||
pub(crate) follow_up_seen: HashMap<String, u64>,
|
||||
/// Monotonic generation assigned to the next newly-accepted `response_id`.
|
||||
/// The ordering key for newest-wins: a fresh id takes the next value (the
|
||||
/// new high-water), so every previously-seen id is strictly lower.
|
||||
/// new high-water), so every already-seen id is strictly lower.
|
||||
pub(crate) follow_up_next_gen: u64,
|
||||
/// Stamped `kigi/follow_ups` that arrived for a turn that is NOT yet the
|
||||
/// currently-adopted one, keyed by `promptId`. Ext notifications and
|
||||
|
||||
@@ -10,8 +10,6 @@ use crate::views::file_search::line_viewer::LineViewerState;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
|
||||
impl AgentView {
|
||||
// -- Agents modal input handling --
|
||||
|
||||
pub(super) fn handle_agents_modal_key(
|
||||
&mut self,
|
||||
key: &crossterm::event::KeyEvent,
|
||||
@@ -102,8 +100,6 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Persona detail modal input handling --
|
||||
|
||||
pub(super) fn handle_persona_detail_key(
|
||||
&mut self,
|
||||
key: &crossterm::event::KeyEvent,
|
||||
@@ -156,8 +152,6 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Hooks/plugins modal input handling --
|
||||
|
||||
pub(super) fn handle_extensions_modal_key(
|
||||
&mut self,
|
||||
key: &crossterm::event::KeyEvent,
|
||||
@@ -218,7 +212,6 @@ impl AgentView {
|
||||
};
|
||||
}
|
||||
|
||||
// If in input mode, route to input handler.
|
||||
if self
|
||||
.extensions_modal
|
||||
.as_ref()
|
||||
@@ -366,7 +359,6 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate navigation/search/tab/filter/action to handle_picker_input.
|
||||
let Some(state) = self.extensions_modal.as_mut() else {
|
||||
return InputOutcome::Changed;
|
||||
};
|
||||
@@ -439,8 +431,6 @@ impl AgentView {
|
||||
&config,
|
||||
);
|
||||
|
||||
// Search state now lives directly in picker_state (no sync needed).
|
||||
|
||||
match outcome {
|
||||
crate::views::picker::PickerOutcome::Closed => {
|
||||
self.extensions_modal = None;
|
||||
@@ -472,7 +462,6 @@ impl AgentView {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Reset selection after filter change.
|
||||
state.picker_state.selected = 0;
|
||||
state.picker_state.scroll_offset = None;
|
||||
state.picker_state.tabs_focused = false;
|
||||
@@ -634,7 +623,8 @@ impl AgentView {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None, // Unhandled — fall through to picker
|
||||
// Unhandled — fall through to picker
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
// Dispatch shortcut click (if any) now that the &mut borrow is released.
|
||||
@@ -709,7 +699,8 @@ impl AgentView {
|
||||
crate::views::extensions_modal::ExtensionsTab::McpServers => state.mcps_filter,
|
||||
_ => crate::views::extensions_modal::StatusFilter::All,
|
||||
};
|
||||
let action_keys: Vec<(char, &str)> = vec![]; // No action keys for mouse
|
||||
// No action keys for mouse
|
||||
let action_keys: Vec<(char, &str)> = vec![];
|
||||
let entry_count = state.entry_data_indices.len();
|
||||
let non_selectable_owned = Self::extensions_modal_non_selectable_mask(state, entry_count);
|
||||
let non_selectable = &non_selectable_owned;
|
||||
@@ -904,13 +895,11 @@ impl AgentView {
|
||||
|
||||
if let Some(gk) = group_key {
|
||||
let is_expanded = state.is_group_expanded(sel, &gk);
|
||||
// `set_collapsed`'s third arg is the NEW collapsed state.
|
||||
// When currently expanded → new state is collapsed (true);
|
||||
// when currently collapsed → new state is expanded (false).
|
||||
// That value equals `is_expanded` directly. Using `!is_expanded`
|
||||
// (the previous code) made `e`/Enter/Space/click into a no-op
|
||||
// for every collapsible header (MCP servers and hooks
|
||||
// groups).
|
||||
// `set_collapsed`'s third arg is the NEW collapsed state:
|
||||
// currently expanded → collapse (true); currently collapsed →
|
||||
// expand (false). That value equals `is_expanded` directly.
|
||||
// Passing `!is_expanded` makes `e`/Enter/Space/click a no-op for
|
||||
// every collapsible header (MCP servers and hooks groups).
|
||||
self.extensions_modal_set_collapsed(sel, &gk, is_expanded);
|
||||
} else {
|
||||
// Leaf item: toggle detail fields.
|
||||
@@ -921,7 +910,6 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the collapsed state for a group key in the extensions modal.
|
||||
fn extensions_modal_set_collapsed(
|
||||
&mut self,
|
||||
sel: usize,
|
||||
@@ -1149,7 +1137,6 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
ButtonAction::RemoveSelectedHook => {
|
||||
// Remove the hook source_dir of the currently selected hook.
|
||||
if let Some(ref state) = self.extensions_modal {
|
||||
use crate::views::extensions_modal::TabDataState;
|
||||
if let TabDataState::Loaded(ref data) = state.hooks_data
|
||||
|
||||
@@ -258,7 +258,8 @@ impl AgentView {
|
||||
return true;
|
||||
}
|
||||
*remaining = remaining.saturating_sub(1);
|
||||
return true; // redraw to advance fade
|
||||
// redraw to advance fade
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
@@ -8,10 +8,6 @@ use crate::key;
|
||||
use crate::scrollback::ScrollbackSearchState;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
|
||||
impl AgentView {
|
||||
/// Scrollback-focused key handling.
|
||||
///
|
||||
/// When the block viewer is open, routes keys to the viewer.
|
||||
/// Otherwise, uses ActionRegistry for keybinding lookup.
|
||||
pub(super) fn handle_scrollback_key(
|
||||
&mut self,
|
||||
key: &KeyEvent,
|
||||
@@ -248,7 +244,6 @@ impl AgentView {
|
||||
}
|
||||
changed
|
||||
}
|
||||
/// Scroll the current search match into view via `reveal_entry_line`.
|
||||
fn reveal_current_search_match(&mut self) {
|
||||
let target = self
|
||||
.scrollback_search
|
||||
@@ -261,10 +256,6 @@ impl AgentView {
|
||||
self.scrollback.reveal_entry_line(idx, line);
|
||||
}
|
||||
}
|
||||
/// Todo-pane-focused key handling.
|
||||
///
|
||||
/// Routes structural keys through the shared overlay handler, then
|
||||
/// content keys through `TodoPane::handle_key`.
|
||||
pub(super) fn handle_todo_key(
|
||||
&mut self,
|
||||
key: &KeyEvent,
|
||||
@@ -300,7 +291,6 @@ impl AgentView {
|
||||
InputOutcome::Unchanged
|
||||
}
|
||||
}
|
||||
/// Bg-task-pane-focused key handling.
|
||||
pub(super) fn handle_bg_tasks_key(
|
||||
&mut self,
|
||||
key: &KeyEvent,
|
||||
@@ -431,7 +421,6 @@ impl AgentView {
|
||||
InputOutcome::Unchanged
|
||||
}
|
||||
}
|
||||
/// Subagent-pane-focused key handling.
|
||||
pub(super) fn handle_catalog_key(
|
||||
&mut self,
|
||||
key: &KeyEvent,
|
||||
|
||||
@@ -263,10 +263,6 @@ impl AgentView {
|
||||
crate::wrap_clipboard_image::WrapImagePaste::NoImage => InputOutcome::Unchanged,
|
||||
})
|
||||
}
|
||||
/// Parse a paste payload as one or more drop-style file paths and
|
||||
/// route each entry: image paths become `[Image #N]` chips, non-image
|
||||
/// paths get inserted as decoded absolute path text.
|
||||
///
|
||||
/// Route a popup pane's `Event::Paste(text)` through the drop
|
||||
/// classifier and fall back to a plain text paste into the shared
|
||||
/// prompt buffer. Used by the plan-feedback, permission-followup,
|
||||
@@ -297,16 +293,9 @@ impl AgentView {
|
||||
/// `"file://{png} file://{txt}"` → `[Image #N] {canon_txt} `;
|
||||
/// `"file://{txt} file://{png}"` → `{canon_txt} [Image #N] `.
|
||||
///
|
||||
/// **Size guard**: payloads ≥ `DROP_CLASSIFIER_MAX_BYTES`
|
||||
/// short-circuit to `None`. The early-return lives inside this
|
||||
/// function (not at each call site) so every paste arm — the
|
||||
/// main Prompt bracketed-paste arm, the four popup `Event::Paste`
|
||||
/// arms (plan-feedback, permission-followup, plan-approval,
|
||||
/// question-view), and the Cmd+V `handle_paste_key_deferred` path
|
||||
/// (clipboard-text, plus deferred file-urls on completion) — gets the
|
||||
/// guard uniformly. Real drag-and-drop payloads (one or more
|
||||
/// `file://` URLs) are at most a few KB; anything ≥ 10 MB is a
|
||||
/// log/code paste and not worth iterating line-by-line.
|
||||
/// **Size guard**: payloads ≥ `DROP_CLASSIFIER_MAX_BYTES` short-circuit
|
||||
/// to `None`. The early-return lives here, not at each call site, so every
|
||||
/// paste arm gets the guard uniformly.
|
||||
pub(super) fn try_handle_dropped_paths_paste(
|
||||
&mut self,
|
||||
text: &str,
|
||||
@@ -1544,10 +1533,9 @@ pub(super) mod paste_key_tests {
|
||||
assert!(agent.ephemeral_tip.is_active());
|
||||
assert_eq!(counts.get("t_seen"), Some(&1));
|
||||
}
|
||||
/// `note_terminal_size` keeps the draw-path semantics it replaced:
|
||||
/// Kitty IDs are invalidated only on an actual size change, the
|
||||
/// `(0, 0)` pre-first-draw state never counts as a resize, and every
|
||||
/// re-measure clears the resize-event staleness flag.
|
||||
/// `note_terminal_size` invariants: Kitty IDs are invalidated only on an
|
||||
/// actual size change, the `(0, 0)` pre-first-draw state never counts as a
|
||||
/// resize, and every re-measure clears the resize-event staleness flag.
|
||||
#[test]
|
||||
fn note_terminal_size_invalidates_kitty_ids_only_on_change() {
|
||||
let mut agent = make_agent();
|
||||
@@ -1881,8 +1869,8 @@ pub(super) mod paste_key_tests {
|
||||
);
|
||||
assert_eq!(embedded.items.width, prompt.width);
|
||||
}
|
||||
/// The tool-media inline-image path (`build_inline_media_escapes`, still live
|
||||
/// for tool calls) transmits the bytes (`a=t`) before placing them (`a=p`) on
|
||||
/// The tool-media inline-image path (`build_inline_media_escapes`, used for
|
||||
/// tool calls) transmits the bytes (`a=t`) before placing them (`a=p`) on
|
||||
/// the first paint, then places only (no re-transmit) on a later frame for
|
||||
/// the same path — a place-without-transmit would render blank.
|
||||
#[test]
|
||||
@@ -1978,7 +1966,6 @@ pub(super) mod paste_key_tests {
|
||||
assert!(agent.inline_video.is_none());
|
||||
assert!(agent.take_inline_media_clear_escapes().is_none());
|
||||
}
|
||||
/// An agent with no placements has nothing to clear.
|
||||
#[test]
|
||||
fn take_inline_media_clear_escapes_none_when_no_placements() {
|
||||
let mut agent = make_agent();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Plan surfaces: plan chip/preview, plan approval + feedback, and casual
|
||||
//! plan commenting (incl. the casual-commenting test fixture).
|
||||
//! plan commenting.
|
||||
use super::AgentView;
|
||||
#[cfg(test)]
|
||||
use super::{ActivePane, InputMode, test_fixtures};
|
||||
@@ -15,7 +15,6 @@ use crate::views::prompt_widget::{EnterOutcome, PromptEvent};
|
||||
use crossterm::event::KeyModifiers;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
impl AgentView {
|
||||
/// Resolve the absolute path to the plan file for this session.
|
||||
fn plan_file_path(&self) -> Option<std::path::PathBuf> {
|
||||
let session_id = self.session.session_id.as_ref()?;
|
||||
let cwd_str = self.session.cwd.to_string_lossy().into_owned();
|
||||
@@ -28,38 +27,31 @@ impl AgentView {
|
||||
.join("plan.md"),
|
||||
)
|
||||
}
|
||||
/// Whether the current line viewer is showing a plan preview.
|
||||
pub(super) fn is_plan_viewer(&self) -> bool {
|
||||
self.line_viewer.as_ref().is_some_and(|v| {
|
||||
v.kind == crate::views::file_search::line_viewer::LineViewerKind::PlanPreview
|
||||
})
|
||||
}
|
||||
/// Whether the user is currently composing a comment via the prompt
|
||||
/// input inside the *casual* plan preview (the modal opened with no
|
||||
/// `plan_approval_view`). Mirrors the `pav.focus == Commenting`
|
||||
/// check used by the plan-approval path so the prompt/footer
|
||||
/// behaves identically across both modes.
|
||||
/// True while the user composes a comment in the *casual* plan preview
|
||||
/// (the modal opened with no `plan_approval_view`). Mirrors the
|
||||
/// `pav.focus == Commenting` check of the plan-approval path so the
|
||||
/// prompt/footer behave identically across both modes.
|
||||
pub(super) fn is_casual_commenting(&self) -> bool {
|
||||
self.plan_approval_view.is_none()
|
||||
&& self.is_plan_viewer()
|
||||
&& self.casual_commenting_range.is_some()
|
||||
}
|
||||
/// Whether the prompt "auto" (LLM classifier mode) flag should render.
|
||||
/// Extracted for unit testing the precedence: auto shows only when the
|
||||
/// session is in auto mode and neither yolo (always-approve wins) nor plan
|
||||
/// is active.
|
||||
/// Whether the prompt "auto" (LLM classifier mode) flag should render:
|
||||
/// only when the session is in auto mode and neither yolo (always-approve
|
||||
/// wins) nor plan is active.
|
||||
pub(super) fn auto_flag_visible(&self, effective_plan: bool) -> bool {
|
||||
self.session.is_auto() && !self.session.is_yolo() && !effective_plan
|
||||
}
|
||||
/// Whether plan content is available for preview.
|
||||
fn plan_preview_available(&self) -> bool {
|
||||
self.plan_body_for_preview().is_some()
|
||||
}
|
||||
/// Whether the "plan" status-bar chip should be rendered.
|
||||
///
|
||||
/// Visible while plan mode is active, or always when the user has set
|
||||
/// `show_plan_chip = true` in `pager.toml`. Hidden by default once the
|
||||
/// user exits plan mode.
|
||||
/// Visible while plan mode is active, or always when `show_plan_chip` is
|
||||
/// set in `pager.toml`; hidden by default once the user exits plan mode.
|
||||
pub(super) fn should_show_plan_chip(
|
||||
&self,
|
||||
appearance: &crate::appearance::AppearanceConfig,
|
||||
@@ -73,11 +65,9 @@ impl AgentView {
|
||||
.and_then(|p| p.plan_content.as_deref())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
/// Resolve the plan body for the line-viewer preview.
|
||||
///
|
||||
/// Prefers content carried on the approval request (inline plan-creation or
|
||||
/// the shell-read file body), then falls back to the on-disk plan file.
|
||||
/// Request body first keeps file-backed previews working when the path
|
||||
/// Request body first keeps file-backed previews working when path
|
||||
/// resolution fails or the file disappears between intercept and open.
|
||||
fn plan_body_for_preview(&self) -> Option<String> {
|
||||
if let Some(content) = self
|
||||
@@ -106,8 +96,6 @@ impl AgentView {
|
||||
self.show_plan_preview();
|
||||
}
|
||||
}
|
||||
/// Show the plan in the line viewer overlay or a "no plan" toast.
|
||||
///
|
||||
/// When plan approval is parked without a body, opens a placeholder
|
||||
/// preview so the user always sees a decision surface (a/s/q) instead of
|
||||
/// a dead "Waiting on plan approval" line with a no-op Tab:plan.
|
||||
@@ -154,14 +142,10 @@ impl AgentView {
|
||||
}
|
||||
self.line_viewer = Some(viewer);
|
||||
}
|
||||
/// Test fixture: drive the agent into casual-commenting state
|
||||
/// (line viewer open in plan-preview mode + `casual_commenting_range`
|
||||
/// armed) so the `Event::Paste` plan-feedback arm at ~1539 is
|
||||
/// reachable from a unit test without spawning the real
|
||||
/// keystroke pipeline. Consolidates three field mutations into
|
||||
/// one helper so a future refactor of casual-commenting state
|
||||
/// only has to update this fixture rather than every test that
|
||||
/// reaches into the fields by name.
|
||||
/// Drive the agent into casual-commenting state (line viewer open in
|
||||
/// plan-preview mode + `casual_commenting_range` armed) so the paste
|
||||
/// plan-feedback arm is reachable from a unit test without the real
|
||||
/// keystroke pipeline.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn enter_casual_commenting_for_test(&mut self) {
|
||||
let mut viewer =
|
||||
@@ -266,10 +250,8 @@ impl AgentView {
|
||||
viewer.plan_mut().feedback_active = true;
|
||||
}
|
||||
}
|
||||
/// Discard an in-progress comment draft: clear the prompt text and
|
||||
/// drop the selected line range + pending edit + stashed feedback.
|
||||
/// Used whenever focus leaves the prompt without an explicit save
|
||||
/// or cancel (e.g. Tab back to Preview, click into the modal).
|
||||
/// Called whenever focus leaves the prompt without an explicit save or
|
||||
/// cancel (e.g. Tab back to Preview, click into the modal).
|
||||
fn discard_in_progress_comment(&mut self) {
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
pav.commenting_range = None;
|
||||
@@ -484,11 +466,8 @@ impl AgentView {
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
/// Enter casual commenting mode from the plan preview.
|
||||
///
|
||||
/// If the cursor is on a comment line, enter edit mode for that comment.
|
||||
/// If the cursor is on a source line, capture the line range and enter
|
||||
/// new-comment mode.
|
||||
/// If the cursor is on a comment line, edit that comment; if on a source
|
||||
/// line, capture the line range and start a new comment.
|
||||
pub(super) fn enter_casual_plan_commenting(&mut self) -> InputOutcome {
|
||||
let viewer = match self.line_viewer.as_mut() {
|
||||
Some(v) => v,
|
||||
@@ -535,7 +514,6 @@ impl AgentView {
|
||||
self.prompt.set_text("");
|
||||
InputOutcome::Changed
|
||||
}
|
||||
/// Save the current casual comment (new or edited) and rebuild the viewer.
|
||||
pub(super) fn save_casual_plan_comment(&mut self) -> InputOutcome {
|
||||
let text = self.prompt.text().to_owned();
|
||||
if text.trim().is_empty() {
|
||||
@@ -570,7 +548,6 @@ impl AgentView {
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
/// Cancel casual plan commenting without saving.
|
||||
pub(super) fn cancel_casual_plan_commenting(&mut self) -> InputOutcome {
|
||||
self.casual_commenting_range = None;
|
||||
self.casual_editing_comment_id = None;
|
||||
@@ -581,11 +558,9 @@ impl AgentView {
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
/// Key handler used while the user is composing a casual plan
|
||||
/// comment via the prompt input. Mirrors `handle_plan_feedback_key`
|
||||
/// (which serves the plan-approval Commenting focus) so the UX is
|
||||
/// identical: Enter saves, Esc cancels, Tab cancels back to the
|
||||
/// modal, and everything else routes to the prompt textarea.
|
||||
/// Mirrors `handle_plan_feedback_key` (the plan-approval Commenting focus)
|
||||
/// so the casual-commenting UX is identical: Enter saves, Esc cancels, Tab
|
||||
/// cancels back to the modal, everything else routes to the prompt textarea.
|
||||
pub(super) fn handle_casual_plan_feedback_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
if key.code == KeyCode::Esc {
|
||||
if self.prompt.file_search_visible() {
|
||||
@@ -612,7 +587,6 @@ impl AgentView {
|
||||
PromptEvent::Ignored => InputOutcome::Changed,
|
||||
}
|
||||
}
|
||||
/// Delete the casual comment under the cursor in the plan preview.
|
||||
pub(super) fn delete_casual_plan_comment_at_cursor(&mut self) -> InputOutcome {
|
||||
let viewer = match self.line_viewer.as_ref() {
|
||||
Some(v) => v,
|
||||
@@ -658,8 +632,6 @@ impl AgentView {
|
||||
#[cfg(test)]
|
||||
mod prompt_flag_tests {
|
||||
use super::test_fixtures::make_agent;
|
||||
/// The prompt "auto" (classifier) mode flag shows only when the session is
|
||||
/// in Auto and neither yolo (always-approve wins) nor plan is active.
|
||||
#[test]
|
||||
fn auto_flag_visible_precedence() {
|
||||
let mut agent = make_agent();
|
||||
|
||||
@@ -60,15 +60,6 @@ impl AgentView {
|
||||
history
|
||||
}
|
||||
|
||||
/// Prompt-focused key handling.
|
||||
///
|
||||
/// Routes through the action registry FIRST for mapped actions (SendPrompt,
|
||||
/// FocusScrollback, etc.), then falls through to the widget for text editing.
|
||||
/// Agent-level and global actions are handled by the caller after this
|
||||
/// returns Unchanged.
|
||||
///
|
||||
/// **Exception**: when the file search dropdown is visible, the widget gets
|
||||
/// first shot at Tab/Enter/Esc/arrows (for navigation and acceptance).
|
||||
/// Test-only wrapper around the private `handle_prompt_key` using a
|
||||
/// **non–VS Code** pinned registry so host `TERM_PROGRAM` cannot change
|
||||
/// InterjectPrompt / OpenExtensions chords under test.
|
||||
@@ -89,6 +80,15 @@ impl AgentView {
|
||||
self.handle_prompt_key(key, registry, false)
|
||||
}
|
||||
|
||||
/// Prompt-focused key handling.
|
||||
///
|
||||
/// Routes through the action registry FIRST for mapped actions (SendPrompt,
|
||||
/// FocusScrollback, etc.), then falls through to the widget for text editing.
|
||||
/// Agent-level and global actions are handled by the caller after this
|
||||
/// returns Unchanged.
|
||||
///
|
||||
/// **Exception**: when the file search dropdown is visible, the widget gets
|
||||
/// first shot at Tab/Enter/Esc/arrows (for navigation and acceptance).
|
||||
// `pub(super)`: also called by `AppView::minimal_key_intercept` to route
|
||||
// Apple Terminal's Ctrl+O interject chord straight to the prompt path —
|
||||
// minimal's prompt is conceptually always focused, but `active_pane` can be
|
||||
@@ -110,7 +110,7 @@ impl AgentView {
|
||||
// focus back from the /btw panel (its scroll keys are consumed earlier).
|
||||
self.btw_focused = false;
|
||||
|
||||
// ── History panel intercept (modal) ─────────────────────────────
|
||||
// History panel intercept (modal).
|
||||
// Must run before the file-search / slash intercepts: a populated
|
||||
// entry can end on an `@` token (or start with `/`), and the
|
||||
// dropdown state derived from that text would otherwise steal the
|
||||
@@ -119,7 +119,7 @@ impl AgentView {
|
||||
return self.handle_history_search_key(key);
|
||||
}
|
||||
|
||||
// ── File search intercept ───────────────────────────────────────
|
||||
// File search intercept.
|
||||
// When the @-completion dropdown is visible, the widget handles
|
||||
// Tab (accept), Enter (accept), Esc (dismiss), and arrow keys
|
||||
// BEFORE the action registry gets them. Otherwise Tab would
|
||||
@@ -127,7 +127,6 @@ impl AgentView {
|
||||
if self.prompt.file_search_visible() {
|
||||
match self.prompt.handle_key(key) {
|
||||
PromptEvent::Edited => {
|
||||
// Check if the prompt wants to open a line viewer.
|
||||
if let Some(req) = self.prompt.pending_viewer_request.take() {
|
||||
self.open_line_viewer(&req.path, req.initial_range);
|
||||
}
|
||||
@@ -137,11 +136,12 @@ impl AgentView {
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
PromptEvent::Ignored => {} // fall through to normal routing
|
||||
// Fall through to normal routing.
|
||||
PromptEvent::Ignored => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slash dropdown intercept ────────────────────────────────────
|
||||
// Slash dropdown intercept.
|
||||
// When the slash completion dropdown is open, intercept navigation
|
||||
// and accept keys BEFORE the action registry. Completion changes
|
||||
// text only — it does NOT execute commands.
|
||||
@@ -165,13 +165,11 @@ impl AgentView {
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
match key.code {
|
||||
// Up / Ctrl-P: move selection up.
|
||||
KeyCode::Up => {
|
||||
self.prompt.slash_move_selection(-1);
|
||||
self.prompt.slash_preview_current_selection();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// Down / Ctrl-N: move selection down.
|
||||
KeyCode::Down => {
|
||||
self.prompt.slash_move_selection(1);
|
||||
self.prompt.slash_preview_current_selection();
|
||||
@@ -187,13 +185,11 @@ impl AgentView {
|
||||
self.prompt.slash_preview_current_selection();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// Tab: accept completion (text only, no execute).
|
||||
KeyCode::Tab => {
|
||||
self.prompt.slash_commit_preview();
|
||||
self.prompt.accept_slash_completion(&self.session.models);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// Esc: close dropdown, revert any live preview.
|
||||
KeyCode::Esc => {
|
||||
self.prompt.slash_cancel_preview();
|
||||
self.prompt.slash_close();
|
||||
@@ -253,7 +249,7 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Completion dropdown intercept ────────────────────────────────
|
||||
// Completion dropdown intercept.
|
||||
// Priority 4-5 in the Tab chain: when the completion dropdown is
|
||||
// open, handle navigation/accept/dismiss. When closed, Tab in bash
|
||||
// mode is terminal-like completion (always on, no env gate).
|
||||
@@ -348,7 +344,7 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Predicted-next-prompt ghost (tab autocomplete) ──────────────
|
||||
// Predicted-next-prompt ghost (tab autocomplete).
|
||||
// Tab or Right arrow accepts the suggestion (the ghost only renders
|
||||
// with the cursor at end-of-text, where Right is otherwise a no-op —
|
||||
// same convention as fish/zsh autosuggestions); Esc on an empty
|
||||
@@ -867,9 +863,8 @@ impl AgentView {
|
||||
.map(str::to_owned)
|
||||
{
|
||||
self.prompt.history_search.deactivate();
|
||||
// Detect `! ` prefix to restore bash mode. Refined: only reset to Normal
|
||||
// if currently in Bash (preserve Remember if active). The ! prefix
|
||||
// restore only applies when not in Remember.
|
||||
// A `! ` entry restores Bash mode, except in Remember mode,
|
||||
// which is preserved. A non-`!` entry in Bash resets to Normal.
|
||||
if self.prompt_input_mode != PromptInputMode::Remember
|
||||
&& let Some(cmd) = text.strip_prefix("! ")
|
||||
{
|
||||
@@ -881,7 +876,6 @@ impl AgentView {
|
||||
} else {
|
||||
self.prompt.set_text(&text);
|
||||
}
|
||||
// Move cursor to end of text.
|
||||
let len = self.prompt.textarea.text().len();
|
||||
self.prompt.textarea.set_cursor(len);
|
||||
// Drop the recomputed `@`-completion context (same
|
||||
@@ -1122,7 +1116,7 @@ mod combined_prompt_history_tests {
|
||||
}
|
||||
|
||||
/// THIS SESSION's prompts (scrollback blocks) outrank the fetched
|
||||
/// fetched history: the fetch races the shell-side append of a
|
||||
/// history: the fetch races the shell-side append of a
|
||||
/// fresh session's first prompts, so scrollback is the authoritative
|
||||
/// "newest" source and a just-sent prompt is always recalled first.
|
||||
#[test]
|
||||
@@ -1142,9 +1136,12 @@ mod combined_prompt_history_tests {
|
||||
assert_eq!(
|
||||
texts(&agent),
|
||||
[
|
||||
"just sent", // this session, newest first
|
||||
"ten", // this session (fetched dup ignored)
|
||||
"seventeen", // fetched history follows
|
||||
// this session, newest first
|
||||
"just sent",
|
||||
// this session (fetched dup ignored)
|
||||
"ten",
|
||||
// fetched history follows
|
||||
"seventeen",
|
||||
"sixteen",
|
||||
"fifteen",
|
||||
]
|
||||
@@ -1169,7 +1166,8 @@ mod combined_prompt_history_tests {
|
||||
texts(&agent),
|
||||
[
|
||||
"scrollback_only_new",
|
||||
" shared ", // trim-keyed dedup: scrollback variant wins
|
||||
// trim-keyed dedup: scrollback variant wins
|
||||
" shared ",
|
||||
"scrollback_only_old",
|
||||
"fetched_only",
|
||||
]
|
||||
|
||||
@@ -254,7 +254,6 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The transcript tail is a user-authored prompt row.
|
||||
fn tail_is_user_prompt(&self) -> bool {
|
||||
matches!(
|
||||
self.scrollback.last().map(|entry| &entry.block),
|
||||
@@ -973,8 +972,8 @@ mod queue_edit_routing_tests {
|
||||
}
|
||||
|
||||
/// Keyboard-deleting the last *local* row while a server row remains keeps
|
||||
/// the pane open and focused (regression: it previously force-hid the pane
|
||||
/// and stranded the server rows).
|
||||
/// the pane open and focused (regression guard: force-hiding it here would
|
||||
/// strand the server rows).
|
||||
#[test]
|
||||
fn delete_last_local_row_keeps_pane_open_when_server_remains() {
|
||||
let mut agent = make_running_agent();
|
||||
|
||||
@@ -117,8 +117,7 @@ impl AgentView {
|
||||
///
|
||||
/// Known transient: when a subagent is fullscreen (`active_subagent.is_some()`),
|
||||
/// draw returns early and the child renders its own bar; Current on the parent
|
||||
/// still reflects parent context (documented limitation, pre-existing before
|
||||
/// this change).
|
||||
/// still reflects parent context (documented limitation).
|
||||
pub fn current_shortcut_hints(&self, registry: &ActionRegistry) -> Vec<HintItem> {
|
||||
use crate::views::shortcuts_bar::HintItem;
|
||||
if let Some(ref viewer) = self.block_viewer {
|
||||
@@ -221,8 +220,7 @@ impl AgentView {
|
||||
self.normal_pane_hints(registry)
|
||||
}
|
||||
}
|
||||
/// Shared "normal pane" hints: flag computation + `build_hints` + queue hint.
|
||||
/// Single source of truth for the two former duplicated blocks in
|
||||
/// Shared "normal pane" hints. Single source of truth for
|
||||
/// `current_shortcut_hints` and `draw`.
|
||||
fn normal_pane_hints(&self, registry: &ActionRegistry) -> Vec<HintItem> {
|
||||
let fold_label = self.selected_fold_label();
|
||||
@@ -366,13 +364,6 @@ impl AgentView {
|
||||
}
|
||||
hints
|
||||
}
|
||||
/// Render the agent view into the given area.
|
||||
///
|
||||
/// Thin orchestrator: computes layout, then calls shared widgets and
|
||||
/// agent-specific overlay helpers in sequence. Each component takes
|
||||
/// only the state it needs — no arg threading.
|
||||
///
|
||||
/// Returns cursor position if the prompt is focused (for terminal cursor).
|
||||
/// Render a fullscreen subagent view — replaces the ENTIRE parent view.
|
||||
///
|
||||
/// Draws:
|
||||
|
||||
@@ -137,10 +137,6 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
/// Mouse handler for the rewind overlay. `Moved` moves the cursor
|
||||
/// (`selected` for picker, `active_idx` for radio phases) and syncs
|
||||
/// the scrollback preview on the picker. `Down(Left)` either
|
||||
/// dispatches a synthesized key (radio) or `PickerSelect` (picker).
|
||||
/// Mouse handler for the rewind overlay. `Moved` moves the cursor
|
||||
/// to the row under the pointer; `Down(Left)` moves the cursor then
|
||||
/// activates that row (Enter-equivalent). Geometry comes from
|
||||
/// `rewind_row_at`, which mirrors `render_rewind_overlay`'s layout.
|
||||
|
||||
@@ -165,7 +165,6 @@ impl AgentView {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Scroll the active scrollback.
|
||||
let scrollback = if let Some(ref child_id) = self.active_subagent {
|
||||
if let Some(child) = self.subagent_views.get_mut(child_id) {
|
||||
&mut child.scrollback
|
||||
@@ -826,7 +825,6 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
|
||||
// Find the content width from the resolved model's visible block geometry.
|
||||
let content_width = self
|
||||
.last_scrollback_selection_model
|
||||
.visible_block_content_width(idx)
|
||||
@@ -998,7 +996,7 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
2 if is_prompt => {
|
||||
// Edit in place; bash/cron keep the old fold behavior.
|
||||
// Edit in place; bash/cron fall back to fold + scroll-to-top.
|
||||
if !self.enter_inline_edit(idx) {
|
||||
if foldable {
|
||||
self.scrollback.toggle_fold_selected();
|
||||
@@ -1679,9 +1677,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// reclamp_drag_head_post_render tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn mouse_down(col: u16, row: u16) -> MouseEvent {
|
||||
MouseEvent {
|
||||
@@ -1882,9 +1878,7 @@ mod tests {
|
||||
assert_eq!(drag.head.block_line_idx, 3, "btw rebuild moves the head");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// anchor_content_width snapshot tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// The linear copy resolves the anchor entry's lines with the drag-start
|
||||
/// width snapshot when the block is gone from `visible_blocks` (scrolled
|
||||
@@ -1967,7 +1961,7 @@ mod tests {
|
||||
|
||||
/// Resolver miss at promotion (the anchor's range vanished from the
|
||||
/// frame between press and threshold): the head collapses to the
|
||||
/// anchor — the live successor of the deleted clamp-to-anchor helper.
|
||||
/// anchor.
|
||||
#[test]
|
||||
fn promotion_miss_collapses_head_to_anchor() {
|
||||
let mut agent = make_agent();
|
||||
@@ -1987,8 +1981,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Resolver miss mid-drag (the range scrolled fully out): the head
|
||||
/// keeps its previous position instead of jumping — the live successor
|
||||
/// of the deleted keep-previous-head helper.
|
||||
/// keeps its previous position instead of jumping.
|
||||
#[test]
|
||||
fn active_drag_motion_miss_keeps_previous_head() {
|
||||
let mut agent = make_agent();
|
||||
@@ -2131,9 +2124,7 @@ mod tests {
|
||||
assert!(agent.reconstruct_drag_copy(&no_snapshot).is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// deferred text-press (anchor on entry into text) tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn mouse_up(col: u16, row: u16) -> MouseEvent {
|
||||
MouseEvent {
|
||||
@@ -2686,9 +2677,7 @@ mod tests {
|
||||
assert!(agent2.deferred_text_press.is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// drag-autoscroll bounce tests (tick + reclamp interplay)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Agent over real scrollback content taller than its viewport (30
|
||||
/// one-line messages through the real layout; pane rows 0-9, prompt at
|
||||
|
||||
@@ -53,9 +53,6 @@ impl AgentView {
|
||||
.iter()
|
||||
.any(|p| p == prompt_id)
|
||||
}
|
||||
/// Create a new agent view with default UI state.
|
||||
///
|
||||
/// The prompt widget is initialized with the session's working directory.
|
||||
pub fn new(session: AgentSession, scrollback: ScrollbackState) -> Self {
|
||||
let prompt = PromptWidget::new_with_cwd(&session.cwd);
|
||||
let mut view = Self {
|
||||
@@ -537,8 +534,8 @@ impl AgentView {
|
||||
}
|
||||
/// Effective turn elapsed time, excluding time spent in question views.
|
||||
///
|
||||
/// Subtracts both the accumulated `turn_paused_duration` (from previously
|
||||
/// closed question views) and the time elapsed since the current question
|
||||
/// Subtracts both the accumulated `turn_paused_duration` (from question
|
||||
/// views closed earlier in the turn) and the time elapsed since the current question
|
||||
/// view opened (if one is active).
|
||||
pub fn turn_elapsed(&self) -> Option<std::time::Duration> {
|
||||
let raw = self.turn_started_at?.elapsed();
|
||||
@@ -863,8 +860,6 @@ mod resolve_turn_activity_tests {
|
||||
Some(TurnActivity::AutoCompacting)
|
||||
);
|
||||
}
|
||||
/// When waiting on task output, the spinner subject is the bg task's
|
||||
/// description (preferred over the raw command).
|
||||
#[test]
|
||||
fn task_output_wait_uses_bg_task_description() {
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
@@ -937,7 +932,6 @@ mod resolve_turn_activity_tests {
|
||||
};
|
||||
assert_eq!(reason.label(), "run release tests…");
|
||||
}
|
||||
/// Without a description, a short command is used as the subject.
|
||||
#[test]
|
||||
fn task_output_wait_falls_back_to_short_command() {
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
@@ -991,7 +985,6 @@ mod resolve_turn_activity_tests {
|
||||
};
|
||||
assert_eq!(reason.label(), "sleep 30…");
|
||||
}
|
||||
/// Multi-id waits use full task_ids.len() for "+ N more", not just resolved count.
|
||||
#[test]
|
||||
fn task_output_wait_multi_id_uses_full_task_count() {
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
@@ -1053,7 +1046,6 @@ mod resolve_turn_activity_tests {
|
||||
"N more is based on full task_ids length, not resolved count"
|
||||
);
|
||||
}
|
||||
/// Long first subjects still keep the multi-task suffix after clamping.
|
||||
#[test]
|
||||
fn task_output_wait_multi_id_preserves_suffix_when_first_is_long() {
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
@@ -1198,7 +1190,6 @@ mod resolve_turn_activity_tests {
|
||||
};
|
||||
assert_eq!(reason.label(), "explore the auth module…");
|
||||
}
|
||||
/// Long bare commands are not used as subjects — keep the original label.
|
||||
#[test]
|
||||
fn task_output_wait_long_command_keeps_generic_label() {
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
|
||||
@@ -333,8 +333,6 @@ mod shell_suggestion_key_tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- always-on Tab fetch (no KIGI_SUGGESTIONS) --------------------------
|
||||
|
||||
/// Tab in bash mode with no fetched candidates fires a deterministic
|
||||
/// fetch — no env flag, no AI, dropdown-scale limit.
|
||||
#[test]
|
||||
@@ -432,8 +430,6 @@ mod shell_suggestion_key_tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- terminal-like Tab (single-candidate accept / common-prefix fill) --
|
||||
|
||||
/// Exactly one token candidate: Tab accepts it immediately — no
|
||||
/// dropdown flash — and the accept re-fetch keeps the pipeline alive.
|
||||
#[test]
|
||||
@@ -652,9 +648,9 @@ mod shell_suggestion_key_tests {
|
||||
.count()
|
||||
}
|
||||
|
||||
/// BugBot: a Fill whose range clips a paste chip used to no-op the
|
||||
/// write and STILL kick a refetch — every Tab spun fill+refetch with no
|
||||
/// draft change. The declined fill now degrades to opening the
|
||||
/// BugBot: a Fill whose range clips a paste chip no-ops the write yet
|
||||
/// would still kick a refetch — every Tab would spin fill+refetch with
|
||||
/// no draft change. The declined fill degrades to opening the
|
||||
/// dropdown: candidates visible, nothing fetched, chip intact, and the
|
||||
/// second Tab rides the normal open-dropdown handling.
|
||||
#[test]
|
||||
@@ -705,11 +701,11 @@ mod shell_suggestion_key_tests {
|
||||
}
|
||||
|
||||
/// BugBot sibling hole: the OPEN-dropdown accept (Tab/Enter/mouse all
|
||||
/// share the helper) used to consume the candidates and close before
|
||||
/// the write path declined the chip-clipping splice — leaving nothing.
|
||||
/// The probe now makes it an honest no-op: nothing consumed, dropdown
|
||||
/// up, chip/draft/generation untouched, no kick — and Enter must not
|
||||
/// fall through to send.
|
||||
/// share the helper) would consume the candidates and close before the
|
||||
/// write path declined the chip-clipping splice — leaving nothing. The
|
||||
/// probe makes it an honest no-op: nothing consumed, dropdown up,
|
||||
/// chip/draft/generation untouched, no kick — and Enter must not fall
|
||||
/// through to send.
|
||||
#[test]
|
||||
fn dropdown_accept_clipping_paste_chip_keeps_candidates() {
|
||||
let (mut agent, text) = chip_agent(vec![
|
||||
@@ -796,8 +792,6 @@ mod shell_suggestion_key_tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Bash-mode gating of the as-you-type pipeline ------------------------
|
||||
|
||||
/// Typing in the normal (chat) prompt never fires the suggest pipeline;
|
||||
/// the same keystroke in bash mode debounces a request.
|
||||
#[test]
|
||||
|
||||
@@ -17,22 +17,18 @@ use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
|
||||
impl AgentView {
|
||||
// ── Line viewer methods ────────────────────────────────────────────
|
||||
|
||||
/// Open the line viewer for a file path with optional initial line range.
|
||||
pub(in crate::app) fn open_line_viewer(
|
||||
&mut self,
|
||||
path: &std::path::Path,
|
||||
initial_range: Option<std::ops::Range<usize>>,
|
||||
) {
|
||||
// Resolve path relative to cwd.
|
||||
let full_path = if path.is_relative() {
|
||||
self.session.cwd.join(path)
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
|
||||
// Get the element ID of the last file ref element (just created).
|
||||
// The last file-ref element is the one the caller just created.
|
||||
let element_id = self
|
||||
.prompt
|
||||
.textarea
|
||||
@@ -43,7 +39,6 @@ impl AgentView {
|
||||
.map(|e| e.id);
|
||||
|
||||
if let Some(mut viewer) = LineViewerState::open(&full_path, element_id) {
|
||||
// If we have an initial line range, scroll to it and select.
|
||||
if let Some(range) = initial_range {
|
||||
viewer.set_initial_selection(range);
|
||||
}
|
||||
@@ -54,7 +49,6 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key event while the line viewer is open.
|
||||
pub(super) fn handle_line_viewer_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
let in_plan_approval = self.plan_approval_view.is_some();
|
||||
|
||||
@@ -110,7 +104,6 @@ impl AgentView {
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
|
||||
// Ctrl+F: toggle fullscreen.
|
||||
if key.code == KeyCode::Char('f') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if let Some(ref mut viewer) = self.line_viewer {
|
||||
viewer.fullscreen = !viewer.fullscreen;
|
||||
@@ -185,7 +178,6 @@ impl AgentView {
|
||||
self.confirm_line_viewer(false);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// y: copy selected line(s) to system clipboard.
|
||||
if key!('y').matches(key) {
|
||||
if let Some(ref viewer) = self.line_viewer {
|
||||
let text = if viewer.list_state.visual_mode {
|
||||
@@ -219,7 +211,6 @@ impl AgentView {
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// Y: copy filename to clipboard.
|
||||
if key!('Y').matches(key) {
|
||||
if let Some(ref viewer) = self.line_viewer {
|
||||
let name = viewer
|
||||
@@ -258,7 +249,6 @@ impl AgentView {
|
||||
self.cancel_line_viewer();
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// All other keys (including Ctrl-D/U for page nav): forward to ListPaneState.
|
||||
if let Some(ref mut viewer) = self.line_viewer {
|
||||
viewer.list_state.handle_key_event(key, &viewer.lines);
|
||||
}
|
||||
@@ -303,13 +293,11 @@ impl AgentView {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Close the undo group.
|
||||
self.prompt.textarea.insert_str(" ");
|
||||
self.prompt.textarea.end_undo_group();
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel line viewer: revert all changes.
|
||||
pub(crate) fn cancel_line_viewer(&mut self) {
|
||||
self.line_viewer = None;
|
||||
self.prompt.textarea.cancel_undo_group();
|
||||
@@ -364,7 +352,6 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle mouse events while the line viewer is open.
|
||||
pub(super) fn handle_line_viewer_mouse(
|
||||
&mut self,
|
||||
mouse: &crossterm::event::MouseEvent,
|
||||
@@ -398,14 +385,12 @@ impl AgentView {
|
||||
|
||||
match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
// Click on close button -> cancel.
|
||||
if close_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
|
||||
if self.plan_approval_view.is_none() {
|
||||
self.cancel_line_viewer();
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// Click on fullscreen button -> toggle fullscreen.
|
||||
if fs_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
|
||||
if let Some(ref mut v) = self.line_viewer {
|
||||
v.fullscreen = !v.fullscreen;
|
||||
@@ -624,7 +609,6 @@ impl AgentView {
|
||||
_ => return InputOutcome::Changed,
|
||||
}
|
||||
|
||||
// Forward to ListPaneState if inside the popup area.
|
||||
let mut should_enter_commenting = false;
|
||||
let mut should_enter_plan_commenting = false;
|
||||
if let Some(area) = popup_area
|
||||
@@ -696,8 +680,6 @@ impl AgentView {
|
||||
InputOutcome::Changed
|
||||
}
|
||||
|
||||
// -- Scrollback selection box buttons -------------------------------------
|
||||
|
||||
/// Render ⧉ (copy) and ↗ (view) buttons on the scrollback selection box.
|
||||
///
|
||||
/// Two modes:
|
||||
@@ -745,7 +727,6 @@ impl AgentView {
|
||||
}
|
||||
|
||||
// Determine inline vs corner mode.
|
||||
// Inline: entry is collapsed AND part of a group (group_range > 1).
|
||||
let split_mode = self
|
||||
.scrollback
|
||||
.appearance()
|
||||
@@ -763,7 +744,6 @@ impl AgentView {
|
||||
let btn_base = Style::default().fg(theme.selection_border);
|
||||
let btn_hover = Style::default().fg(theme.text_primary);
|
||||
|
||||
// Build button array based on capabilities.
|
||||
if has_copy && has_view {
|
||||
let (btn_right_x, y) = if inline {
|
||||
// Inline: buttons on the selected entry's content row.
|
||||
@@ -844,8 +824,6 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Block viewer input handling ------------------------------------------
|
||||
|
||||
/// Handle a key event when the block viewer is open.
|
||||
///
|
||||
/// Returns `Changed` if consumed, `Unchanged` if the key should bubble up.
|
||||
@@ -854,18 +832,15 @@ impl AgentView {
|
||||
return InputOutcome::Unchanged;
|
||||
};
|
||||
|
||||
// Check for close signals first (Esc/q/Ctrl-F)
|
||||
if viewer.is_close_key(key) {
|
||||
self.block_viewer = None;
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
|
||||
// Route to viewer — returns whether the key was consumed
|
||||
if !viewer.handle_key(key) {
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
|
||||
// Handle raw toggle: capture old source map, toggle, rebuild with stability
|
||||
if viewer.raw_toggle_pending {
|
||||
viewer.raw_toggle_pending = false;
|
||||
// Record scroll anchor BEFORE toggle so the selected line stays
|
||||
@@ -883,7 +858,6 @@ impl AgentView {
|
||||
)
|
||||
})
|
||||
});
|
||||
// Toggle raw mode on the entry
|
||||
if let Some(entry) = self.scrollback.get_by_id_mut(viewer.entry_id) {
|
||||
entry.toggle_raw();
|
||||
}
|
||||
@@ -894,7 +868,6 @@ impl AgentView {
|
||||
}
|
||||
}
|
||||
|
||||
// Process pending copy actions (logic lives in BlockViewerPane)
|
||||
let entry_id = viewer.entry_id;
|
||||
if let Some(entry) = self.scrollback.get_by_id(entry_id)
|
||||
&& let Some(text) = viewer.process_pending_copy(entry)
|
||||
@@ -905,7 +878,6 @@ impl AgentView {
|
||||
InputOutcome::Changed
|
||||
}
|
||||
|
||||
/// Handle a mouse event when the block viewer modal is open.
|
||||
pub(in crate::app) fn handle_block_viewer_mouse(
|
||||
&mut self,
|
||||
mouse: &crossterm::event::MouseEvent,
|
||||
@@ -917,7 +889,6 @@ impl AgentView {
|
||||
return InputOutcome::Changed;
|
||||
};
|
||||
|
||||
// Route to modal chrome first (close button, click-outside).
|
||||
let modal_outcome =
|
||||
handle_modal_mouse(&mut viewer.modal, mouse.kind, mouse.column, mouse.row);
|
||||
match modal_outcome {
|
||||
@@ -929,7 +900,6 @@ impl AgentView {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Content interaction (scroll, click, drag).
|
||||
match mouse.kind {
|
||||
MouseEventKind::ScrollDown => viewer.handle_scroll(3),
|
||||
MouseEventKind::ScrollUp => viewer.handle_scroll(-3),
|
||||
@@ -939,7 +909,6 @@ impl AgentView {
|
||||
viewer.handle_mouse(mouse.kind, mouse.column, mouse.row);
|
||||
}
|
||||
MouseEventKind::Moved => {
|
||||
// Update hover state for content area.
|
||||
viewer.handle_mouse(mouse.kind, mouse.column, mouse.row);
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -35,7 +35,6 @@ impl NewWorktreeDialogState {
|
||||
label_input: String::new(),
|
||||
}
|
||||
}
|
||||
/// Handle a key event. Returns the dialog outcome.
|
||||
pub fn handle_key(&mut self, key: &crossterm::event::KeyEvent) -> NewWorktreeDialogOutcome {
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
@@ -1248,7 +1247,7 @@ impl AppView {
|
||||
}
|
||||
/// Reconcile the shared prompt queue for a session from a
|
||||
/// `kigi/queue/changed` broadcast. The broadcast is
|
||||
/// authoritative: it fully replaces the previously-known queue for that
|
||||
/// authoritative: it fully replaces the last-known queue for that
|
||||
/// session. An empty list clears the entry.
|
||||
///
|
||||
/// Returns `(old_id, new_id)` for echoes retired via the kind+text
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Bundle status state and response types.
|
||||
//!
|
||||
//! Pager-side cache of what `kigi-shell` reports from
|
||||
//! `kigi/bundle/status`. The shell now performs the actual bundle download in
|
||||
//! the background post-auth; the pager only reads the resulting on-disk
|
||||
//! catalog so it can populate the welcome-screen subagent pane.
|
||||
//! `kigi/bundle/status`. The shell performs the bundle download in the
|
||||
//! background post-auth; the pager only reads the resulting on-disk catalog so
|
||||
//! it can populate the welcome-screen subagent pane.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
|
||||
@@ -270,7 +270,6 @@ impl ServeArgs {
|
||||
.unwrap_or_else(|| generate_random_key(12))
|
||||
}
|
||||
}
|
||||
/// Generate a random alphanumeric key of the given length.
|
||||
fn generate_random_key(len: usize) -> String {
|
||||
let raw = uuid::Uuid::new_v4().to_string().replace('-', "");
|
||||
raw.chars().cycle().take(len).collect()
|
||||
@@ -678,7 +677,6 @@ pub enum ResumeTarget {
|
||||
None,
|
||||
}
|
||||
impl PagerArgs {
|
||||
/// Parse CLI arguments and apply `--cwd` if provided.
|
||||
pub fn parse_and_apply_cwd() -> anyhow::Result<Self> {
|
||||
let bin_name = std::env::args()
|
||||
.next()
|
||||
|
||||
@@ -65,7 +65,8 @@ impl CsiFragmentFilter {
|
||||
// bare \e then [I/[O in one drain batch is treated as a focus report; a typed pair rarely lands in one batch (same assumption as the mouse Complete arm)
|
||||
filtered_count += 1;
|
||||
self.tentative.clear();
|
||||
result.pop(); // retract the bare Esc
|
||||
// retract the bare Esc
|
||||
result.pop();
|
||||
// translate the reassembled report into its focus event so focus-driven UX (prompt refocus, recap away-timer, /gboom key-release) still fires over SSH
|
||||
result.push(if ch == 'I' {
|
||||
Event::FocusGained
|
||||
@@ -200,8 +201,6 @@ mod tests {
|
||||
press_mods(code, KeyModifiers::SHIFT)
|
||||
}
|
||||
|
||||
// ── SGR mouse fragment filter tests ──────────────────────────────
|
||||
|
||||
/// Build key events matching crossterm's actual output for a fragmented
|
||||
/// SGR mouse report `[<btn;col;row{M|m}]`.
|
||||
fn sgr_fragment(btn: &str, col: &str, row: &str, term: char) -> Vec<Event> {
|
||||
@@ -266,8 +265,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn csi_filter_partial_fragment_held() {
|
||||
// Partial SGR fragment (no terminating M/m) is held in the
|
||||
// persistent filter's tentative buffer, not emitted yet.
|
||||
let events = vec![
|
||||
press(KeyCode::Char('[')),
|
||||
press(KeyCode::Char('<')),
|
||||
@@ -282,9 +279,9 @@ mod tests {
|
||||
let mut f = CsiFragmentFilter::new();
|
||||
let result = f.filter(events);
|
||||
assert!(result.is_empty(), "partial fragment should be held");
|
||||
// A follow-up non-SGR event flushes the held events.
|
||||
let result2 = f.filter(vec![press(KeyCode::Enter)]);
|
||||
assert_eq!(result2.len(), 10); // 9 held + 1 new
|
||||
// 9 held + 1 new
|
||||
assert_eq!(result2.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -346,7 +343,8 @@ mod tests {
|
||||
];
|
||||
events.extend(sgr_fragment("0", "0", "0", 'M'));
|
||||
let result = CsiFragmentFilter::new().filter(events);
|
||||
assert_eq!(result.len(), 4); // [, <, 3, 5 preserved
|
||||
// [, <, 3, 5 preserved
|
||||
assert_eq!(result.len(), 4);
|
||||
}
|
||||
|
||||
/// A typed `[` must be emitted in the same batch, not held until
|
||||
@@ -404,32 +402,25 @@ mod tests {
|
||||
assert_eq!(total, 7);
|
||||
}
|
||||
|
||||
// ── Cross-batch SGR filtering tests ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn csi_filter_cross_batch_esc_then_fragment() {
|
||||
// Esc arrives in batch 1, SGR fragment chars in batch 2.
|
||||
// This is the exact scenario from the bug report.
|
||||
let mut f = CsiFragmentFilter::new();
|
||||
|
||||
// Batch 1: just the Esc
|
||||
let r1 = f.filter(vec![press(KeyCode::Esc)]);
|
||||
// Esc is emitted (can't be retracted across batches)
|
||||
assert_eq!(r1.len(), 1);
|
||||
assert_eq!(r1[0], press(KeyCode::Esc));
|
||||
|
||||
// Batch 2: the remaining SGR fragment chars
|
||||
let r2 = f.filter(sgr_fragment("64", "91", "51", 'M'));
|
||||
// Fragment is filtered — no garbage in the prompt
|
||||
assert!(r2.is_empty(), "SGR fragment chars should be filtered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csi_filter_cross_batch_partial_then_rest() {
|
||||
// Fragment split mid-sequence across two batches.
|
||||
let mut f = CsiFragmentFilter::new();
|
||||
|
||||
// Batch 1: partial fragment [<64;
|
||||
let r1 = f.filter(vec![
|
||||
press(KeyCode::Char('[')),
|
||||
press(KeyCode::Char('<')),
|
||||
@@ -439,8 +430,8 @@ mod tests {
|
||||
]);
|
||||
assert!(r1.is_empty(), "partial fragment should be held");
|
||||
|
||||
// Batch 2: remaining 91;51M — uppercase M arrives with SHIFT
|
||||
// (crossterm legacy parser sets SHIFT for uppercase chars).
|
||||
// crossterm's legacy parser sets SHIFT for uppercase chars, so the
|
||||
// terminating M arrives with SHIFT.
|
||||
let r2 = f.filter(vec![
|
||||
press(KeyCode::Char('9')),
|
||||
press(KeyCode::Char('1')),
|
||||
@@ -471,7 +462,7 @@ mod tests {
|
||||
press(KeyCode::Char(';')),
|
||||
press(KeyCode::Char('6')),
|
||||
press(KeyCode::Char('3')),
|
||||
press_shift(KeyCode::Char('M')), // crossterm adds SHIFT for uppercase
|
||||
press_shift(KeyCode::Char('M')),
|
||||
];
|
||||
let result = CsiFragmentFilter::new().filter(events);
|
||||
assert!(
|
||||
@@ -498,10 +489,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn csi_filter_cross_batch_partial_then_reject() {
|
||||
// Partial fragment in batch 1, rejected in batch 2.
|
||||
let mut f = CsiFragmentFilter::new();
|
||||
|
||||
// Batch 1: [<6
|
||||
let r1 = f.filter(vec![
|
||||
press(KeyCode::Char('[')),
|
||||
press(KeyCode::Char('<')),
|
||||
@@ -509,10 +498,9 @@ mod tests {
|
||||
]);
|
||||
assert!(r1.is_empty(), "partial should be held");
|
||||
|
||||
// Batch 2: starts with 'a' which rejects the match
|
||||
let r2 = f.filter(vec![press(KeyCode::Char('a'))]);
|
||||
// Held events + new event are all emitted
|
||||
assert_eq!(r2.len(), 4); // [, <, 6, a
|
||||
// [, <, 6, a
|
||||
assert_eq!(r2.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -521,11 +509,10 @@ mod tests {
|
||||
// bug scenario: scrolling during worktree creation).
|
||||
let mut f = CsiFragmentFilter::new();
|
||||
|
||||
// Batch 1: Esc from first scroll
|
||||
let r1 = f.filter(vec![press(KeyCode::Esc)]);
|
||||
assert_eq!(r1.len(), 1); // Esc emitted
|
||||
// Esc emitted
|
||||
assert_eq!(r1.len(), 1);
|
||||
|
||||
// Batch 2: fragment + Esc + fragment (two scroll events)
|
||||
let mut batch2 = sgr_fragment("64", "91", "51", 'M');
|
||||
batch2.push(press(KeyCode::Esc));
|
||||
batch2.extend(sgr_fragment("64", "91", "51", 'M'));
|
||||
@@ -572,8 +559,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── CSI focus report filtering tests ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn csi_filter_focus_in_after_esc_translated() {
|
||||
// Split \e[I focus-in (Esc, [, I — uppercase I arrives with SHIFT) is
|
||||
@@ -671,10 +656,8 @@ mod tests {
|
||||
fn csi_filter_cross_batch_focus_not_retracted() {
|
||||
// known limitation: only a same-batch report is reassembled (and translated); one split across drain batches still leaks, since a lone Esc can't be held across batches
|
||||
let mut f = CsiFragmentFilter::new();
|
||||
// Batch 1: lone Esc is emitted (a lone Esc can't be held across batches).
|
||||
let r1 = f.filter(vec![press(KeyCode::Esc)]);
|
||||
assert_eq!(r1, vec![press(KeyCode::Esc)]);
|
||||
// Batch 2: `[` then SHIFT-I come through — the focus report is not retracted.
|
||||
let r2 = f.filter(vec![
|
||||
press(KeyCode::Char('[')),
|
||||
press_shift(KeyCode::Char('I')),
|
||||
|
||||
@@ -11,10 +11,6 @@ use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState, PlatformLog
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::SessionEvent;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `/logout` -- ask the shell to clear auth, then return to the login screen.
|
||||
pub(super) fn dispatch_logout(_app: &mut AppView) -> Vec<Effect> {
|
||||
vec![Effect::Logout]
|
||||
@@ -45,7 +41,6 @@ pub(super) fn ensure_login_method(app: &mut AppView) {
|
||||
// No interactive method: leave login_method_id unset (fail-closed).
|
||||
}
|
||||
|
||||
/// Error when no interactive login method is available (empty auth_methods).
|
||||
fn no_login_method_error(_app: &AppView) -> String {
|
||||
"No login method available".to_string()
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ pub(super) fn with_active_agent(app: &mut AppView, f: impl FnOnce(&mut AgentView
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a shared reference to the active agent view (if any).
|
||||
/// Resolves through `active_subagent` — see [`with_active_agent`].
|
||||
pub(super) fn get_active_agent(app: &AppView) -> Option<&AgentView> {
|
||||
if let ActiveView::Agent(id) = app.active_view
|
||||
&& let Some(agent) = app.agents.get(&id)
|
||||
@@ -48,7 +48,7 @@ pub(super) fn get_active_agent(app: &AppView) -> Option<&AgentView> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the active agent view (if any).
|
||||
/// Resolves through `active_subagent` — see [`with_active_agent`].
|
||||
pub(super) fn get_active_agent_mut(app: &mut AppView) -> Option<&mut AgentView> {
|
||||
if let ActiveView::Agent(id) = app.active_view
|
||||
&& let Some(agent) = app.agents.get_mut(&id)
|
||||
@@ -138,11 +138,9 @@ pub(crate) enum SwitchCause {
|
||||
Load,
|
||||
/// Triggered by the agent picker (dashboard attach / switch).
|
||||
Picker,
|
||||
// `SwitchCause::Dashboard` was added
|
||||
// for the dashboard attach path but the earlier popup overlay
|
||||
// never reaches `switch_to_agent`, so the variant was dead. YAGNI —
|
||||
// any future caller can re-add it. The dashboard's attach path
|
||||
// sets `DashboardState::attached_agent` directly.
|
||||
// No `Dashboard` variant: the dashboard attach path sets
|
||||
// `DashboardState::attached_agent` directly and never reaches
|
||||
// `switch_to_agent`, so it would be dead. Any future caller can add it.
|
||||
}
|
||||
|
||||
/// Surface a launch-blocked `--yolo` once on the first agent view (the TUI owns
|
||||
|
||||
@@ -18,10 +18,6 @@ use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::{ActiveView, AppView, TrustState};
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Dashboard dispatchers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a `DashboardState` from the persisted layout (pins / reorder /
|
||||
/// grouping), loading + caching `app.dashboard_persisted` on first use. Used
|
||||
/// both to materialize the real dashboard and to compute a correct cycle order
|
||||
@@ -120,24 +116,14 @@ pub(super) fn dispatch_open_dashboard(app: &mut AppView) -> Vec<Effect> {
|
||||
return vec![];
|
||||
}
|
||||
// Edge case 24: idempotent toggle — opening from the dashboard view
|
||||
// itself just closes.
|
||||
//
|
||||
// Ctrl+\ is now a single-shot toggle between the
|
||||
// agent view and the dashboard view. The previous design used
|
||||
// Ctrl+\ as a 3-state cascade (open dashboard → close popup →
|
||||
// exit dashboard) which fought the user's mental model of
|
||||
// "Ctrl+\ flips views". With auto-attach landing a popup on every
|
||||
// open, the close-popup step would have eaten the user's expected
|
||||
// exit press. Now Ctrl+\ always exits when already in the
|
||||
// dashboard; closing the popup-only stays bound to Esc inside the
|
||||
// popup mouse/key cascade.
|
||||
// itself just closes. Ctrl+\ is a single-shot flip between the agent
|
||||
// view and the dashboard; closing a popup-only stays bound to Esc.
|
||||
if matches!(app.active_view, ActiveView::AgentDashboard) {
|
||||
return dispatch_exit_dashboard(app);
|
||||
}
|
||||
// Preserve in-memory state across reopen.
|
||||
// `app.dashboard.is_some()` means we've previously initialised
|
||||
// it; preserve the user's filter / dispatch text / hover /
|
||||
// selection. Otherwise seed from persisted state.
|
||||
// Preserve in-memory state across reopen: `app.dashboard.is_some()`
|
||||
// means it was already initialised, so keep the user's filter /
|
||||
// dispatch text / hover / selection. Otherwise seed from persisted state.
|
||||
if app.dashboard.is_none() {
|
||||
ensure_dashboard_state(app);
|
||||
} else if let Some(d) = app.dashboard.as_mut() {
|
||||
@@ -167,30 +153,13 @@ pub(super) fn dispatch_open_dashboard(app: &mut AppView) -> Vec<Effect> {
|
||||
agent.worktree_label = info.worktree_label;
|
||||
}
|
||||
}
|
||||
// The previous "auto-attach popup overlay" path
|
||||
// showed BOTH the dashboard (as a top banner) AND the focused
|
||||
// agent (as a bottom popup) on every `/dashboard` open. The
|
||||
// stacked layout was confusing — the user couldn't tell which
|
||||
// view owned the prompt, and the popup's keybindings (Enter
|
||||
// to send, etc.) had subtle input-routing bugs. Dashboard
|
||||
// open now shows ONLY the dashboard; pressing Enter on a row
|
||||
// switches the whole view to the agent's fullscreen view
|
||||
// (handled in `dispatch_dashboard_attach`).
|
||||
//
|
||||
// Always open in NEW-SESSION mode: focus the `[+ New Agent]` button
|
||||
// (no row selected) so typing a prompt + Enter dispatches a brand
|
||||
// new agent. Reply mode is opt-in — the user navigates (↑/↓ or j/k)
|
||||
// or clicks a row to select it, which arms "reply to that agent".
|
||||
//
|
||||
// Previously the dashboard pre-seeded `selected` to the agent the
|
||||
// user came from, which silently armed reply mode: a prompt typed
|
||||
// right after opening went to the old agent instead of spawning a
|
||||
// new one, AND the reply path never clears `selected`, so EVERY
|
||||
// subsequent dispatch kept replying to the same agent — the user
|
||||
// got "stuck to the same agent" and couldn't quickly dispatch new
|
||||
// sessions. New-session is the dashboard's primary gesture, so it is
|
||||
// the default; reply stays one explicit selection away.
|
||||
//
|
||||
// (no row selected) so typing a prompt + Enter dispatches a brand new
|
||||
// agent. Reply mode is opt-in — the user navigates (↑/↓ or j/k) or
|
||||
// clicks a row to select it, which arms "reply to that agent".
|
||||
// Pre-seeding `selected` would silently arm reply mode, and because the
|
||||
// reply path never clears `selected`, trap every subsequent dispatch on
|
||||
// the same agent.
|
||||
configure_dashboard_state(app);
|
||||
app.active_view = ActiveView::AgentDashboard;
|
||||
// Outside leader mode there is no live leader roster to poll, so the
|
||||
@@ -204,9 +173,9 @@ pub(super) fn dispatch_open_dashboard(app: &mut AppView) -> Vec<Effect> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Helper: produce a closure that answers "does this DashboardRowId
|
||||
/// still exist in `agents`?". Static lifetime not possible (closures
|
||||
/// borrow), so callers pass `&app.agents`.
|
||||
/// Produce a closure that answers "does this DashboardRowId still exist
|
||||
/// in `agents`?". Static lifetime not possible (closures borrow), so
|
||||
/// callers pass `&app.agents`.
|
||||
fn dashboard_alive_fn(
|
||||
agents: &indexmap::IndexMap<AgentId, AgentView>,
|
||||
) -> impl Fn(&crate::views::dashboard::DashboardRowId) -> bool + '_ {
|
||||
@@ -233,7 +202,7 @@ pub(super) fn dispatch_exit_dashboard(app: &mut AppView) -> Vec<Effect> {
|
||||
if let Some(d) = app.dashboard.as_mut() {
|
||||
d.close_popup();
|
||||
}
|
||||
// Return to either Welcome or the most recently active agent.
|
||||
// Return to an agent view, or Welcome when none remain.
|
||||
if let Some(id) = app.agents.keys().next().copied() {
|
||||
app.active_view = ActiveView::Agent(id);
|
||||
surface_yolo_launch_block_notice(app, id);
|
||||
@@ -256,9 +225,9 @@ pub(super) fn dispatch_dashboard_attach(
|
||||
// straight to the agent because `active_view = Agent(id)`, so
|
||||
// Enter/Shift+Tab/etc. all work as in any regular agent view.
|
||||
//
|
||||
// Attaching re-targets the overlay — an overlay stop-confirm armed
|
||||
// on a previously attached agent (legacy popup row-click path
|
||||
// reaches here without a key press) must not follow the user in.
|
||||
// Attaching re-targets the overlay — an overlay stop-confirm armed on
|
||||
// the agent attached before this (legacy popup row-click path reaches
|
||||
// here without a key press) must not follow the user in.
|
||||
clear_pending_overlay_stop(app);
|
||||
match id {
|
||||
DashboardRowId::TopLevel(agent_id) => {
|
||||
@@ -279,9 +248,6 @@ pub(super) fn dispatch_dashboard_attach(
|
||||
// section cursor / button focus are cleared — exactly one
|
||||
// cursor target stays active.
|
||||
d.focus_row(DashboardRowId::TopLevel(agent_id));
|
||||
// Signal session-overlay mode: render the agent
|
||||
// wrapped in the bordered frame with cycle/close
|
||||
// affordances at the top right.
|
||||
d.attached_agent = Some(agent_id);
|
||||
}
|
||||
app.active_view = ActiveView::Agent(agent_id);
|
||||
@@ -375,10 +341,8 @@ pub(super) fn dispatch_dashboard_attach(
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Exit the dashboard's session-overlay: dismiss the bordered
|
||||
/// chrome and return to the dashboard view. Mirrors the popup
|
||||
/// `[✗]` close from the older design but applied to the new
|
||||
/// fullscreen-with-frame layout.
|
||||
/// Exit the dashboard's session-overlay: dismiss the bordered chrome and
|
||||
/// return to the dashboard view.
|
||||
pub(super) fn dispatch_dashboard_overlay_exit(app: &mut AppView) -> Vec<Effect> {
|
||||
if let Some(d) = app.dashboard.as_mut() {
|
||||
d.close_popup();
|
||||
@@ -552,9 +516,6 @@ pub(super) fn dispatch_dashboard_toggle_auto_approve(app: &mut AppView) -> Vec<E
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Temporarily borrow active_view so `set_yolo_mode` targets
|
||||
// the dashboard's selected agent rather than whichever view
|
||||
// is currently active. Restored before returning.
|
||||
let saved_view = app.active_view;
|
||||
app.active_view = ActiveView::Agent(agent_id);
|
||||
let effects = set_yolo_mode(app, new);
|
||||
@@ -1078,18 +1039,13 @@ pub(super) fn dispatch_dashboard_dispatch(
|
||||
}
|
||||
|
||||
// The dashboard's dispatch input ALWAYS spawns a new session — it is
|
||||
// never a reply target. A row being selected is purely the overview
|
||||
// navigation cursor (Enter on it OPENS the agent); it must not turn
|
||||
// the input into "reply to that agent". Conflating the two trapped
|
||||
// the user: navigating to a row (vim j/k) flipped the input to
|
||||
// "Reply to <agent>" and there was no obvious way back to spawning a
|
||||
// new session. To converse with an existing agent, open it (navigate
|
||||
// + Enter, or click) and reply inside its own view.
|
||||
// never a reply target. A selected row is purely the overview navigation
|
||||
// cursor (Enter on it OPENS the agent); it must not turn the input into
|
||||
// "reply to that agent". To converse with an existing agent, open it and
|
||||
// reply inside its own view.
|
||||
//
|
||||
// New-session path.
|
||||
//
|
||||
// Return the new AgentId from the inner constructor
|
||||
// so we don't have to rely on `app.agents.last()`.
|
||||
// Return the new AgentId from the inner constructor rather than relying
|
||||
// on `app.agents.last()`.
|
||||
//
|
||||
// Carry the dashboard's staged model / plan-mode (set via `/model` and
|
||||
// `/plan`) onto the new session: the model id seeds `CreateSession`, and
|
||||
@@ -1238,7 +1194,7 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
|
||||
// Registered but not offered on this surface (session-scoped
|
||||
// hidden from the dropdown, or non-dashboard `dashboard_only`):
|
||||
// error toast — never spawn a session whose first prompt is the
|
||||
// slash text (that was worse than the old loud Action toasts).
|
||||
// slash text.
|
||||
if !dashboard
|
||||
.dispatch
|
||||
.slash_controller
|
||||
@@ -1496,20 +1452,6 @@ pub(super) fn apply_pending_dispatch_config(
|
||||
}
|
||||
}
|
||||
|
||||
/// Send or queue a reply typed into the peek panel's `❯ reply` input.
|
||||
///
|
||||
/// The reply is enqueued on the row's owning top-level agent and then
|
||||
/// [`maybe_drain_queue`] decides the rest: an **idle** agent sends it
|
||||
/// immediately (a turn starts), a **mid-turn** agent keeps it queued so
|
||||
/// it drains after the current turn finishes. This is the same queue /
|
||||
/// drain pipeline the agent view's own prompt input uses, so the two
|
||||
/// surfaces behave identically.
|
||||
///
|
||||
/// Subagent rows can't be replied to (they're driven by their parent),
|
||||
/// so they surface a toast and leave the peek open.
|
||||
///
|
||||
/// `attach` (Ctrl+S) additionally walks into the agent's detail
|
||||
/// view, mirroring the dispatch input's send+open affordance.
|
||||
/// Cycle the PEEKED agent's live mode (Normal → Plan → Always-Approve →
|
||||
/// Normal), the peek-panel counterpart to `DashboardCycleMode`. Reuses
|
||||
/// the shared cycle body `dispatch_cycle_mode_and_sync` by temporarily
|
||||
@@ -1558,6 +1500,20 @@ pub(super) fn dispatch_dashboard_peek_cycle_mode(app: &mut AppView) -> Vec<Effec
|
||||
effects
|
||||
}
|
||||
|
||||
/// Send or queue a reply typed into the peek panel's `❯ reply` input.
|
||||
///
|
||||
/// The reply is enqueued on the row's owning top-level agent and then
|
||||
/// [`maybe_drain_queue`] decides the rest: an **idle** agent sends it
|
||||
/// immediately (a turn starts), a **mid-turn** agent keeps it queued so
|
||||
/// it drains after the current turn finishes. This is the same queue /
|
||||
/// drain pipeline the agent view's own prompt input uses, so the two
|
||||
/// surfaces behave identically.
|
||||
///
|
||||
/// Subagent rows can't be replied to (they're driven by their parent),
|
||||
/// so they surface a toast and leave the peek open.
|
||||
///
|
||||
/// `attach` (Ctrl+S) additionally walks into the agent's detail
|
||||
/// view, mirroring the dispatch input's send+open affordance.
|
||||
pub(super) fn dispatch_dashboard_peek_reply(
|
||||
app: &mut AppView,
|
||||
row: crate::views::dashboard::DashboardRowId,
|
||||
@@ -1630,8 +1586,6 @@ pub(super) fn dispatch_dashboard_peek_reply(
|
||||
}
|
||||
let effects = maybe_drain_queue(agent);
|
||||
|
||||
// Clear the reply draft now that it's been accepted, and drop any
|
||||
// stale error toast.
|
||||
if let Some(d) = app.dashboard.as_mut() {
|
||||
d.clear_peek_reply();
|
||||
d.error_toast = None;
|
||||
@@ -1794,10 +1748,6 @@ pub(super) fn dispatch_dashboard_stop(app: &mut AppView) -> Vec<Effect> {
|
||||
return vec![];
|
||||
};
|
||||
let now = Instant::now();
|
||||
// `t.elapsed()` is the idiomatic Instant API
|
||||
// for "how long since this Instant". Behaviour identical
|
||||
// to `now.duration_since(*t)` when `t <= now`, which is
|
||||
// the only case the dispatcher constructs.
|
||||
let already_confirming = app
|
||||
.dashboard
|
||||
.as_ref()
|
||||
@@ -2015,7 +1965,7 @@ pub(super) fn dispatch_dashboard_reorder(app: &mut AppView, up: bool) -> Vec<Eff
|
||||
d.reorder.swap(i, i + 1);
|
||||
}
|
||||
Some(_) => {
|
||||
// Already at the bottom — append to end.
|
||||
// Already at the bottom — nothing to move.
|
||||
}
|
||||
None => {
|
||||
d.reorder.push(sel);
|
||||
@@ -2055,7 +2005,6 @@ pub(super) fn dispatch_dashboard_permission_select(
|
||||
request_id: usize,
|
||||
option_id: acp::PermissionOptionId,
|
||||
) -> Vec<Effect> {
|
||||
// Determine the owning AgentId.
|
||||
let target_id = match &row {
|
||||
crate::views::dashboard::DashboardRowId::TopLevel(id) => *id,
|
||||
crate::views::dashboard::DashboardRowId::Subagent { parent, .. } => *parent,
|
||||
|
||||
@@ -4,10 +4,6 @@ use crate::app::actions::Effect;
|
||||
use crate::app::app_view::AppView;
|
||||
|
||||
/// Open the interactive Claude-import modal on the welcome screen.
|
||||
///
|
||||
/// Scans for importable items. If empty, shows a brief startup warning and
|
||||
/// marks dismissed. Otherwise stores modal state on AppView so welcome
|
||||
/// rendering shows the modal.
|
||||
pub(super) fn dispatch_import_claude(app: &mut AppView) -> Vec<Effect> {
|
||||
let cwd = app.cwd.clone();
|
||||
let plan = kigi_shell::claude_import::scan_importable_settings(&cwd);
|
||||
@@ -103,8 +99,6 @@ pub(super) fn dispatch_import_claude_cancel(app: &mut AppView) -> Vec<Effect> {
|
||||
/// launch — if it matches (no new Claude content), the menu stays hidden.
|
||||
pub(super) fn dispatch_dismiss_claude_import(app: &mut AppView) -> Vec<Effect> {
|
||||
let cwd = app.cwd.clone();
|
||||
// Record the current `.claude/` content hash so the welcome menu row
|
||||
// doesn't reappear next session unless the content actually changes.
|
||||
kigi_shell::claude_import_state::mark_dismissed(&cwd);
|
||||
// Also set the [claude_compat] imported = true marker so runtime
|
||||
// fallback paths (perms, env, MCP servers, hooks, plugins) stop
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
//! Mid-turn interjection dispatch: optimistic local echo, the
|
||||
//! `kigi/interject` effect, and prompt-history recording. Split out of
|
||||
//! `dispatch.rs` verbatim (pure code motion).
|
||||
//! `kigi/interject` effect, and prompt-history recording.
|
||||
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
|
||||
/// Send a mid-turn interjection. Pushes a standard user prompt block locally
|
||||
/// for instant feedback, records the text in prompt history, clears the
|
||||
/// prompt, and fires the `kigi/interject` ext method carrying a client-minted
|
||||
/// id.
|
||||
/// Send a mid-turn interjection.
|
||||
///
|
||||
/// The shell broadcasts `kigi/session/interjection` to every attached pane so
|
||||
/// other clients viewing the same session render it too (multi-client /
|
||||
@@ -42,9 +38,6 @@ pub(super) fn dispatch_interject(
|
||||
|
||||
record_interject_prompt_history(agent, &text);
|
||||
|
||||
// Push a standard user prompt block locally for instant feedback, and
|
||||
// record its id so the broadcast echo (`kigi/session/interjection`) is
|
||||
// deduped instead of rendering a second copy on this pane.
|
||||
let interjection_id = uuid::Uuid::new_v4().to_string();
|
||||
agent.self_interjection_ids.insert(interjection_id.clone());
|
||||
agent
|
||||
@@ -119,7 +112,6 @@ pub(super) fn dispatch_send_prompt_now(
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Submitting retires any edit-contextual ephemeral tip.
|
||||
agent.ephemeral_tip.clear_on_submit();
|
||||
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
@@ -273,7 +265,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A no-session interject still retires the tip: the clear now runs before
|
||||
/// A no-session interject still retires the tip: the clear runs before
|
||||
/// the "No active session" early return, matching the other submit paths.
|
||||
#[test]
|
||||
fn interject_without_session_still_clears_ephemeral_tip() {
|
||||
|
||||
@@ -120,12 +120,9 @@ pub(super) fn dispatch_enter_plan_mode(
|
||||
|
||||
/// Set plan mode (on / off). PAGER-owned + ACP-mediated, per-session.
|
||||
///
|
||||
/// Optimistic flow: captures effective state (`pending.or(active)`),
|
||||
/// sets `plan_mode_pending`, refreshes modals, toasts, then emits
|
||||
/// `Effect::SetSessionMode`. Shell confirms via `CurrentModeUpdate`.
|
||||
///
|
||||
/// No explicit rollback — `SetSessionMode` has no failure surface.
|
||||
/// If the ACP transport drops, `plan_mode_pending` stays set until
|
||||
/// Optimistic: sets `plan_mode_pending`; the shell confirms via
|
||||
/// `CurrentModeUpdate`. No explicit rollback — `SetSessionMode` has no failure
|
||||
/// surface, so on a dropped ACP transport `plan_mode_pending` stays set until
|
||||
/// the next `CurrentModeUpdate` or session restart.
|
||||
///
|
||||
/// Idempotent: same value toasts but skips the ACP round-trip.
|
||||
|
||||
@@ -58,16 +58,18 @@ pub(super) fn dispatch_send_remember_note(app: &mut AppView, text: String) -> Ve
|
||||
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
// No session — open modal with raw content only (no LLM rewrite).
|
||||
// no session → no LLM rewrite, Tab disabled; nonce unused since no
|
||||
// rewrite is in flight
|
||||
agent.active_modal = Some(ActiveModal::RememberNoteReview {
|
||||
raw_content: trimmed.clone(),
|
||||
enhanced_content: None, // no session → no LLM rewrite, Tab disabled
|
||||
enhanced_content: None,
|
||||
showing_enhanced: false,
|
||||
scroll: 0,
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
cached_lines: None,
|
||||
cwd,
|
||||
agent_id: id,
|
||||
rewrite_nonce: 0, // no rewrite in flight, nonce unused
|
||||
rewrite_nonce: 0,
|
||||
});
|
||||
return vec![];
|
||||
};
|
||||
@@ -148,7 +150,6 @@ fn extract_session_context(agent: &AgentView) -> String {
|
||||
let mut user_prompts: Vec<String> = Vec::new();
|
||||
let mut file_paths: Vec<String> = Vec::new();
|
||||
|
||||
// Walk scrollback entries in reverse to collect recent context.
|
||||
let len = agent.scrollback.len();
|
||||
for i in (0..len).rev() {
|
||||
let Some(entry) = agent.scrollback.entry(i) else {
|
||||
@@ -186,7 +187,6 @@ fn extract_session_context(agent: &AgentView) -> String {
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
// Stop early once we have enough context.
|
||||
if user_prompts.len() >= 5 && file_paths.len() >= 20 {
|
||||
break;
|
||||
}
|
||||
@@ -194,15 +194,12 @@ fn extract_session_context(agent: &AgentView) -> String {
|
||||
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
// CWD
|
||||
parts.push(format!("CWD: {}", agent.session.cwd.display()));
|
||||
|
||||
// Git branch
|
||||
if let Some(ref branch) = agent.current_branch {
|
||||
parts.push(format!("Branch: {branch}"));
|
||||
}
|
||||
|
||||
// Recent prompts (chronological order)
|
||||
if !user_prompts.is_empty() {
|
||||
user_prompts.reverse();
|
||||
parts.push("Recent prompts:".to_string());
|
||||
|
||||
@@ -6,10 +6,6 @@ use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permission dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handle permission option selection (AllowOnce, AllowAlways, RejectAlways).
|
||||
///
|
||||
/// Pops the front request, sends the response, and handles queue transitions
|
||||
@@ -121,7 +117,6 @@ pub(super) fn dispatch_permission_select(
|
||||
.meta(meta)))
|
||||
.ok();
|
||||
|
||||
// Queue transition: restore prompt if queue is now empty, clear if next-front.
|
||||
resolve_permission_queue_transition(agent);
|
||||
|
||||
// "Enable always-approve" side effect: flip YOLO + persist + notify.
|
||||
@@ -160,7 +155,6 @@ pub(super) fn dispatch_permission_followup(app: &mut AppView, text: String) -> V
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Find the RejectOnce option.
|
||||
let option_id = perm
|
||||
.options
|
||||
.iter()
|
||||
@@ -168,7 +162,6 @@ pub(super) fn dispatch_permission_followup(app: &mut AppView, text: String) -> V
|
||||
.map(|o| o.option_id.clone());
|
||||
|
||||
let Some(option_id) = option_id else {
|
||||
// No RejectOnce option — cancel instead.
|
||||
perm.request
|
||||
.response_tx
|
||||
.send(Ok(acp::RequestPermissionResponse::new(
|
||||
@@ -179,7 +172,6 @@ pub(super) fn dispatch_permission_followup(app: &mut AppView, text: String) -> V
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Include followup message in meta.
|
||||
let meta = if !text.trim().is_empty() {
|
||||
serde_json::json!({
|
||||
"followup_message": text,
|
||||
@@ -243,7 +235,6 @@ pub(super) fn drain_permission_queue(agent: &mut AgentView) {
|
||||
)))
|
||||
.ok();
|
||||
}
|
||||
// Queue is now empty — restore stashed prompt.
|
||||
if let Some(stashed) = agent.permission_stashed_prompt.take() {
|
||||
agent.prompt.restore(stashed);
|
||||
}
|
||||
@@ -258,7 +249,6 @@ pub(super) fn drain_permission_queue(agent: &mut AgentView) {
|
||||
pub(crate) fn resolve_permission_queue_transition(agent: &mut AgentView) {
|
||||
agent.last_permission_click = None;
|
||||
if agent.permission_queue.is_empty() {
|
||||
// Restore original prompt.
|
||||
if let Some(stashed) = agent.permission_stashed_prompt.take() {
|
||||
agent.prompt.restore(stashed);
|
||||
}
|
||||
@@ -266,7 +256,6 @@ pub(crate) fn resolve_permission_queue_transition(agent: &mut AgentView) {
|
||||
// Clear any followup text from the just-resolved permission so it
|
||||
// doesn't leak into the next permission's UI.
|
||||
agent.prompt.set_text("");
|
||||
// Reset next front's focus to Options.
|
||||
if let Some(next) = agent.permission_queue.front_mut() {
|
||||
next.focus = crate::views::permission_view::PermissionFocus::Options;
|
||||
}
|
||||
|
||||
@@ -27,11 +27,6 @@ pub(super) fn consume_chat_kind(app: &mut AppView) -> bool {
|
||||
app.chat_mode || pending
|
||||
}
|
||||
|
||||
/// Enqueue a prompt and try to drain immediately.
|
||||
///
|
||||
/// The prompt is always pushed to the queue first. If the agent is idle
|
||||
/// (and has a session), `maybe_drain_queue` pops the front prompt and
|
||||
/// sends it in the same dispatch call — no deferred ticks.
|
||||
/// Start (if needed) and submit the initial prompt from `kigi "<prompt>"`.
|
||||
///
|
||||
/// Shared by the TUI startup path (already authenticated) and the post-login
|
||||
@@ -104,7 +99,6 @@ pub(super) fn dispatch_show_undo_tip(app: &mut AppView) -> Vec<Effect> {
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
// Shows and increments the per-session count in place (no disk write).
|
||||
// Emit the impression only when the tip actually took the slot (mirrors
|
||||
// the `tip.shown` gate), so gated no-ops and TTL refreshes don't count.
|
||||
agent.show_ephemeral_tip(
|
||||
@@ -149,7 +143,6 @@ pub(super) fn dispatch_show_plan_nudge(app: &mut AppView) -> Vec<Effect> {
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
// Shows and increments the per-session count in place (no disk write).
|
||||
// Impression counts only on a real show (see `dispatch_show_undo_tip`).
|
||||
agent.show_ephemeral_tip(
|
||||
crate::tips::plan_nudge::plan_nudge_tip(),
|
||||
@@ -322,8 +315,6 @@ pub(super) fn dispatch_send_prompt_inner(
|
||||
|
||||
let mut effects = Vec::new();
|
||||
|
||||
// ── Registry-based slash command execution ─────────────────────
|
||||
// If the text starts with `/`, run it through the slash registry.
|
||||
// The registry resolves builtins, ACP-advertised commands, and
|
||||
// unknown commands uniformly. Dispatch is the SOLE execution owner.
|
||||
// `literal` (chip click) skips this so chip text is never a command.
|
||||
@@ -348,7 +339,6 @@ pub(super) fn dispatch_send_prompt_inner(
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Build execution context.
|
||||
let exec_result = {
|
||||
let mut ctx = CommandExecCtx {
|
||||
models: &agent.session.models,
|
||||
@@ -416,8 +406,7 @@ pub(super) fn dispatch_send_prompt_inner(
|
||||
}
|
||||
};
|
||||
|
||||
// Map CommandResult to pager behavior. (MRU persistence is queued
|
||||
// off-thread inside `record_command_use` above.)
|
||||
// MRU persistence is queued off-thread inside `record_command_use` above.
|
||||
match exec_result {
|
||||
CommandResult::Handled | CommandResult::HandledNoOp => {
|
||||
if consume_input {
|
||||
@@ -523,7 +512,7 @@ pub(super) fn dispatch_send_prompt_inner(
|
||||
}
|
||||
return dispatch(Action::Quit, app);
|
||||
} else {
|
||||
// ── Server-authoritative immediate send (plain prompt only) ──
|
||||
// Server-authoritative immediate send (plain prompt only).
|
||||
// A plain prompt typed while a turn is RUNNING is sent to the agent
|
||||
// immediately instead of being held in the local drip-feed queue. The
|
||||
// agent appends it to its authoritative `pending_inputs` (no concurrent
|
||||
@@ -541,7 +530,7 @@ pub(super) fn dispatch_send_prompt_inner(
|
||||
// suggestions: clear the visible chips here — INSIDE the send/enqueue
|
||||
// path, after the `reconnect_pending` and active-agent early-return
|
||||
// guards — so the chips are cleared ONLY when the suggestion actually
|
||||
// sends/enqueues. Placing it before those guards (the prior fix) cleared
|
||||
// sends/enqueues. Placing it before those guards cleared
|
||||
// the chips even when `reconnect_pending` aborted with a toast and no
|
||||
// send, losing both the chips and the submit. This single clear covers
|
||||
// BOTH the immediate-send and enqueue subpaths below; `clear_follow_ups`
|
||||
@@ -763,7 +752,7 @@ pub(super) fn dispatch_send_bash_command(app: &mut AppView, command: String) ->
|
||||
agent.session.prompt_history.truncate(200);
|
||||
}
|
||||
|
||||
// ── Server-authoritative immediate send for bash while running ──
|
||||
// Server-authoritative immediate send for bash while running.
|
||||
// A bash command typed while a turn is RUNNING is sent to the agent
|
||||
// immediately (it's already a `session/prompt` with bash meta) and echoed
|
||||
// into the shared queue with `kind="bash"`. On `running_prompt_id`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Prompt-queue dispatch: the server-authoritative immediate-send routing
|
||||
//! helpers, optimistic queue echoes, the local drip-feed drain
|
||||
//! ([`maybe_drain_queue`]), the turn-start shim, and the queue-interject
|
||||
//! action arm. Split out of `dispatch.rs` verbatim (pure code motion).
|
||||
//! action arm.
|
||||
|
||||
use super::ctx::{active_agent_session_id, with_active_agent};
|
||||
use super::interject::record_interject_prompt_history;
|
||||
@@ -155,7 +155,6 @@ pub(super) fn drain_prompt_state_to_last_queued(agent: &mut AgentView) {
|
||||
|
||||
/// Prepend `<system-reminder>` framing to a cron prompt for the model.
|
||||
///
|
||||
/// Delegates to the shared implementation in `kigi_tools::reminders`.
|
||||
/// The UI shows the raw `prompt` text via `RenderBlock::cron_prompt`; this
|
||||
/// wrapped version is only sent to the model via `Effect::SendPrompt` so
|
||||
/// the model knows the message is a scheduled task execution, not a human.
|
||||
@@ -166,12 +165,6 @@ fn format_cron_prompt(prompt: &str, task_id: &str, human_schedule: &str) -> Stri
|
||||
/// Try to send the next queued entry (prompt, command, bash, or cron) if the agent is idle.
|
||||
///
|
||||
/// Called after enqueue operations and task completions to advance the queue.
|
||||
///
|
||||
/// Branches on `QueueEntryKind`:
|
||||
/// - **Prompt**: pushes user prompt block to scrollback, starts turn, returns `Effect::SendPrompt`
|
||||
/// - **Command**: starts command, returns the appropriate `Effect` (e.g., `Effect::Compact`)
|
||||
/// - **BashCommand**: starts turn (no user block), returns `Effect::SendBashCommand`
|
||||
/// - **Cron**: pushes cron prompt block to scrollback, starts turn, returns `Effect::SendPrompt`
|
||||
pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec<Effect> {
|
||||
use crate::app::agent::QueueEntryKind;
|
||||
use crate::unified_log as ulog;
|
||||
@@ -224,7 +217,6 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec<Effect> {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Block drain if the user is editing the front prompt.
|
||||
if let PromptMode::EditingQueued { id, .. } = &agent.prompt_mode
|
||||
&& agent
|
||||
.session
|
||||
@@ -333,14 +325,12 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec<Effect> {
|
||||
}
|
||||
agent.turn_started_at = Some(Instant::now());
|
||||
|
||||
// Scroll to the new prompt and engage follow mode.
|
||||
let prompt_idx = agent.scrollback.len().saturating_sub(1);
|
||||
agent.scrollback.set_selected(Some(prompt_idx));
|
||||
agent.scrollback.scroll_to_entry_top(prompt_idx);
|
||||
agent.scrollback.enable_follow_with_preserve();
|
||||
|
||||
if let Some(mut blocks) = queued.wire_blocks {
|
||||
// Skill injection: send structured blocks.
|
||||
// Annotate the first text block's meta with the display text
|
||||
// so the pager can reconstruct the clean prompt on session
|
||||
// restore (replay). Without this, replay shows the raw skill
|
||||
@@ -369,7 +359,6 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec<Effect> {
|
||||
prompt_id,
|
||||
}]
|
||||
} else if !queued.images.is_empty() {
|
||||
// Image-bearing prompt: build text + image content blocks.
|
||||
// Pass the session cwd so orphan `[Image #N: <path>]`
|
||||
// placeholders (paste from a previous session, etc.)
|
||||
// can be recovered from disk via the shared helper.
|
||||
@@ -387,7 +376,6 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec<Effect> {
|
||||
prompt_id,
|
||||
}]
|
||||
} else {
|
||||
// Normal prompt: send text as-is.
|
||||
vec![Effect::SendPrompt {
|
||||
agent_id,
|
||||
session_id,
|
||||
@@ -417,7 +405,6 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec<Effect> {
|
||||
agent.session.current_prompt_id = Some(prompt_id.clone());
|
||||
agent.turn_started_at = Some(Instant::now());
|
||||
|
||||
// Engage follow mode so streaming output scrolls into view.
|
||||
agent.scrollback.enable_follow_with_preserve();
|
||||
|
||||
vec![Effect::SendBashCommand {
|
||||
@@ -600,8 +587,8 @@ pub(crate) fn apply_turn_start_shim(
|
||||
// turn the leader drained into the running slot: if THIS client originated
|
||||
// it (its own queued/immediate prompt), it drives it; otherwise it is
|
||||
// viewing a turn another client drives, so `attached_as_viewer` must flip
|
||||
// back to true even if this pane has sent prompts before (the flag is no
|
||||
// longer a one-way latch) — that drives `handle_prompt_complete` + the
|
||||
// back to true even if this pane has sent prompts before (the flag is
|
||||
// not a one-way latch) — that drives `handle_prompt_complete` + the
|
||||
// viewer chrome correctly.
|
||||
let adopted_from_other_client = !agent.is_self_originated_prompt(&prompt_id);
|
||||
// Sticky pin + still-armed send-now expect (not cleared on adopt — the
|
||||
@@ -852,8 +839,6 @@ mod tests {
|
||||
assert!(out.ends_with("do stuff"));
|
||||
}
|
||||
|
||||
// ── Drain-blocking tests ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn drain_blocked_when_editing_front_prompt() {
|
||||
let mut app = test_app_with_agent();
|
||||
@@ -1785,7 +1770,7 @@ mod tests {
|
||||
enqueue_local(&mut app, id, "p2");
|
||||
enqueue_local(&mut app, id, "p3");
|
||||
enqueue_local(&mut app, id, "p4");
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 3); // p2, p3, p4
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 3);
|
||||
|
||||
// End turn for p1 → sets Idle → maybe_drain_queue pops p2 → Running again.
|
||||
// Queue is now: p3, p4.
|
||||
@@ -1804,7 +1789,7 @@ mod tests {
|
||||
// End turn for p2 → should NOT drain p3 (being edited).
|
||||
let effects = dispatch(end_turn(), &mut app);
|
||||
assert!(effects.is_empty(), "drain should be blocked: {effects:?}");
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 2); // p3, p4
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 2);
|
||||
|
||||
// Simulate user saving edited text.
|
||||
app.agents
|
||||
|
||||
@@ -682,8 +682,6 @@ pub(super) fn dispatch_rewind_success(
|
||||
vec![]
|
||||
}
|
||||
|
||||
// TaskResult handlers.
|
||||
|
||||
pub(super) fn handle_rewind_points_loaded(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
|
||||
@@ -15,32 +15,18 @@ use crate::scrollback::blocks::SessionEvent;
|
||||
use crate::scrollback::state::ScrollbackState;
|
||||
use agent_client_protocol as acp;
|
||||
use std::time::Instant;
|
||||
/// Top-level `/fork` dispatcher. Resolves the worktree decision: an
|
||||
/// explicit `--worktree` / `--no-worktree` flag short-circuits to
|
||||
/// [`dispatch_fork_resolved`]. When no flag is given and a persisted
|
||||
/// `fork_worktree_mode` preference is set (`Always` / `Never`), the
|
||||
/// popup is skipped and the corresponding path is taken directly. The
|
||||
/// `Ask` default opens the [`open_fork_question`] modal so the user is
|
||||
/// asked.
|
||||
/// Top-level `/fork` dispatcher, resolving the worktree decision (explicit
|
||||
/// flag, persisted `fork_worktree_mode`, or the [`open_fork_question`] modal).
|
||||
///
|
||||
/// When the parent session's working directory is **not** inside a git
|
||||
/// repository (indicated by the absence of a `git_head_changed`
|
||||
/// notification — `current_branch` is `None`):
|
||||
/// - `--worktree` is rejected with a toast (nothing to create a worktree from).
|
||||
/// - No flag (regardless of `fork_worktree_mode`): the worktree question
|
||||
/// is skipped and the fork proceeds with `worktree = false`.
|
||||
/// A parent outside a git repository has `current_branch == None` (no
|
||||
/// `git_head_changed` notification): `--worktree` is rejected and every other
|
||||
/// path forks with `worktree = false`. That fallback is safe even if the
|
||||
/// notification simply has not arrived yet — the worktree can be created
|
||||
/// manually afterwards.
|
||||
///
|
||||
/// Note: if the notification has not arrived yet (rare — user forks
|
||||
/// before the shell sends `git_head_changed`), the fallback to
|
||||
/// `worktree = false` is safe and the worktree can be created manually
|
||||
/// afterwards.
|
||||
///
|
||||
/// Two failure surfaces:
|
||||
/// - Active view is not an agent: toast and return.
|
||||
/// - Active agent has no `session_id` (still being created): toast and
|
||||
/// return. Both rejections are deliberate -- queueing the fork until
|
||||
/// `SessionLoaded` would require persisting `ForkArgs` across the
|
||||
/// `TaskResult` and is deferred to v2.
|
||||
/// A missing agent or a parent with no `session_id` are rejected rather than
|
||||
/// queued: queueing until `SessionLoaded` would mean persisting `ForkArgs`
|
||||
/// across the `TaskResult`, deferred to v2.
|
||||
pub(in crate::app::dispatch) fn dispatch_fork(
|
||||
app: &mut AppView,
|
||||
args: crate::slash::commands::fork::ForkArgs,
|
||||
|
||||
@@ -495,16 +495,6 @@ pub(in crate::app::dispatch) fn reanchor_grouped_selection<T>(
|
||||
}
|
||||
state.selected = sel;
|
||||
}
|
||||
/// Trigger a deep content search when the session picker query changes.
|
||||
///
|
||||
/// Any query of 2+ chars searches content — title matches never suppress
|
||||
/// it. Forced (Ctrl+/) searches fire immediately; keystrokes otherwise
|
||||
/// coalesce through [`Effect::DebounceSessionSearch`], whose expiry runs
|
||||
/// the search only if its seq is still current. Shorter queries clear the
|
||||
/// content results.
|
||||
///
|
||||
/// Checks the active agent's modal first; if no modal session picker
|
||||
/// exists, falls back to the welcome-screen picker state.
|
||||
pub(in crate::app::dispatch) fn dispatch_cycle_session_source_filter(
|
||||
app: &mut AppView,
|
||||
) -> Vec<Effect> {
|
||||
@@ -544,6 +534,16 @@ pub(in crate::app::dispatch) fn dispatch_cycle_session_source_filter(
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
/// Trigger a deep content search when the session picker query changes.
|
||||
///
|
||||
/// Any query of 2+ chars searches content — title matches never suppress
|
||||
/// it. Forced (Ctrl+/) searches fire immediately; keystrokes otherwise
|
||||
/// coalesce through [`Effect::DebounceSessionSearch`], whose expiry runs
|
||||
/// the search only if its seq is still current. Shorter queries clear the
|
||||
/// content results.
|
||||
///
|
||||
/// Checks the active agent's modal first; if no modal session picker
|
||||
/// exists, falls back to the welcome-screen picker state.
|
||||
pub(in crate::app::dispatch) fn dispatch_trigger_deep_search(
|
||||
app: &mut AppView,
|
||||
force: bool,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
//! Session rename / close helpers (shared with the dashboard).
|
||||
//!
|
||||
//! The `/sessions` picker modal was removed; rename-via-slash and
|
||||
//! dashboard close still use these dispatchers.
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
|
||||
@@ -40,7 +40,6 @@ pub(in crate::app::dispatch) fn set_multiline_mode(app: &mut AppView, new: bool)
|
||||
return vec![];
|
||||
}
|
||||
agent.multiline_mode = new;
|
||||
// Refresh modal snapshot so the indicator reflects the new value.
|
||||
refresh_open_settings_modals(app);
|
||||
tracing::info!(
|
||||
target: "settings",
|
||||
@@ -72,7 +71,6 @@ pub(in crate::app::dispatch) fn set_render_mermaid(
|
||||
return vec![];
|
||||
}
|
||||
set_render_mermaid_inner(kind);
|
||||
// Refresh modal snapshot so the picker reflects the new value.
|
||||
refresh_open_settings_modals(app);
|
||||
tracing::info!(
|
||||
target: "settings",
|
||||
@@ -714,7 +712,6 @@ pub(super) fn set_default_selected_permission_inner(
|
||||
crate::appearance::permission_cursor::set_default_selected_permission(value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings setters — unified dispatch for the settings modal and slash
|
||||
// commands.
|
||||
//
|
||||
@@ -725,7 +722,6 @@ pub(super) fn set_default_selected_permission_inner(
|
||||
//
|
||||
// PAGER setters (e.g. `set_multiline_mode`) have no persist/rollback,
|
||||
// so they skip the split.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State-only mutation for `compact_mode`. Updates the in-memory
|
||||
/// `current_ui` snapshot (read by the modal) and the thread-local cache
|
||||
@@ -745,7 +741,6 @@ pub(in crate::app::dispatch) fn set_compact_mode(app: &mut AppView, new: bool) -
|
||||
return vec![];
|
||||
}
|
||||
set_compact_mode_inner(app, new);
|
||||
// Refresh modal snapshot so the indicator reflects the new value.
|
||||
refresh_open_settings_modals(app);
|
||||
tracing::info!(target: "settings", key = "compact_mode", value = new, "setting changed");
|
||||
// Turning the setting off while the short-terminal derivation holds keeps
|
||||
@@ -778,7 +773,6 @@ pub(super) fn set_timestamps_inner(app: &mut AppView, new: bool) {
|
||||
|
||||
pub(in crate::app::dispatch) fn set_timestamps(app: &mut AppView, new: bool) -> Vec<Effect> {
|
||||
let prev = app.current_ui.show_timestamps.unwrap_or(true);
|
||||
// Idempotency gate.
|
||||
if prev == new {
|
||||
return vec![];
|
||||
}
|
||||
@@ -811,7 +805,6 @@ pub(in crate::app::dispatch) fn set_timeline(app: &mut AppView, new: bool) -> Ve
|
||||
// renders from and what `/timeline` toggles against) — not the separately
|
||||
// hydrated `current_ui`, which could disagree and make the toggle no-op.
|
||||
let prev = app.appearance.show_timeline;
|
||||
// Idempotency gate.
|
||||
if prev == new {
|
||||
return vec![];
|
||||
}
|
||||
@@ -855,9 +848,9 @@ pub(in crate::app::dispatch) fn set_simple_mode(app: &mut AppView, new: bool) ->
|
||||
set_simple_mode_inner(app, new);
|
||||
refresh_open_settings_modals(app);
|
||||
tracing::info!(target: "settings", key = "simple_mode", value = new, "setting changed");
|
||||
// Toast label mirrors the renamed registry label
|
||||
// ("Disable vim input mode") so the user sees the same name in the
|
||||
// modal and the toast.
|
||||
// Toast label matches the registry's display label
|
||||
// ("Disable vim input mode"), not the setting's internal name, so
|
||||
// the modal and toast agree.
|
||||
app.show_toast(&save_success_toast("Disable vim input mode", new));
|
||||
vec![Effect::PersistSetting {
|
||||
key: "simple_mode",
|
||||
@@ -866,7 +859,6 @@ pub(in crate::app::dispatch) fn set_simple_mode(app: &mut AppView, new: bool) ->
|
||||
}]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contextual-hint tips: the `contextual_hints.*` per-tip toggles.
|
||||
//
|
||||
// SHELL-owned: persisted to `[ui.contextual_hints]`. Each setter writes the
|
||||
@@ -874,7 +866,6 @@ pub(in crate::app::dispatch) fn set_simple_mode(app: &mut AppView, new: bool) ->
|
||||
// re-propagates the prompt gates to every agent, so a toggle takes effect at
|
||||
// runtime (not just on next launch). `write` is a non-capturing closure that
|
||||
// coerces to `fn` so the tips share one inner.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State-only mutation: write one tip's user-config Option, then re-resolve and
|
||||
/// fan the resolved gates out to `app` + every agent prompt.
|
||||
@@ -1009,7 +1000,6 @@ pub(in crate::app::dispatch) fn set_contextual_hint_word_select(
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme settings: `theme`, `auto_dark_theme`, `auto_light_theme`.
|
||||
//
|
||||
// Each has a preview/commit split:
|
||||
@@ -1021,7 +1011,6 @@ pub(in crate::app::dispatch) fn set_contextual_hint_word_select(
|
||||
//
|
||||
// Unknown names: `error!` in outer (registry skew), `warn!` in inner
|
||||
// (rollback of corrupted config).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a "✓ <Label>: <value>" toast for theme-family settings.
|
||||
/// `value` is the user-friendly display name, not the canonical.
|
||||
@@ -1069,8 +1058,6 @@ fn auto_theme_setting_is_live(key: &str) -> bool {
|
||||
crate::theme::cache::is_auto_mode() && system_is_in_matching_mode(key)
|
||||
}
|
||||
|
||||
// ── theme (commit path) ─────────────────────────────────────────────
|
||||
|
||||
/// State + cache + visual mutation for `theme`. **Commit path.**
|
||||
/// Updates `app.current_ui.theme`, toggles `AUTO_MODE` based on
|
||||
/// whether the value is `"auto"`, and applies the live theme.
|
||||
@@ -1135,8 +1122,6 @@ pub(in crate::app::dispatch) fn set_theme(app: &mut AppView, new: String) -> Vec
|
||||
}]
|
||||
}
|
||||
|
||||
// ── theme (preview path) ────────────────────────────────────────────
|
||||
|
||||
/// Preview-only mutation for `theme`. Applies the live visual without
|
||||
/// modifying state, toggling `AUTO_MODE`, persisting, or toasting.
|
||||
/// For `"auto"`, resolves and applies the theme but does NOT toggle
|
||||
@@ -1168,8 +1153,6 @@ pub(in crate::app::dispatch) fn preview_theme(_app: &mut AppView, new: String) -
|
||||
vec![]
|
||||
}
|
||||
|
||||
// ── auto_dark_theme (commit path) ───────────────────────────────────
|
||||
|
||||
/// State + cache + visual mutation for `auto_dark_theme`. Commit path.
|
||||
/// Applies visually only when the setting is live (auto mode + dark).
|
||||
/// Rejects `"auto"` as an invalid value (log + no-op).
|
||||
@@ -1239,8 +1222,6 @@ pub(in crate::app::dispatch) fn set_auto_dark_theme(app: &mut AppView, new: Stri
|
||||
}]
|
||||
}
|
||||
|
||||
// ── auto_dark_theme (preview path) ──────────────────────────────────
|
||||
|
||||
/// Preview-only mutation for `auto_dark_theme`. Visual only when live.
|
||||
fn preview_auto_dark_theme_inner(value: &str) {
|
||||
let Some(kind) = crate::theme::ThemeKind::from_name(value) else {
|
||||
@@ -1280,8 +1261,6 @@ pub(in crate::app::dispatch) fn preview_auto_dark_theme(
|
||||
vec![]
|
||||
}
|
||||
|
||||
// ── auto_light_theme (commit path) ──────────────────────────────────
|
||||
|
||||
/// State + cache + visual mutation for `auto_light_theme`. Commit path.
|
||||
/// Mirror of `set_auto_dark_theme_inner` for the light bucket.
|
||||
pub(super) fn set_auto_light_theme_inner(app: &mut AppView, value: &str) {
|
||||
@@ -1352,8 +1331,6 @@ pub(in crate::app::dispatch) fn set_auto_light_theme(
|
||||
}]
|
||||
}
|
||||
|
||||
// ── auto_light_theme (preview path) ─────────────────────────────────
|
||||
|
||||
/// Preview-only mutation for `auto_light_theme`. Mirror of
|
||||
/// `preview_auto_dark_theme_inner` for the light bucket.
|
||||
fn preview_auto_light_theme_inner(value: &str) {
|
||||
@@ -1394,12 +1371,10 @@ pub(in crate::app::dispatch) fn preview_auto_light_theme(
|
||||
vec![]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// default_model — resolves display name to `ModelId`, then emits both
|
||||
// `Effect::SwitchModel` (active session) and `Effect::PersistSetting`
|
||||
// (next-session default). No live preview (model switch has ACP side
|
||||
// effects).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State-only mutation for `default_model`: set
|
||||
/// `agent.session.models.current` to the supplied id. Returns `true`
|
||||
@@ -1490,7 +1465,6 @@ pub(in crate::app::dispatch) fn set_default_model(
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Idempotent: same model already active → no-op.
|
||||
if prev_id.as_ref() == Some(&new_id) {
|
||||
return vec![];
|
||||
}
|
||||
@@ -1531,7 +1505,7 @@ pub(in crate::app::dispatch) fn set_default_model(
|
||||
|
||||
// Best-effort session-level switch. The `Effect::SwitchModel`
|
||||
// pipeline handles its own deferred-switch semantics for the
|
||||
// no-session-id-yet case (see line 583 of this file).
|
||||
// no-session-id-yet case (see `deferred_model_switch` below).
|
||||
if let Some(sid) = session_id {
|
||||
// We already hold a reference path to the agent above; re-borrow
|
||||
// mutably here to flip `model_switch_pending`.
|
||||
@@ -1548,7 +1522,8 @@ pub(in crate::app::dispatch) fn set_default_model(
|
||||
} else if let Some(agent) = app.agents.get_mut(&aid) {
|
||||
// No session id yet — stash for
|
||||
// `EventLoop::on_session_created` to apply once the session
|
||||
// id materialises. Mirrors `Action::SwitchModel` line 586.
|
||||
// id materialises. Mirrors the deferred-switch handling in
|
||||
// `Action::SwitchModel`.
|
||||
agent.session.deferred_model_switch = Some((new_id, None));
|
||||
}
|
||||
effects
|
||||
@@ -1609,14 +1584,11 @@ pub(in crate::app::dispatch) fn clear_default_model(app: &mut AppView) -> Vec<Ef
|
||||
}]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model-family settings: fork_secondary_model (and formerly
|
||||
// web_search_model, session_summary_model, default_reasoning_effort).
|
||||
// Model-family settings: fork_secondary_model.
|
||||
//
|
||||
// SHELL-OWNED. Unlike `default_model`, these do NOT mutate live
|
||||
// runtime state — they update `current_ui` mirrors and persist.
|
||||
// No live preview. Rollback is purely disk + mirror.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State-only mutation for `fork_secondary_model`. Updates the
|
||||
/// `app.current_ui.fork_secondary_model` mirror so the modal
|
||||
@@ -1678,7 +1650,6 @@ pub(in crate::app::dispatch) fn set_fork_secondary_model(
|
||||
let new_id_str = new_id.0.to_string();
|
||||
let prev_id_str = app.current_ui.fork_secondary_model.clone();
|
||||
if prev_id_str == new_id_str {
|
||||
// Idempotent fast-path: no-op for redundant writes.
|
||||
return vec![];
|
||||
}
|
||||
set_fork_secondary_model_inner(app, new_id_str.clone());
|
||||
@@ -1706,7 +1677,6 @@ pub(in crate::app::dispatch) fn clear_fork_secondary_model(app: &mut AppView) ->
|
||||
let baseline = kigi_shell::models::default_model().to_string();
|
||||
let prev_id_str = app.current_ui.fork_secondary_model.clone();
|
||||
if prev_id_str == baseline {
|
||||
// Idempotent: already at baseline.
|
||||
app.show_toast("\u{2713} Fork secondary model: already at default");
|
||||
return vec![];
|
||||
}
|
||||
@@ -1730,15 +1700,9 @@ pub(in crate::app::dispatch) fn clear_fork_secondary_model(app: &mut AppView) ->
|
||||
}]
|
||||
}
|
||||
|
||||
// `session_summary_model` and `default_reasoning_effort` setters were
|
||||
// removed alongside their registry entries. Mirror fields and TOML
|
||||
// schema stay for compat.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// max_thoughts_width — Int-valued setting. Registry surface is `i64`;
|
||||
// clamped to `(min, max)` bounds and cast to `u16`. Live application
|
||||
// via `app.current_ui.max_thoughts_width`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Clamp `i64` to the registered `max_thoughts_width` bounds.
|
||||
/// Bounds imported from `settings::defs` (single source of truth).
|
||||
@@ -1762,8 +1726,7 @@ pub(in crate::app::dispatch) fn set_max_thoughts_width(app: &mut AppView, new: i
|
||||
let prev = app.current_ui.max_thoughts_width as i64;
|
||||
let clamped = clamp_max_thoughts_width(new);
|
||||
if prev == clamped {
|
||||
// Idempotent fast-path: no-op for redundant writes. Matches
|
||||
// the bool setters' idempotency contract.
|
||||
// Matches the bool setters' idempotency contract.
|
||||
return vec![];
|
||||
}
|
||||
set_max_thoughts_width_inner(app, new);
|
||||
@@ -1782,17 +1745,12 @@ pub(in crate::app::dispatch) fn set_max_thoughts_width(app: &mut AppView, new: i
|
||||
}]
|
||||
}
|
||||
|
||||
// `auto_compact_threshold_percent` setter was removed alongside its
|
||||
// registry entry. Mirror field stays for compat.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// show_tips, auto_update — SHELL-OWNED `Option<bool>` setters.
|
||||
// Changes take effect on next session start (restart_required: true).
|
||||
// Standard inner/outer split. First commit of the default value
|
||||
// persists (so the resolver sees user intent vs managed default).
|
||||
// Rollback restores `None` when the target equals the effective
|
||||
// default (keeps mirror in sync with on-disk state after failure).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Effective-default lookup for the `Option<bool>` AppView mirrors
|
||||
/// (`show_tips`, `auto_update`, ask_user_question timeout).
|
||||
@@ -1819,8 +1777,8 @@ pub(in crate::app::dispatch) fn set_show_tips(app: &mut AppView, new: bool) -> V
|
||||
let prev_state = app.show_tips;
|
||||
let prev_effective = prev_state.unwrap_or(true);
|
||||
if prev_effective == new && prev_state.is_some() {
|
||||
// Idempotent fast-path. `.is_some()` lets the first commit of
|
||||
// the default value persist (so the resolver sees user intent).
|
||||
// `.is_some()` lets the first commit of the default value
|
||||
// persist (so the resolver sees user intent).
|
||||
return vec![];
|
||||
}
|
||||
set_show_tips_inner(app, new);
|
||||
@@ -1864,10 +1822,8 @@ pub(in crate::app::dispatch) fn set_auto_update(app: &mut AppView, new: bool) ->
|
||||
}]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// display_refresh_auto_cadence — SHELL-OWNED nested Option on
|
||||
// `[ui.display_refresh].auto_cadence_enabled`. Restart-required.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State-only mutation for `display_refresh_auto_cadence`.
|
||||
pub(super) fn set_display_refresh_auto_cadence_inner(app: &mut AppView, value: bool) {
|
||||
|
||||
@@ -33,7 +33,6 @@ pub(in crate::app::dispatch) fn save_success_toast(label: &str, on: bool) -> Str
|
||||
/// snapshots by value; without this, toggles would appear stuck.
|
||||
pub(crate) fn refresh_open_settings_modals(app: &mut AppView) {
|
||||
use crate::views::modal::ActiveModal;
|
||||
// Early exit when no settings modal is open (common case).
|
||||
if !app.agents.values().any(|a| {
|
||||
matches!(
|
||||
a.active_modal,
|
||||
@@ -146,7 +145,6 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(app: &mut AppView) -> Vec
|
||||
// mutable borrow on `agent` so the borrow checker is happy.
|
||||
let registry = app.settings_registry.clone();
|
||||
let ui_snapshot = app.current_ui.clone();
|
||||
// Capture app-level fields before the mut-borrow on the agent.
|
||||
let show_tips_from_app = app.show_tips;
|
||||
let auto_update_from_app = app.auto_update;
|
||||
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
|
||||
@@ -304,7 +302,6 @@ pub(in crate::app::dispatch) fn dispatch_confirm_reset_setting(
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Take the ResetSettingsConfirm modal out and restore Settings.
|
||||
// Take the ResetSettingsConfirm modal out and capture the
|
||||
// target key from the variant (single source of truth — the
|
||||
// Action does NOT carry the key, eliminating the desync risk
|
||||
@@ -424,11 +421,9 @@ pub(in crate::app::dispatch) fn dispatch_toggle_compact_mode(app: &mut AppView)
|
||||
/// the `dispatch_toggle_multiline` / `dispatch_toggle_compact_mode` /
|
||||
/// `dispatch_toggle_timestamps` pattern.
|
||||
pub(in crate::app::dispatch) fn dispatch_toggle_vim_mode(app: &mut AppView) -> Vec<Effect> {
|
||||
// Toggle the EFFECTIVE value (the pager cache) so `/vim-mode` works
|
||||
// from ANY view — including the session-less dashboard. Previously
|
||||
// this early-returned unless an agent was active, so running
|
||||
// `/vim-mode` on the dashboard was a silent no-op and the overview's
|
||||
// j/k navigation (which is gated on vim-mode) never turned on.
|
||||
// Toggle the EFFECTIVE value (the pager cache), not a per-agent
|
||||
// field, so `/vim-mode` works from ANY view — including the
|
||||
// session-less dashboard, whose j/k navigation is gated on vim-mode.
|
||||
let prev = crate::appearance::cache::load_vim_mode();
|
||||
let enabled = !prev;
|
||||
// Propagate to every agent AND every nested subagent view (so
|
||||
@@ -786,7 +781,6 @@ pub(in crate::app::dispatch) fn action_for_reset(
|
||||
None
|
||||
}
|
||||
}
|
||||
// max_thoughts_width: direct round-trip.
|
||||
("max_thoughts_width", SettingValue::Int(i)) => Some(Action::SetMaxThoughtsWidth(*i)),
|
||||
// plan_mode: "on" / "off" → PlanModeKind.
|
||||
// "on" arm is a skew guard (default is "off").
|
||||
@@ -796,7 +790,6 @@ pub(in crate::app::dispatch) fn action_for_reset(
|
||||
("plan_mode", SettingValue::Enum("on")) => {
|
||||
Some(Action::SetPlanMode(crate::app::actions::PlanModeKind::On))
|
||||
}
|
||||
// show_tips / auto_update / display_refresh_auto_cadence: direct bool.
|
||||
("show_tips", SettingValue::Bool(b)) => Some(Action::SetShowTips(*b)),
|
||||
("auto_update", SettingValue::Bool(b)) => Some(Action::SetAutoUpdate(*b)),
|
||||
("display_refresh_auto_cadence", SettingValue::Bool(b)) => {
|
||||
@@ -953,7 +946,6 @@ pub(in crate::app::dispatch) fn apply_setting_rollback(
|
||||
default is unsaved",
|
||||
);
|
||||
}
|
||||
// max_thoughts_width: direct inner call.
|
||||
("max_thoughts_width", SettingValue::Int(i)) => set_max_thoughts_width_inner(app, *i),
|
||||
// scroll_speed: direct inner call (clamp handled by inner).
|
||||
("scroll_speed", SettingValue::Int(i)) => set_scroll_speed_inner(app, *i as u8),
|
||||
@@ -974,7 +966,6 @@ pub(in crate::app::dispatch) fn apply_setting_rollback(
|
||||
}
|
||||
}
|
||||
("scroll_lines", SettingValue::Int(i)) => set_scroll_lines_inner(app, *i as u8),
|
||||
// vim_mode: direct inner call.
|
||||
("vim_mode", SettingValue::Bool(b)) => set_vim_mode_inner(app, *b),
|
||||
("remember_tool_approvals", SettingValue::Bool(b)) => {
|
||||
set_remember_tool_approvals_inner(app, *b)
|
||||
|
||||
@@ -155,7 +155,6 @@ fn drain_clipboard_target(target: &ClipboardPasteTarget, app: &mut AppView) -> V
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Handle a completed async task result.
|
||||
pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec<Effect> {
|
||||
match result {
|
||||
TaskResult::SessionCreated {
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── agent-bound kinds (bash) ─────────
|
||||
|
||||
/// A bash command typed while a turn is RUNNING takes the
|
||||
/// server-authoritative immediate path (Effect + optimistic echo, no local
|
||||
/// queue entry).
|
||||
@@ -23,9 +21,7 @@ fn bash_while_running_is_server_authoritative() {
|
||||
}
|
||||
other => panic!("expected immediate SendBashCommand, got {other:?}"),
|
||||
};
|
||||
// Not in the local queue.
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 0);
|
||||
// Optimistic echo present with kind="bash".
|
||||
let q = app
|
||||
.shared_prompt_queue("test-session")
|
||||
.expect("echo present");
|
||||
@@ -54,8 +50,8 @@ fn auth_complete_triggers_bundle_status_fetch() {
|
||||
);
|
||||
|
||||
assert!(matches!(app.auth_state, AuthState::Done));
|
||||
// Pager only refreshes the on-disk catalog snapshot; the actual
|
||||
// bundle download now runs inside the shell post-auth.
|
||||
// Pager only refreshes the on-disk catalog snapshot; the bundle
|
||||
// download runs inside the shell post-auth.
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
|
||||
@@ -13,8 +13,6 @@ fn last_system_text(app: &AppView) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// ── /usage dispatch tests ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn show_usage_returns_fetch_usage_effect() {
|
||||
let mut app = test_app_with_agent();
|
||||
|
||||
@@ -1398,9 +1398,9 @@ fn dashboard_plan_description_transforms_snapshot_and_chip_ranges() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The sessions picker modal was removed; `/sessions` survives as an alias
|
||||
/// of `/dashboard`. It must resolve to the dashboard command and inherit
|
||||
/// the dashboard feature-flag gate (hidden by canonical name, fail-closed).
|
||||
/// `/sessions` is an alias of `/dashboard`; it must resolve to the
|
||||
/// dashboard command and inherit its feature-flag gate (hidden by
|
||||
/// canonical name, fail-closed).
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn dashboard_slash_sessions_aliases_dashboard() {
|
||||
@@ -1871,7 +1871,7 @@ fn dashboard_deferred_plan_mode_applied_on_session_created() {
|
||||
}
|
||||
|
||||
/// Any non-empty prompt — even a single character — dispatches a
|
||||
/// new session (the old 4-char floor was relaxed to 1 char).
|
||||
/// new session.
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn dashboard_dispatch_single_char_creates_session() {
|
||||
@@ -1971,10 +1971,7 @@ fn dashboard_dispatch_with_top_level_selection_creates_new_session() {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Enter / Ctrl+S behaviour matrix.
|
||||
//
|
||||
// Pins the contract the user spelled out (Ctrl+S is "send + open";
|
||||
// Enter / Ctrl+S behaviour matrix (Ctrl+S is "send + open";
|
||||
// Shift/Alt+Enter insert a newline):
|
||||
//
|
||||
// button + empty prompt + Enter → Create + open detail
|
||||
@@ -1987,11 +1984,6 @@ fn dashboard_dispatch_with_top_level_selection_creates_new_session() {
|
||||
// The dispatch input always spawns a NEW session — a selected row is
|
||||
// the navigation cursor (Enter on an empty prompt opens it), never a
|
||||
// reply target.
|
||||
//
|
||||
// Tests drive the whole stack — the state handler emits an
|
||||
// action, the dispatcher runs, and we assert the resulting
|
||||
// view + selection + attached_agent.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// 2 — Button focused + non-empty + Enter → new session,
|
||||
/// STAY on the dashboard, no attached_agent.
|
||||
@@ -2174,10 +2166,8 @@ fn dashboard_attach_top_level_switches_to_agent_view() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Attach routes through `focus_row`, so a previously selected
|
||||
/// section header is cleared — the row and section cursors stay
|
||||
/// mutually exclusive (a bare `selected` assignment used to leave
|
||||
/// both active).
|
||||
/// Attach routes through `focus_row`, so any selected section header
|
||||
/// is cleared — the row and section cursors stay mutually exclusive.
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn dashboard_attach_clears_selected_section() {
|
||||
@@ -2321,11 +2311,10 @@ fn dashboard_attach_subagent_lazily_replays_deferred_transcript() {
|
||||
/// No auto-attached popup. The user reaches an agent's view by
|
||||
/// pressing Enter on its row.
|
||||
///
|
||||
/// Opening from an agent now lands in NEW-SESSION mode (the
|
||||
/// Opening from an agent lands in NEW-SESSION mode (the
|
||||
/// `[+ New Agent]` button focused, no row selected) so typing +
|
||||
/// Enter dispatches a brand new agent. Previously the dashboard
|
||||
/// pre-seeded `selected` to the came-from agent, which armed reply
|
||||
/// mode and trapped the user replying to that one agent.
|
||||
/// Enter dispatches a brand new agent, rather than pre-seeding
|
||||
/// `selected` to the came-from agent and arming reply mode.
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn dashboard_open_does_not_auto_attach_to_focused_agent() {
|
||||
@@ -2439,12 +2428,9 @@ fn dashboard_dispatch_after_open_from_agent_spawns_new_sessions() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Opening from Welcome leaves the `[+ New Agent]`
|
||||
/// button as the default focus. Previously the dashboard
|
||||
/// seeded selection to the first agent so Enter would attach
|
||||
/// without navigating; with the button taking that role,
|
||||
/// selection stays empty and the button signals what Enter
|
||||
/// (on an empty prompt) will do.
|
||||
/// Opening from Welcome leaves the `[+ New Agent]` button as the
|
||||
/// default focus: selection stays empty, and the button signals
|
||||
/// what Enter (on an empty prompt) will do.
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn dashboard_open_from_welcome_focuses_new_agent_button() {
|
||||
@@ -2468,9 +2454,6 @@ fn dashboard_open_from_welcome_focuses_new_agent_button() {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Dashboard mouse-wheel scrolling
|
||||
//
|
||||
// Mouse wheel is intentionally decoupled from the selected row:
|
||||
// scrolling only moves the viewport, leaving `selected` alone.
|
||||
// `DashboardState::handle_scroll` flags
|
||||
@@ -2478,7 +2461,6 @@ fn dashboard_open_from_welcome_focuses_new_agent_button() {
|
||||
// `clamp_viewport` skips its snap-to-selection pull-back so the
|
||||
// viewport can travel past the cursor. Selection-driven nav
|
||||
// (arrows, click) clears the flag and re-engages the snap.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
@@ -3364,7 +3346,8 @@ fn dashboard_toggle_auto_approve_flips_yolo_on_selected_agent() {
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn dashboard_toggle_auto_approve_with_no_selection_toasts() {
|
||||
let mut app = test_app(); // no agent
|
||||
// no agent
|
||||
let mut app = test_app();
|
||||
open_dashboard(&mut app);
|
||||
let effects = dispatch_dashboard_toggle_auto_approve(&mut app);
|
||||
assert!(effects.is_empty());
|
||||
@@ -3439,13 +3422,9 @@ fn dashboard_rename_end_to_end_top_level_row() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `DashboardCancelRename` emits no effects and
|
||||
/// leaves `display_name` untouched. Previously named
|
||||
/// `dashboard_rename_cancel_via_esc_does_not_emit_effect`
|
||||
/// but that name implied Esc keystroke routing — the test
|
||||
/// actually dispatches `Action::DashboardCancelRename` directly.
|
||||
/// The Esc-keystroke routing is now pinned by the sibling test
|
||||
/// `dashboard_rename_esc_keystroke_routes_to_cancel`.
|
||||
/// `DashboardCancelRename` emits no effects and leaves `display_name`
|
||||
/// untouched. Esc-keystroke routing to this action is pinned separately
|
||||
/// by `dashboard_rename_esc_keystroke_routes_to_cancel`.
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn dashboard_rename_cancel_action_emits_no_effect() {
|
||||
@@ -3809,19 +3788,14 @@ fn dashboard_close_shortcuts_help_clears_modal() {
|
||||
assert!(app.dashboard.as_ref().unwrap().shortcuts_modal.is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// `[+ New Agent]` button
|
||||
//
|
||||
// The header button is the default cursor target when no row
|
||||
// is selected. Up-arrow from the first row, Esc deselect,
|
||||
// dashboard-open-from-welcome, and `reanchor_selection`-drops
|
||||
// all land here. Enter-with-empty-prompt and click both
|
||||
// dispatch `DashboardCreateNewAgentWithDetail`, which
|
||||
// creates a session AND switches into detail view. Enter
|
||||
// with a NON-empty prompt falls through to
|
||||
// `DashboardDispatch`, which creates the session but stays
|
||||
// `[+ New Agent]` button: the header button is the default cursor
|
||||
// target when no row is selected. Up-arrow from the first row, Esc
|
||||
// deselect, dashboard-open-from-welcome, and `reanchor_selection`-drops
|
||||
// all land here. Enter-with-empty-prompt and click both dispatch
|
||||
// `DashboardCreateNewAgentWithDetail`, which creates a session AND
|
||||
// switches into detail view. Enter with a NON-empty prompt falls
|
||||
// through to `DashboardDispatch`, which creates the session but stays
|
||||
// on the dashboard.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
@@ -3986,9 +3960,7 @@ fn dashboard_filter_state_known_token_via_dispatch() {
|
||||
));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Real extract_recent_lines coverage.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// count=0 returns empty even when scrollback has entries.
|
||||
#[test]
|
||||
@@ -4136,9 +4108,7 @@ fn extract_recent_lines_strips_ansi() {
|
||||
assert!(out[0].contains("evil"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Stop-confirm full lifecycle.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// A press > 2s after the first re-arms (does NOT close).
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
@@ -4254,7 +4224,8 @@ fn dashboard_permission_select_drops_stale_request() {
|
||||
last_response_truncated: false,
|
||||
question: Some("q?".into()),
|
||||
options: vec![("allow".into(), "Allow".into())],
|
||||
request_id: Some(123), // mismatched id
|
||||
// mismatched id
|
||||
request_id: Some(123),
|
||||
reject_option: None,
|
||||
},
|
||||
));
|
||||
@@ -4687,7 +4658,8 @@ fn dashboard_question_answer_sends_and_clears() {
|
||||
let effects = dispatch_dashboard_question_answer(
|
||||
&mut app,
|
||||
crate::views::dashboard::DashboardRowId::TopLevel(AgentId(0)),
|
||||
Some(1), // pick "Postgres"
|
||||
// pick "Postgres"
|
||||
Some(1),
|
||||
String::new(),
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
@@ -4745,7 +4717,8 @@ fn dashboard_question_answer_walks_multiple_questions() {
|
||||
let fields = compute_peek_fields(&row, &app.agents).expect("ask surfaced");
|
||||
assert!(fields.question.as_deref().unwrap().starts_with("(1/2)"));
|
||||
assert!(fields.request_id.is_none());
|
||||
assert_eq!(fields.reject_option, Some(2)); // 2 options + "Other"
|
||||
// 2 options + "Other"
|
||||
assert_eq!(fields.reject_option, Some(2));
|
||||
// Plant a peek with a stale draft to verify the advance reset.
|
||||
if let Some(d) = app.dashboard.as_mut() {
|
||||
let mut p = PeekPanelState::new(row.clone(), fields);
|
||||
@@ -4787,7 +4760,8 @@ fn dashboard_peek_auto_opens_for_selected_row() {
|
||||
let mut app = test_app_with_agent();
|
||||
mark_agent_nonempty(&mut app, AgentId(0));
|
||||
open_dashboard(&mut app);
|
||||
let area = Rect::new(0, 0, 80, 24); // tall enough for the peek
|
||||
// tall enough for the peek
|
||||
let area = Rect::new(0, 0, 80, 24);
|
||||
let reg = crate::actions::ActionRegistry::defaults();
|
||||
|
||||
// Select a row, then render → the peek opens by default.
|
||||
@@ -4972,7 +4946,7 @@ fn build_rows_keeps_pinned_empty_local_session() {
|
||||
assert_eq!(rows.len(), 1, "a pinned empty session is kept");
|
||||
}
|
||||
|
||||
// -- Conversation-origin roster rows (chat-mode dashboard fallback) ----
|
||||
// Conversation-origin roster rows (chat-mode dashboard fallback).
|
||||
|
||||
#[test]
|
||||
fn dashboard_attach_roster_focuses_existing_local_agent() {
|
||||
|
||||
@@ -373,7 +373,6 @@ fn authenticating_seq(app: &AppView) -> u64 {
|
||||
ref other => panic!("expected Authenticating, got {other:?}"),
|
||||
}
|
||||
}
|
||||
/// Extract text from the last system message in an agent's scrollback.
|
||||
fn last_system_text(app: &AppView, id: AgentId) -> String {
|
||||
system_text_from_end(app, id, 0)
|
||||
}
|
||||
@@ -648,8 +647,6 @@ fn open_session_picker_with(
|
||||
pending_delete: None,
|
||||
});
|
||||
}
|
||||
/// Toast strings match the expected format and contain on/off
|
||||
/// status.
|
||||
fn read_toast(app: &AppView) -> String {
|
||||
let agent = app.agents.get(&AgentId(0)).expect("agent must exist");
|
||||
agent
|
||||
@@ -748,7 +745,6 @@ fn agent_scrollback_len(app: &AppView) -> usize {
|
||||
app.agents.get(&AgentId(0)).unwrap().scrollback.len()
|
||||
}
|
||||
use crate::scrollback::blocks::UserPromptBlock;
|
||||
/// Helper: open the dashboard against an existing `app`.
|
||||
fn open_dashboard(app: &mut AppView) {
|
||||
let _ = dispatch_open_dashboard(app);
|
||||
}
|
||||
@@ -792,12 +788,10 @@ fn dashboard_row_order(app: &AppView) -> Vec<crate::views::dashboard::DashboardR
|
||||
/// Build a synthetic `PermissionViewState` with the given id and
|
||||
/// options. Pushes it to the agent's permission_queue.
|
||||
///
|
||||
/// Returns the response receiver so tests can verify
|
||||
/// the response was actually `send`'d through the oneshot. The
|
||||
/// previous version dropped the receiver (`_rx`), which let
|
||||
/// "happy-path" tests assert the queue was popped but masked
|
||||
/// regressions where the pop happened without the corresponding
|
||||
/// send.
|
||||
/// Returns the response receiver so tests can verify the response
|
||||
/// was actually `send`'d through the oneshot — a dropped receiver
|
||||
/// would let "happy-path" tests assert the queue was popped while
|
||||
/// masking regressions where the pop happens without a send.
|
||||
fn push_synthetic_permission(
|
||||
agent: &mut crate::app::agent_view::AgentView,
|
||||
id: usize,
|
||||
|
||||
@@ -147,8 +147,6 @@ fn accept_word_select_tip_no_op_when_tip_not_showing() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── /plan slash command tests ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn slash_plan_no_args_not_in_plan_enters_plan_mode() {
|
||||
let mut app = test_app_with_agent();
|
||||
@@ -296,7 +294,6 @@ fn set_plan_mode_mutates_only_active_agent_not_others() {
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// `set_yolo_mode` dispatcher unit tests (security-relevant)
|
||||
//
|
||||
// SHELL-owned, but with rollback semantics: a disk-write failure
|
||||
@@ -337,7 +334,6 @@ fn set_plan_mode_mutates_only_active_agent_not_others() {
|
||||
// - Failure toast: "✗ Could not save permission_mode: {error}"
|
||||
// — exact format pinned via `assert_eq!` in
|
||||
// `rollback_permission_mode_reverts_state_no_effect`.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/// Slash gate sync: both toggles stay offered while modes change; only the
|
||||
/// auto feature gate suppresses `/auto`.
|
||||
@@ -453,10 +449,8 @@ fn set_yolo_mode_off_to_on_emits_persist_with_rollback() {
|
||||
crate::app::actions::PermissionModePersist::WithRollback("ask"),
|
||||
"rollback must revert to the prior canonical (was 'ask')"
|
||||
);
|
||||
// Explicit session_id assertion
|
||||
// (previously hidden behind `..` — a regression that
|
||||
// dropped session_id silently broke the ACP
|
||||
// notification gate at effects.rs).
|
||||
// If dropped, session_id silently breaks the ACP
|
||||
// notification gate at effects.rs.
|
||||
assert!(
|
||||
session_id.is_some(),
|
||||
"session_id must be threaded through for ACP notification gating"
|
||||
@@ -892,7 +886,7 @@ fn set_yolo_mode_on_drains_multi_item_queue() {
|
||||
Ok(Ok(acp::RequestPermissionResponse {
|
||||
outcome: acp::RequestPermissionOutcome::Selected(_),
|
||||
..
|
||||
})) => {} // OK
|
||||
})) => {}
|
||||
other => panic!(
|
||||
"item {i} did not receive AllowOnce Selected response: {other:?} — \
|
||||
drain skipped items beyond the first?",
|
||||
@@ -970,7 +964,7 @@ fn set_yolo_mode_on_duplicate_dispatch_still_drains_queue() {
|
||||
Ok(Ok(acp::RequestPermissionResponse {
|
||||
outcome: acp::RequestPermissionOutcome::Selected(_),
|
||||
..
|
||||
})) => {} // OK
|
||||
})) => {}
|
||||
other => panic!(
|
||||
"duplicate dispatch must auto-approve the newly queued permission, got {other:?}",
|
||||
),
|
||||
@@ -1377,7 +1371,8 @@ fn cycle_mode_pre_session_normal_to_plan_does_not_persist_permission_mode() {
|
||||
/// mutation are all gated by the same `app.active_view` guard).
|
||||
#[test]
|
||||
fn set_yolo_mode_no_op_when_no_active_agent() {
|
||||
let mut app = test_app(); // no agent, active_view = Welcome
|
||||
// No agent, active_view = Welcome.
|
||||
let mut app = test_app();
|
||||
let default_yolo_before = app.default_yolo;
|
||||
let perm_mode_before = app.current_ui.permission_mode.clone();
|
||||
|
||||
@@ -1441,7 +1436,6 @@ fn set_yolo_mode_refreshes_open_modal_snapshots() {
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Dispatch-layer integration tests for
|
||||
// `Action::SetPermissionMode(kind)`.
|
||||
//
|
||||
@@ -1472,7 +1466,6 @@ fn set_yolo_mode_refreshes_open_modal_snapshots() {
|
||||
// - `apply_setting_rollback("permission_mode", Enum("default"))`
|
||||
// restores `current_ui.permission_mode = Some("default")`
|
||||
// (preserves canonical; doesn't re-emit any Effect).
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn set_permission_mode_default_overrides_canonical_to_default() {
|
||||
@@ -2130,14 +2123,12 @@ fn set_theme_auto_enables_auto_mode_and_persists_auto() {
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// set_plan_mode dispatch-level coverage.
|
||||
//
|
||||
// Mirrors the `coding_data_sharing` and `yolo` test
|
||||
// patterns. These exercise the dispatch path directly (not the
|
||||
// modal Enter path or the slash-command parser path), so they cover
|
||||
// the same plumbing every entry point ultimately funnels through.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Idempotent ON: dispatcher sees `prev == new`,
|
||||
/// toasts but emits NO Effect (saves a wasted ACP round-trip).
|
||||
|
||||
@@ -2,19 +2,15 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `ConfirmResetSetting
|
||||
/// { Reset }` on `permission_mode` (the security-critical SHELL Enum)
|
||||
/// dispatches `Action::SetPermissionMode(PermissionModeKind::Ask)`
|
||||
/// (the typed Action, per the modal-commit ↔ typed-setter
|
||||
/// rule) via recursive dispatch. Emits
|
||||
/// `Effect::PersistPermissionMode` — verifies the recursive
|
||||
/// dispatch reaches the YOLO pipeline through
|
||||
/// `set_permission_mode` rather than the legacy `set_yolo_mode`.
|
||||
/// Resetting `permission_mode` must dispatch the typed
|
||||
/// `Action::SetPermissionMode(PermissionModeKind::Ask)` via recursive
|
||||
/// dispatch, reaching the YOLO pipeline through `set_permission_mode`
|
||||
/// rather than the legacy `set_yolo_mode`.
|
||||
#[test]
|
||||
fn dispatch_confirm_reset_setting_reset_dispatches_set_permission_mode_for_permission_mode() {
|
||||
use crate::views::modal::ResetSettingsResult;
|
||||
let mut app = test_app_with_agent();
|
||||
// Flip yolo on first (default is OFF = "ask").
|
||||
// Default is OFF ("ask"); flip yolo on first so the reset is a real transition.
|
||||
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
||||
assert!(app.agents[&AgentId(0)].session.is_yolo());
|
||||
|
||||
@@ -36,7 +32,6 @@ fn dispatch_confirm_reset_setting_reset_dispatches_set_permission_mode_for_permi
|
||||
has_persist,
|
||||
"Reset of permission_mode must emit PersistPermissionMode, got {effects:?}",
|
||||
);
|
||||
// Agent's yolo flag is reset to default (off).
|
||||
assert!(
|
||||
!app.agents[&AgentId(0)].session.is_yolo(),
|
||||
"agent.session.yolo_mode must be reset to default (off)",
|
||||
@@ -105,7 +100,6 @@ fn set_yolo_mode_on_drains_permission_queue_with_allow_once() {
|
||||
|
||||
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
||||
|
||||
// Queue is drained.
|
||||
assert!(
|
||||
app.agents[&AgentId(0)].permission_queue.is_empty(),
|
||||
"YOLO ON must drain the permission_queue",
|
||||
@@ -231,7 +225,6 @@ fn set_permission_mode_always_approve_blocked_by_policy_pin() {
|
||||
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
||||
}
|
||||
|
||||
/// SetPermissionMode(Auto) persists auto and does not enable yolo.
|
||||
#[test]
|
||||
fn set_permission_mode_auto_persists_without_yolo() {
|
||||
use crate::app::actions::PermissionModeKind;
|
||||
@@ -283,27 +276,16 @@ fn set_permission_mode_auto_degrades_to_ask_when_gated_off() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Rollback with an unknown canonical: defensively defaults to
|
||||
/// "ask" (the safe fallback — fewer prompts on a corrupt
|
||||
/// rollback value is worse, more prompts is safer).
|
||||
///
|
||||
/// The previous docstring claimed "logs a
|
||||
/// warning and defaults to 'ask'" — the warning log is fired via
|
||||
/// `tracing::warn!` in `apply_setting_rollback`'s arm, but the
|
||||
/// test doesn't capture/assert it. The fix is documentary: the
|
||||
/// test pins the OBSERVABLE behaviour (state defaults to "ask")
|
||||
/// and acknowledges that the warn-log is best-effort visibility
|
||||
/// for developers, not a contract surface the test enforces.
|
||||
/// `tracing_test::traced_test` capture would be more rigorous
|
||||
/// but is not currently used in this crate.
|
||||
/// Rollback with an unknown canonical defensively defaults to "ask" (the
|
||||
/// safe fallback — fewer prompts on a corrupt rollback value is worse than
|
||||
/// more prompts). `apply_setting_rollback` also fires `tracing::warn!` for
|
||||
/// this case, but the test only pins the observable state, not the log.
|
||||
#[test]
|
||||
fn rollback_permission_mode_unknown_canonical_defaults_to_ask() {
|
||||
use crate::settings::SettingValue;
|
||||
let mut app = test_app_with_agent();
|
||||
// Pre-set to true.
|
||||
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
||||
|
||||
// Garbage canonical rolls back to "ask" (the safe default).
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SettingPersistFailed {
|
||||
key: "permission_mode",
|
||||
@@ -318,31 +300,23 @@ fn rollback_permission_mode_unknown_canonical_defaults_to_ask() {
|
||||
"unknown canonical → safe default (ask = no auto-approve)",
|
||||
);
|
||||
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
||||
// The failure toast is the standard
|
||||
// `✗ Could not save permission_mode: …` format. A future
|
||||
// enhancement could differentiate "schema corruption" from
|
||||
// "real disk failure" in the toast text, but currently the
|
||||
// user sees the same wording; pinned here so a future
|
||||
// divergence is intentional.
|
||||
// The toast text does not currently distinguish schema corruption from
|
||||
// a real disk failure; both produce the standard
|
||||
// `✗ Could not save permission_mode: …` wording.
|
||||
}
|
||||
|
||||
/// Rollback path refreshes open modal
|
||||
/// snapshots in the same way the success path does. Mirror of
|
||||
/// `set_yolo_mode_refreshes_open_modal_snapshots` for the
|
||||
/// `apply_setting_rollback` entry into `set_yolo_mode_inner`.
|
||||
/// Without this, a modal that's open when a disk write fails
|
||||
/// shows a stale "always-approve" indicator after the state
|
||||
/// has rolled back to "ask".
|
||||
/// The rollback path (`apply_setting_rollback` into `set_yolo_mode_inner`)
|
||||
/// must refresh open modal snapshots the same way the success path does.
|
||||
/// Without this, a modal open when a disk write fails would keep showing a
|
||||
/// stale "always-approve" indicator after the state rolls back to "ask".
|
||||
#[test]
|
||||
fn rollback_permission_mode_refreshes_open_modal_snapshots() {
|
||||
use crate::settings::SettingValue;
|
||||
use crate::views::modal::ActiveModal;
|
||||
|
||||
let mut app = test_app_with_agent();
|
||||
// Pre-set yolo=true via the typed setter so the rollback
|
||||
// captures real prior state.
|
||||
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
||||
// Open the modal AFTER the optimistic toggle so the open-time
|
||||
// Open the modal after the optimistic toggle so the open-time
|
||||
// snapshot reflects yolo=true.
|
||||
let _ = dispatch(Action::OpenSettings, &mut app);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
@@ -354,7 +328,6 @@ fn rollback_permission_mode_refreshes_open_modal_snapshots() {
|
||||
"pre-rollback snapshot reflects optimistic state (yolo=true)",
|
||||
);
|
||||
|
||||
// Simulate disk-write failure → rollback to "ask".
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SettingPersistFailed {
|
||||
key: "permission_mode",
|
||||
@@ -364,7 +337,6 @@ fn rollback_permission_mode_refreshes_open_modal_snapshots() {
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// The modal's snapshot MUST refresh to the rolled-back value.
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
|
||||
panic!("modal must stay open after rollback");
|
||||
@@ -395,9 +367,9 @@ fn set_permission_mode_ask_emits_brand_consistent_toast() {
|
||||
assert!(!app.agents[&AgentId(0)].session.is_yolo());
|
||||
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
||||
|
||||
// Toast brands as "Permission mode" not
|
||||
// "Always-approve". Previously the Ask arm reused `yolo_toast(false)`
|
||||
// which produced "✓ Always-approve: off" — a brand mismatch.
|
||||
// Toast must brand as "Permission mode", not "Always-approve" — the Ask
|
||||
// arm must not fall back to `yolo_toast(false)`, which produces the
|
||||
// wrong brand.
|
||||
let toast = app.agents[&AgentId(0)]
|
||||
.toast
|
||||
.as_ref()
|
||||
@@ -426,14 +398,12 @@ fn set_permission_mode_ask_emits_brand_consistent_toast() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test. A `--yolo`
|
||||
/// startup sets `agent.session.yolo_mode = true` but leaves
|
||||
/// `app.current_ui.permission_mode` at `None`. Without the
|
||||
/// LIVE-precedence capture, dispatching `SetPermissionMode(Default)`
|
||||
/// would produce `WithRollback("ask")` — diverging the pager from
|
||||
/// the shell on disk failure (the ACP suppress-on-failure gate
|
||||
/// keeps the shell at YOLO, but the pager would roll back to
|
||||
/// non-YOLO). This test pins the LIVE-precedence fix.
|
||||
/// A `--yolo` startup sets `agent.session.yolo_mode = true` but leaves
|
||||
/// `app.current_ui.permission_mode` at `None`. Without LIVE-precedence
|
||||
/// capture, dispatching `SetPermissionMode(Default)` would produce
|
||||
/// `WithRollback("ask")` — diverging the pager from the shell on disk
|
||||
/// failure (the ACP suppress-on-failure gate keeps the shell at YOLO, but
|
||||
/// the pager would roll back to non-YOLO).
|
||||
#[test]
|
||||
fn set_permission_mode_with_live_yolo_and_no_ui_mirror_rolls_back_to_always_approve() {
|
||||
use crate::app::actions::PermissionModeKind;
|
||||
@@ -450,8 +420,6 @@ fn set_permission_mode_with_live_yolo_and_no_ui_mirror_rolls_back_to_always_appr
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// The dispatch flipped yolo off (Default projects onto
|
||||
// bool=false) and set the canonical to "default".
|
||||
assert!(!app.agents[&AgentId(0)].session.is_yolo());
|
||||
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("default"));
|
||||
|
||||
@@ -473,17 +441,13 @@ fn set_permission_mode_with_live_yolo_and_no_ui_mirror_rolls_back_to_always_appr
|
||||
}
|
||||
}
|
||||
|
||||
/// `apply_setting_rollback("permission_mode",
|
||||
/// Enum("default"))` — the rollback arm that preserves the
|
||||
/// "default" canonical through a failed-persist. The headline
|
||||
/// architectural contract: rolling back to "default" must NOT
|
||||
/// collapse onto "ask" via the inner's bool projection.
|
||||
/// `apply_setting_rollback("permission_mode", Enum("default"))` must
|
||||
/// preserve the "default" canonical through a failed-persist, not collapse
|
||||
/// onto "ask" via the inner's bool projection.
|
||||
#[test]
|
||||
fn rollback_permission_mode_default_canonical_preserves_default() {
|
||||
use crate::settings::SettingValue;
|
||||
let mut app = test_app_with_agent();
|
||||
// Pre-flip to YOLO so the rollback has somewhere to roll
|
||||
// back FROM.
|
||||
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
||||
assert!(app.agents[&AgentId(0)].session.is_yolo());
|
||||
assert_eq!(
|
||||
@@ -491,8 +455,6 @@ fn rollback_permission_mode_default_canonical_preserves_default() {
|
||||
Some("always-approve"),
|
||||
);
|
||||
|
||||
// Simulate disk-write failure with `rollback_value =
|
||||
// Enum("default")`.
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::SettingPersistFailed {
|
||||
key: "permission_mode",
|
||||
@@ -509,7 +471,6 @@ fn rollback_permission_mode_default_canonical_preserves_default() {
|
||||
"rollback path must not re-emit Effects, got {effects:?}",
|
||||
);
|
||||
|
||||
// Yolo flipped to false (Default projects onto bool=false).
|
||||
assert!(
|
||||
!app.agents[&AgentId(0)].session.is_yolo(),
|
||||
"Default projects onto yolo=false; agent.session.yolo_mode must flip back",
|
||||
@@ -526,7 +487,6 @@ fn rollback_permission_mode_default_canonical_preserves_default() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-empty permission_queue → NeedsInput.
|
||||
#[test]
|
||||
fn classify_top_level_permission_queue_non_empty_is_needs_input() {
|
||||
use crate::views::dashboard::{RowState, classify_top_level};
|
||||
|
||||
@@ -110,8 +110,6 @@ fn show_undo_tip_no_op_when_flag_off() {
|
||||
assert!(!app.agents[&id].ephemeral_tip.is_active());
|
||||
}
|
||||
|
||||
// ── Small-screen tip (`show_small_screen_tip` + its one-shot trigger) ──
|
||||
|
||||
/// `show_small_screen_tip` on a drawable agent shows the tip and increments
|
||||
/// the per-session seen count in memory (nothing persisted — the fn returns
|
||||
/// nothing, so it cannot raise effects).
|
||||
@@ -557,9 +555,9 @@ fn chip_submit_while_enqueued_clears_follow_up_chips() {
|
||||
// A chip click submitted while a turn is RUNNING *and* the local queue
|
||||
// is non-empty takes the ENQUEUE path, not immediate-server-send:
|
||||
// `immediate_server_send_eligible` is false whenever `pending_prompts`
|
||||
// is non-empty. Before the fix, only the immediate-send branch cleared
|
||||
// the chips, so this path left them on screen after the user had already
|
||||
// acted on one. The clear now runs for every `SubmitFollowUp` path.
|
||||
// is non-empty. The chip clear must run on this path too, not just the
|
||||
// immediate-send branch, or the chips would linger on screen after the
|
||||
// user had already acted on one.
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
{
|
||||
@@ -586,7 +584,7 @@ fn chip_submit_while_enqueued_clears_follow_up_chips() {
|
||||
.any(|e| matches!(e, Effect::SendPrompt { text, .. } if text == "Summarize")),
|
||||
"chip must be enqueued, not immediate-sent, got {effects:?}"
|
||||
);
|
||||
// The chips are cleared on the enqueue path too (the bug fix).
|
||||
// The chips are cleared on the enqueue path too.
|
||||
assert!(
|
||||
app.agents[&id].follow_ups.is_none(),
|
||||
"enqueue chip path must clear chips"
|
||||
@@ -597,7 +595,6 @@ fn chip_submit_while_enqueued_clears_follow_up_chips() {
|
||||
fn send_prompt_while_running_queues_without_drain() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
// Simulate a running turn.
|
||||
app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning;
|
||||
|
||||
let effects = dispatch(Action::SendPrompt("queued".into()), &mut app);
|
||||
@@ -1689,8 +1686,6 @@ fn bash_while_idle_stays_on_local_path() {
|
||||
assert!(app.agents[&id].bash_turn);
|
||||
}
|
||||
|
||||
// ── Reconnect-pending dispatch guards ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn send_prompt_blocked_during_reconnect() {
|
||||
let mut app = test_app_with_agent();
|
||||
@@ -2049,7 +2044,8 @@ fn prompt_history_loaded_refreshes_open_history_search_with_current_query() {
|
||||
fn agent_send_before_paste_probe_keeps_image() {
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
let mut app = test_app_with_agent();
|
||||
app.project_picker_shown = true; // don't intercept the send with the picker
|
||||
// don't intercept the send with the picker
|
||||
app.project_picker_shown = true;
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
@@ -2131,7 +2127,8 @@ fn interject_before_paste_probe_keeps_image() {
|
||||
let id = AgentId(0);
|
||||
{
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
agent.session.state = AgentState::TurnRunning; // interject needs a live turn
|
||||
// interject needs a live turn
|
||||
agent.session.state = AgentState::TurnRunning;
|
||||
agent.set_active_pane(ActivePane::Prompt, true);
|
||||
agent.prompt.set_text("look at this");
|
||||
}
|
||||
@@ -2215,7 +2212,8 @@ fn interject_before_paste_probe_keeps_image() {
|
||||
#[test]
|
||||
fn agent_paste_completion_after_switch_does_not_send_to_other_agent() {
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
let mut app = test_app_with_agent(); // agent A = AgentId(0), active view
|
||||
// agent A = AgentId(0), active view
|
||||
let mut app = test_app_with_agent();
|
||||
app.project_picker_shown = true;
|
||||
let a = AgentId(0);
|
||||
let b = AgentId(1);
|
||||
@@ -2351,8 +2349,6 @@ fn slash_and_exit_input_does_not_trigger_project_picker() {
|
||||
assert!(!input_can_trigger_project_picker(" "));
|
||||
}
|
||||
|
||||
// ── Minimal-mode slash gate tests ───────────────────────────────────
|
||||
|
||||
/// Returns true if any system block in agent 0's scrollback contains
|
||||
/// `needle`. Avoids `last_system_text`'s "last block must be System" panic
|
||||
/// for the allowed-command control (which may leave no system block).
|
||||
@@ -2409,8 +2405,6 @@ fn minimal_mode_allows_mode_agnostic_slash_command() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── /queue (ShowQueue) dispatch tests ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn show_queue_empty_commits_empty_message() {
|
||||
let mut app = test_app_with_agent();
|
||||
@@ -2447,8 +2441,6 @@ fn show_queue_no_active_agent_is_noop() {
|
||||
assert!(effects.is_empty(), "ShowQueue without an agent is a no-op");
|
||||
}
|
||||
|
||||
// ── Send-now cancel marker suppression (PromptResponse rail) ────────
|
||||
|
||||
/// Count of "Turn cancelled by user …" marker blocks in the agent's scrollback.
|
||||
fn count_cancelled_markers(app: &AppView, id: AgentId) -> usize {
|
||||
let agent = &app.agents[&id];
|
||||
|
||||
@@ -437,7 +437,7 @@ fn slash_model_invalid_arg_produces_scrollback_error() {
|
||||
/// `/model` + Enter (required args missing) must NOT error into scrollback —
|
||||
/// it re-opens the prompt in the args phase so the existing dropdown lists
|
||||
/// the catalog (= the connected providers' models). This is the documented
|
||||
/// "Blocks" row of `is_command_complete`, which previously had no consumer.
|
||||
/// "Blocks" row of `is_command_complete`.
|
||||
#[test]
|
||||
fn slash_model_no_args_reopens_the_model_picker() {
|
||||
let mut app = test_app_with_agent();
|
||||
@@ -1183,9 +1183,7 @@ fn find_agent_by_session_id_finds_inactive_agent() {
|
||||
/// Cross-setting smoke test.
|
||||
/// Verifies that the dispatcher routes each Action to the
|
||||
/// correct setter (catches a copy-paste registration bug
|
||||
/// where two setters were swapped). The original 5-setting
|
||||
/// matrix shrank to 2 after the user-feedback drop of
|
||||
/// `session_picker_grouped` / `load_envrc` / `use_leader`.
|
||||
/// where two setters were swapped).
|
||||
#[test]
|
||||
fn pr13_each_setter_writes_to_its_own_mirror() {
|
||||
let mut app = test_app_with_agent();
|
||||
@@ -1201,9 +1199,8 @@ fn pr13_each_setter_writes_to_its_own_mirror() {
|
||||
/// Three-way alignment pin: the PAGER registry default must agree
|
||||
/// with `PagerLocalSnapshot::default()` (covered by
|
||||
/// `defaults_match_pager_state` in `registry::tests`) AND with
|
||||
/// `AgentView::new`'s runtime initializer. This is the third leg
|
||||
/// of the triangle that was previously missing — the
|
||||
/// registry test alone can't see `AgentView::new`'s constant.
|
||||
/// `AgentView::new`'s runtime initializer — the registry test alone
|
||||
/// can't see `AgentView::new`'s constant.
|
||||
#[test]
|
||||
fn pager_registry_default_matches_agent_view_new_initializer() {
|
||||
use crate::settings::{SettingKind, SettingOwner, SettingsRegistry};
|
||||
@@ -1267,9 +1264,9 @@ fn pager_registry_default_matches_agent_view_new_initializer() {
|
||||
}
|
||||
}
|
||||
/// If the user picks the regular "Yes, proceed" option (NOT
|
||||
/// enable-always-approve), the dispatcher must behave exactly as
|
||||
/// before — no PersistPermissionMode effect, no YOLO flip. Pins
|
||||
/// that the new code path is gated strictly on the id check.
|
||||
/// enable-always-approve), the dispatcher must emit no
|
||||
/// PersistPermissionMode effect and no YOLO flip: the
|
||||
/// always-approve path is gated strictly on the id check.
|
||||
#[test]
|
||||
fn regular_allow_once_does_not_trigger_always_approve_persist() {
|
||||
use std::sync::Arc;
|
||||
@@ -1404,7 +1401,6 @@ fn show_tasks_no_active_agent_is_noop() {
|
||||
let effects = dispatch(Action::ShowTasks, &mut app);
|
||||
assert!(effects.is_empty(), "ShowTasks without an agent is a no-op");
|
||||
}
|
||||
/// classify_top_level decision matrix.
|
||||
#[test]
|
||||
fn classify_top_level_branches() {
|
||||
use crate::views::dashboard::{RowState, classify_top_level};
|
||||
@@ -1563,7 +1559,6 @@ fn peek_label_reflects_last_response_type() {
|
||||
.push_block(RenderBlock::user_prompt("do it"));
|
||||
assert_eq!(extract_last_response_type(agent), "Idle");
|
||||
}
|
||||
/// agent.question_view.is_some() → NeedsInput.
|
||||
#[test]
|
||||
fn classify_top_level_question_view_some_is_needs_input() {
|
||||
use crate::views::dashboard::{RowState, classify_top_level};
|
||||
@@ -1602,7 +1597,6 @@ fn top_level_label_strips_control_characters() {
|
||||
);
|
||||
assert!(top.label.contains("evil"));
|
||||
}
|
||||
/// Build a synthetic MouseEvent for tests.
|
||||
fn mouse_event(
|
||||
kind: crossterm::event::MouseEventKind,
|
||||
col: u16,
|
||||
@@ -1615,11 +1609,6 @@ fn mouse_event(
|
||||
modifiers: crossterm::event::KeyModifiers::NONE,
|
||||
}
|
||||
}
|
||||
/// Left-click on a row selects it (single click).
|
||||
/// Single left-click on a row attaches the
|
||||
/// conversation immediately (was: selects only, required
|
||||
/// double-click to attach). The user explicitly reported the
|
||||
/// previous click-to-select behaviour as unresponsive.
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn mouse_left_click_attaches_immediately() {
|
||||
@@ -1645,11 +1634,8 @@ fn mouse_left_click_attaches_immediately() {
|
||||
}
|
||||
assert_eq!(d.selected, Some(id));
|
||||
}
|
||||
/// Every left-click attaches, including
|
||||
/// rapid repeated clicks. The previous design used a 500ms window
|
||||
/// to distinguish single (select) from double (attach) click;
|
||||
/// the new design makes every click attach so the user's mental
|
||||
/// model "click = open" always holds.
|
||||
/// There is no double-click window — every click attaches, so the
|
||||
/// user's mental model "click = open" always holds.
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn mouse_repeated_click_keeps_attaching() {
|
||||
@@ -1682,9 +1668,8 @@ fn mouse_repeated_click_keeps_attaching() {
|
||||
other => panic!("expected DashboardAttach on second click, got {other:?}"),
|
||||
}
|
||||
}
|
||||
/// Clicks after the previous 500ms-double-click
|
||||
/// window also attach (the previous test asserted single-click
|
||||
/// behaviour for >500ms-apart clicks; now every click attaches).
|
||||
/// Clicks more than 500ms apart also attach — there is no
|
||||
/// double-click window.
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn mouse_click_after_long_pause_still_attaches() {
|
||||
@@ -1715,7 +1700,6 @@ fn mouse_click_after_long_pause_still_attaches() {
|
||||
other => panic!("expected DashboardAttach, got {other:?}"),
|
||||
}
|
||||
}
|
||||
/// Click on the peek close-button rect closes the peek.
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn mouse_click_on_peek_close_rect_clears_peek() {
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
// ── Worktree session tests ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn open_new_worktree_dialog_sets_dialog_state() {
|
||||
let mut app = test_app();
|
||||
@@ -26,7 +24,6 @@ fn worktree_forked_sets_session_id_eagerly_and_emits_load() {
|
||||
);
|
||||
let id = AgentId(0);
|
||||
|
||||
// Before WorktreeForked: session_id is None, loading_replay is false.
|
||||
assert!(app.agents[&id].session.session_id.is_none());
|
||||
assert!(!app.agents[&id].session.loading_replay);
|
||||
|
||||
@@ -45,21 +42,17 @@ fn worktree_forked_sets_session_id_eagerly_and_emits_load() {
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// session_id set eagerly.
|
||||
assert_eq!(
|
||||
app.agents[&id].session.session_id,
|
||||
Some(acp::SessionId::new("forked-sess-1"))
|
||||
);
|
||||
// loading_replay enabled so UI suppresses redraws during replay.
|
||||
assert!(app.agents[&id].session.loading_replay);
|
||||
// CWD updated to worktree.
|
||||
assert_eq!(app.agents[&id].session.cwd, session_cwd);
|
||||
assert!(app.agents[&id].session.is_worktree);
|
||||
// Emits LoadSession effect.
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(&effects[0], Effect::LoadSession { session_id, .. }
|
||||
if session_id == "forked-sess-1"));
|
||||
// Scrollback has the "Worktree ready" message.
|
||||
assert!(!app.agents[&id].scrollback.is_empty());
|
||||
}
|
||||
|
||||
@@ -93,13 +86,11 @@ fn worktree_forked_with_restore_shows_summary_in_scrollback() {
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Should emit LoadSession.
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(
|
||||
&effects[0],
|
||||
Effect::LoadSession { session_id, .. } if session_id == "forked-sess-2"
|
||||
));
|
||||
// Scrollback should contain the restore summary.
|
||||
let has_restore_msg = app.agents[&id]
|
||||
.scrollback
|
||||
.entries_in_range(0..app.agents[&id].scrollback.len())
|
||||
@@ -161,7 +152,6 @@ fn worktree_forked_with_restore_failure_shows_warning_banner() {
|
||||
);
|
||||
assert!(text.contains("restore aborted"));
|
||||
assert!(text.contains("MERGE_HEAD present"));
|
||||
// The success banner must NOT also appear.
|
||||
let success_present = entries
|
||||
.iter()
|
||||
.any(|e| matches!(&e.block, RenderBlock::System(s) if s.text.contains("Code restored")));
|
||||
@@ -412,7 +402,6 @@ fn dispatch_fork_no_flag_non_git_skips_modal_and_forks_without_worktree() {
|
||||
Action::Fork(fork_args(None, Some("explore offline"))),
|
||||
&mut app,
|
||||
);
|
||||
// Must skip the modal and emit ForkSession immediately.
|
||||
assert!(
|
||||
matches!(effects.as_slice(), [Effect::ForkSession { .. }]),
|
||||
"non-git cwd must skip modal and emit ForkSession, got {effects:?}"
|
||||
@@ -450,7 +439,6 @@ fn dispatch_fork_no_flag_always_opens_question_modal() {
|
||||
}
|
||||
other => panic!("expected Fork, got {other:?}"),
|
||||
}
|
||||
// The modal must offer four options: Yes / No / Always / Never.
|
||||
assert_eq!(
|
||||
qv.questions[0].options.len(),
|
||||
4,
|
||||
|
||||
@@ -1846,7 +1846,6 @@ fn dashboard_stop_double_press_via_handle_key_closes_top_level() {
|
||||
"second Ctrl+X via handle_input must close the target agent (Issue 300 regression)",
|
||||
);
|
||||
}
|
||||
/// Top-level resolver round-trip via real AgentView.
|
||||
#[test]
|
||||
fn session_id_resolver_round_trip_top_level() {
|
||||
use crate::views::dashboard::{DashboardRowId, PersistedRowId, SessionIdResolver};
|
||||
@@ -1864,7 +1863,6 @@ fn session_id_resolver_round_trip_top_level() {
|
||||
};
|
||||
assert!(resolver.resolve(&absent).is_none());
|
||||
}
|
||||
/// Subagent resolver round-trip.
|
||||
#[test]
|
||||
fn session_id_resolver_round_trip_subagent() {
|
||||
use crate::views::dashboard::{DashboardRowId, PersistedRowId, SessionIdResolver};
|
||||
|
||||
@@ -1606,7 +1606,7 @@ fn build_mode_query_arms_debounce_despite_title_hits_and_force_skips_it() {
|
||||
);
|
||||
}
|
||||
/// Build mode: a sub-2-char query clears the content results AND
|
||||
/// invalidates the previously armed debounce, so its late expiry can't
|
||||
/// invalidates the debounce armed earlier, so its late expiry can't
|
||||
/// resurrect the search.
|
||||
#[test]
|
||||
fn build_mode_short_query_clears_results_and_invalidates_armed_debounce() {
|
||||
|
||||
@@ -73,8 +73,6 @@ fn session_created_with_flag_but_modal_closed_clears_flag_no_fetches() {
|
||||
assert!(!app.agents[&id].pending_extensions_fetch);
|
||||
}
|
||||
|
||||
// ── /new dispatcher tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dispatch_new_session_opens_question_modal_in_git_repo() {
|
||||
let mut app = new_session_test_app();
|
||||
@@ -125,8 +123,6 @@ fn dispatch_new_session_skips_modal_in_non_git_repo() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Session close (shared with dashboard) ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn close_inactive_agent_drops_it() {
|
||||
let mut app = three_agent_app();
|
||||
|
||||
@@ -184,9 +184,9 @@ fn stashed_model_keeps_model_when_unsupported() {
|
||||
|
||||
#[test]
|
||||
fn effort_max_rejected_when_model_offers_no_max() {
|
||||
// Since the Xhigh/Max split, "max" is its own canonical level — a model
|
||||
// whose menu has no max-valued option rejects it with the offered list
|
||||
// (previously the parse alias silently rode it onto the xhigh option).
|
||||
// "max" is its own canonical level, distinct from "xhigh": a model
|
||||
// whose menu has no max-valued option rejects it with the offered
|
||||
// list rather than falling back to the xhigh option.
|
||||
let models = models_with_current(true);
|
||||
let out = take_deferred_model_switch(None, &models, Some("max"));
|
||||
assert_eq!(
|
||||
|
||||
@@ -126,10 +126,10 @@ fn toggle_vim_mode_propagates_to_open_subagent_views() {
|
||||
);
|
||||
}
|
||||
/// `/vim-mode` must toggle vim from the DASHBOARD too (not just an
|
||||
/// agent view) — previously it early-returned unless an agent was
|
||||
/// active, so it was a silent no-op and the overview's j/k never
|
||||
/// turned on. Turning vim ON also focuses the overview so j/k
|
||||
/// navigate immediately; turning it OFF returns focus to the input.
|
||||
/// agent view) — an early return without an active agent would make
|
||||
/// this a silent no-op and the overview's j/k would never turn on.
|
||||
/// Turning vim ON also focuses the overview so j/k navigate
|
||||
/// immediately; turning it OFF returns focus to the input.
|
||||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn toggle_vim_mode_works_on_dashboard_and_focuses_overview() {
|
||||
@@ -776,11 +776,9 @@ fn refresh_open_settings_modals_updates_reset_confirm_settings_state() {
|
||||
_ => panic!("expected ResetSettingsConfirm still active"),
|
||||
}
|
||||
}
|
||||
/// The
|
||||
/// dispatch-arm bail-out path is verified directly via
|
||||
/// `#[should_panic]` in debug mode rather than the previous
|
||||
/// `if cfg!(debug_assertions) { return; }` placebo which gave
|
||||
/// debug-mode CI no coverage of the routing-bug bail-out.
|
||||
/// The dispatch-arm bail-out path is verified directly via
|
||||
/// `#[should_panic]` in debug mode, giving debug-mode CI real
|
||||
/// coverage of the routing-bug bail-out.
|
||||
#[test]
|
||||
#[cfg(debug_assertions)]
|
||||
#[should_panic(expected = "OpenResetConfirm dispatched without an open Settings modal")]
|
||||
|
||||
@@ -42,7 +42,6 @@ fn send_while_idle_with_nonempty_shared_queue_routes_to_server() {
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("expected immediate SendPrompt for 'c', got {effects:?}"));
|
||||
// Did NOT start a local turn or adopt "c" as the running prompt.
|
||||
assert!(
|
||||
!app.agents[&id].session.state.is_turn_running(),
|
||||
"must not promote 'c' to a local running turn"
|
||||
@@ -220,8 +219,6 @@ fn show_usage_on_welcome_screen_is_noop() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Minimal update-notice tests ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn minimal_update_notice_commits_a_system_block() {
|
||||
let mut app = test_app_with_agent();
|
||||
|
||||
@@ -541,16 +541,12 @@ fn switch_model_complete_success_updates_model_and_pushes_message() {
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Pending flag cleared.
|
||||
assert!(!app.agents[&id].session.model_switch_pending);
|
||||
// Current model updated.
|
||||
assert_eq!(
|
||||
app.agents[&id].session.models.current,
|
||||
Some(model_id.clone())
|
||||
);
|
||||
// Success message pushed to scrollback.
|
||||
assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback + 1);
|
||||
// PersistPreferredModel effect emitted.
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(
|
||||
&effects[0],
|
||||
@@ -632,7 +628,8 @@ fn switch_model_complete_persists_resolved_effort_from_catalog_meta() {
|
||||
Action::TaskComplete(TaskResult::SwitchModelComplete {
|
||||
agent_id: id,
|
||||
model_id: model_id.clone(),
|
||||
effort: None, // user typed `/model Blackbox 4.7` with no effort
|
||||
// user typed `/model Blackbox 4.7` with no effort
|
||||
effort: None,
|
||||
result: Ok(()),
|
||||
prev_model_id: None,
|
||||
}),
|
||||
@@ -752,9 +749,7 @@ fn switch_model_complete_failure_pushes_error_and_clears_pending() {
|
||||
assert!(effects.is_empty());
|
||||
// Pending flag cleared.
|
||||
assert!(!app.agents[&id].session.model_switch_pending);
|
||||
// Current model unchanged.
|
||||
assert_eq!(app.agents[&id].session.models.current, old_current);
|
||||
// Error message pushed to scrollback.
|
||||
assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback + 1);
|
||||
}
|
||||
|
||||
@@ -794,16 +789,13 @@ fn switch_model_incompatible_agent_shows_question_modal() {
|
||||
|
||||
// No effects emitted (modal is synchronous state).
|
||||
assert!(effects.is_empty());
|
||||
// Pending flag cleared.
|
||||
assert!(!app.agents[&id].session.model_switch_pending);
|
||||
// Question modal is open.
|
||||
assert!(app.agents[&id].question_view.is_some());
|
||||
let qv = app.agents[&id].question_view.as_ref().unwrap();
|
||||
assert!(matches!(
|
||||
qv.local_kind,
|
||||
Some(crate::views::question_view::LocalQuestionKind::AgentTypeMismatch { .. })
|
||||
));
|
||||
// No error message pushed to scrollback.
|
||||
assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback);
|
||||
}
|
||||
|
||||
@@ -855,7 +847,6 @@ fn incompatible_agent_rollback_restores_previous_model() {
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// models.current must be rolled back to the previous model.
|
||||
assert_eq!(
|
||||
app.agents[&id].session.models.current,
|
||||
Some(prev_model),
|
||||
@@ -901,12 +892,10 @@ fn incompatible_agent_closes_active_modal() {
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Active modal must be closed.
|
||||
assert!(
|
||||
app.agents[&id].active_modal.is_none(),
|
||||
"active modal must be closed when IncompatibleAgent fires",
|
||||
);
|
||||
// Question modal must be open.
|
||||
assert!(
|
||||
app.agents[&id].question_view.is_some(),
|
||||
"question modal must be open",
|
||||
@@ -950,7 +939,6 @@ fn same_agent_type_switch_no_modal() {
|
||||
// Model should be switched, no modal.
|
||||
assert_eq!(app.agents[&id].session.models.current, Some(model_b));
|
||||
assert!(app.agents[&id].question_view.is_none());
|
||||
// Should emit PersistPreferredModel.
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
@@ -965,10 +953,8 @@ fn switch_model_pending_lifecycle() {
|
||||
let id = AgentId(0);
|
||||
let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));
|
||||
|
||||
// Initially false.
|
||||
assert!(!app.agents[&id].session.model_switch_pending);
|
||||
|
||||
// Action sets pending.
|
||||
dispatch(
|
||||
Action::SwitchModel {
|
||||
model_id: model_id.clone(),
|
||||
@@ -978,7 +964,6 @@ fn switch_model_pending_lifecycle() {
|
||||
);
|
||||
assert!(app.agents[&id].session.model_switch_pending);
|
||||
|
||||
// TaskResult clears pending.
|
||||
dispatch(
|
||||
Action::TaskComplete(TaskResult::SwitchModelComplete {
|
||||
agent_id: id,
|
||||
@@ -1012,7 +997,6 @@ fn no_deferred_switch_means_no_extra_effect() {
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// No SwitchModel effect.
|
||||
assert!(
|
||||
!effects
|
||||
.iter()
|
||||
@@ -1178,8 +1162,6 @@ fn available_commands_refreshed_empty_is_noop() {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Session deletion from the /resume picker -----------------------
|
||||
|
||||
#[test]
|
||||
fn delete_session_complete_removes_only_matching_source_and_id() {
|
||||
use crate::views::modal::ActiveModal;
|
||||
@@ -1470,13 +1452,11 @@ fn rename_session_failed_keeps_local_display_name_and_pushes_system_block() {
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// No rollback.
|
||||
assert_eq!(
|
||||
app.agents[&AgentId(0)].display_name.as_deref(),
|
||||
Some("optimistic title"),
|
||||
"display_name must NOT roll back on RenameSessionFailed"
|
||||
);
|
||||
// System block appended with the error.
|
||||
let scrollback = &app.agents[&AgentId(0)].scrollback;
|
||||
assert_eq!(
|
||||
scrollback.len(),
|
||||
@@ -1500,10 +1480,9 @@ fn rename_session_failed_keeps_local_display_name_and_pushes_system_block() {
|
||||
fn rollback_known_key_reverts_cache_and_no_effect() {
|
||||
use crate::settings::SettingValue;
|
||||
let mut app = test_app_with_agent();
|
||||
// Toggle to true first.
|
||||
let _ = dispatch(Action::SetCompactMode(true), &mut app);
|
||||
assert!(app.current_ui.compact_mode);
|
||||
// Now simulate persist failure that rolls back to false.
|
||||
// Simulate persist failure that rolls back to false.
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::SettingPersistFailed {
|
||||
key: "compact_mode",
|
||||
@@ -1516,7 +1495,6 @@ fn rollback_known_key_reverts_cache_and_no_effect() {
|
||||
effects.is_empty(),
|
||||
"rollback path must NOT emit any new Effects (would loop)",
|
||||
);
|
||||
// Cache is reverted.
|
||||
assert!(!app.current_ui.compact_mode);
|
||||
}
|
||||
|
||||
@@ -1689,8 +1667,6 @@ fn rollback_to_always_approve_blocked_by_policy_pin() {
|
||||
assert!(!app.default_yolo);
|
||||
}
|
||||
|
||||
// -- SessionListLoaded ------------------------------------------------
|
||||
|
||||
/// Canary: an empty list surfaces the generic "no sessions" toast.
|
||||
#[test]
|
||||
fn session_list_empty_shows_generic_toast() {
|
||||
|
||||
@@ -195,8 +195,8 @@ fn open_block_viewer_uses_markdown_viewer_for_agent_message_with_image_ref() {
|
||||
|
||||
assert!(effects.is_empty());
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
// Agent messages with image refs now open the normal markdown viewer
|
||||
// (inline media rendering moved to the tool call block).
|
||||
// Agent messages with image refs open the normal markdown viewer;
|
||||
// inline media rendering happens in the tool call block.
|
||||
assert!(agent.block_viewer.is_some());
|
||||
}
|
||||
|
||||
@@ -230,15 +230,15 @@ fn open_block_viewer_opens_image_only_blocks_natively() {
|
||||
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
|
||||
let effects = dispatch(Action::OpenBlockViewer, &mut app);
|
||||
|
||||
// Generated media now opens in the OS-native viewer (fire-and-forget),
|
||||
// so neither the in-app block viewer nor image viewer is shown.
|
||||
// Generated media opens in the OS-native viewer (fire-and-forget);
|
||||
// neither the in-app block viewer nor image viewer is shown.
|
||||
assert!(effects.is_empty());
|
||||
let agent = app.agents.get(&id).unwrap();
|
||||
assert!(agent.block_viewer.is_none());
|
||||
assert!(agent.image_viewer.is_none());
|
||||
}
|
||||
|
||||
// -- Plugins tab: group-collapse seeding on PluginsListLoaded --------------
|
||||
// Plugins tab: group-collapse seeding on PluginsListLoaded
|
||||
|
||||
fn plugins_list_response() -> kigi_hooks_plugins_types::PluginsListResponse {
|
||||
use crate::views::extensions_modal::test_plugin_info;
|
||||
|
||||
@@ -8,7 +8,7 @@ use super::*;
|
||||
/// `Internal error: "session failed to respond"`. An `acp::Error` carries
|
||||
/// no `promptId`, so before the Err-arm gate this error was misattributed
|
||||
/// to the running turn and rendered as a spurious "Turn failed", detonating
|
||||
/// an unrelated in-flight turn. The handler now gates the Err arm on the
|
||||
/// an unrelated in-flight turn. The handler gates the Err arm on the
|
||||
/// `prompt_id` the pager minted for that RPC: an error whose id is NOT the
|
||||
/// running turn is discarded; the running turn is left untouched.
|
||||
#[test]
|
||||
@@ -1022,13 +1022,11 @@ fn bg_task_kill_failed_clears_pending_kill_on_inactive_agent() {
|
||||
assert!(task.kill_requested_at.is_none());
|
||||
}
|
||||
|
||||
/// build_rows handles many subagents (placeholder).
|
||||
#[test]
|
||||
fn build_rows_collapses_many_subagents() {
|
||||
use crate::views::dashboard::build_rows;
|
||||
let mut app = test_app_with_agent();
|
||||
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
||||
// Insert 9 subagents.
|
||||
for i in 0..9 {
|
||||
let info = make_test_subagent(&format!("c{i}"), &format!("sa{i}"));
|
||||
agent
|
||||
@@ -1078,7 +1076,6 @@ fn build_rows_seven_subagents_no_placeholder() {
|
||||
assert!(!rows.last().unwrap().is_more_placeholder);
|
||||
}
|
||||
|
||||
/// At the threshold (exactly 8), no placeholder.
|
||||
#[test]
|
||||
fn build_rows_eight_subagents_no_placeholder() {
|
||||
use crate::views::dashboard::build_rows;
|
||||
@@ -1208,7 +1205,6 @@ fn subagent_label_strips_control_characters() {
|
||||
"subagent label must not retain \\x1b: {:?}",
|
||||
sub.label
|
||||
);
|
||||
// Visible characters survive.
|
||||
assert!(
|
||||
sub.label.contains("evil"),
|
||||
"sanitised label should preserve printable characters, got {:?}",
|
||||
|
||||
@@ -12,7 +12,6 @@ use agent_client_protocol as acp;
|
||||
/// Copy the selected block's content to the system clipboard.
|
||||
///
|
||||
/// Respects the block's raw/pretty mode for markdown content.
|
||||
/// Shows a toast notification on theExtensionsTab
|
||||
pub(super) fn dispatch_copy_block_content(app: &mut AppView) {
|
||||
with_active_agent(app, |agent| {
|
||||
let Some(idx) = agent.scrollback.selected() else {
|
||||
@@ -254,7 +253,6 @@ pub(super) fn dispatch_open_block_viewer(app: &mut AppView) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to create a normal viewer for the selected block type.
|
||||
let viewer = match &entry.block {
|
||||
RenderBlock::Thinking(_) | RenderBlock::AgentMessage(_) => {
|
||||
BlockViewerPane::for_markdown(entry.id, entry)
|
||||
|
||||
@@ -68,8 +68,8 @@ pub(super) fn dispatch_cancel_turn(app: &mut AppView) -> Vec<Effect> {
|
||||
// response may have been lost in transit. Re-send instead of silently
|
||||
// no-opping (cancel is idempotent on the agent), so Ctrl+C / palette
|
||||
// CancelTurn is never a dead key on a stuck "Cancelling…" spinner.
|
||||
// Skips the subagent panel — that
|
||||
// choice was already made (or defaulted) on the first cancel.
|
||||
// Skips the subagent panel — that choice was already made (or
|
||||
// defaulted) on the first cancel.
|
||||
if agent.session.state.is_cancelling() {
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
return vec![];
|
||||
@@ -502,7 +502,6 @@ pub(super) fn dispatch_demote_to_background(app: &mut AppView) -> Vec<Effect> {
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
return vec![];
|
||||
};
|
||||
// Get the tool_call_id of the currently running execute tool
|
||||
let Some(tool_call_id) = agent
|
||||
.session
|
||||
.tracker
|
||||
|
||||
@@ -437,7 +437,6 @@ async fn persist_setting_type_mismatch_errors_compact_mode() {
|
||||
"error message must mention key + expected kind, got: {err}",
|
||||
);
|
||||
}
|
||||
/// Type-mismatch for `show_timestamps`.
|
||||
#[tokio::test]
|
||||
async fn persist_setting_type_mismatch_errors_show_timestamps() {
|
||||
use crate::settings::SettingValue;
|
||||
@@ -449,7 +448,6 @@ async fn persist_setting_type_mismatch_errors_show_timestamps() {
|
||||
"error message must mention key + expected kind, got: {err}",
|
||||
);
|
||||
}
|
||||
/// Type-mismatch for `show_timeline`.
|
||||
#[tokio::test]
|
||||
async fn persist_setting_type_mismatch_errors_show_timeline() {
|
||||
use crate::settings::SettingValue;
|
||||
@@ -460,7 +458,6 @@ async fn persist_setting_type_mismatch_errors_show_timeline() {
|
||||
"error message must mention key + expected kind, got: {err}",
|
||||
);
|
||||
}
|
||||
/// Type-mismatch for `simple_mode`.
|
||||
#[tokio::test]
|
||||
async fn persist_setting_type_mismatch_errors_simple_mode() {
|
||||
use crate::settings::SettingValue;
|
||||
|
||||
@@ -1070,7 +1070,8 @@ pub(crate) async fn run(
|
||||
Ok(ev) => {
|
||||
consecutive_event_errors = 0;
|
||||
if input_tx.send(ev).is_err() {
|
||||
break; // event loop has shut down
|
||||
// event loop has shut down
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -2124,8 +2125,6 @@ pub(crate) async fn run(
|
||||
Ok(make_run_result(&app))
|
||||
}
|
||||
|
||||
/// Schedule the next animation tick if there are running entries and none is pending.
|
||||
///
|
||||
/// Load `UiConfig` from the shell's layered config at startup.
|
||||
/// Falls back to `UiConfig::default()` on any failure.
|
||||
pub(crate) fn load_initial_ui_config() -> kigi_shell::agent::config::UiConfig {
|
||||
@@ -2164,16 +2163,6 @@ fn load_initial_config_session_bools() -> InitialConfigSessionBools {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether to pre-generate the automatic "return-from-away" recap right now.
|
||||
///
|
||||
/// True only when the terminal has been unfocused past the recap threshold
|
||||
/// (once per away period, gated by [`FocusTracker::recap_due`]), the shell has
|
||||
/// rolled out session recap (`session_recap_available`), the user has not opted
|
||||
/// out via `ui.notifications.session_recap`, and the active agent has *finished
|
||||
/// its turn* with nothing pending that could wake it — i.e. idle, no modal, no
|
||||
/// pending question, an established session, and no running background task (a
|
||||
/// bg task completing can auto-wake the agent). Generating it now means the
|
||||
/// recap is already in the scrollback when the user returns.
|
||||
/// Sync shell `sessionRecap` into execution gate + every existing slash surface.
|
||||
/// Dashboard created later is seeded in `dispatch_open_dashboard`.
|
||||
fn apply_session_recap_available(app: &mut AppView, available: bool) {
|
||||
@@ -2187,6 +2176,16 @@ fn apply_session_recap_available(app: &mut AppView, available: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether to pre-generate the automatic "return-from-away" recap right now.
|
||||
///
|
||||
/// True only when the terminal has been unfocused past the recap threshold
|
||||
/// (once per away period, gated by [`FocusTracker::recap_due`]), the shell has
|
||||
/// rolled out session recap (`session_recap_available`), the user has not opted
|
||||
/// out via `ui.notifications.session_recap`, and the active agent has *finished
|
||||
/// its turn* with nothing pending that could wake it — i.e. idle, no modal, no
|
||||
/// pending question, an established session, and no running background task (a
|
||||
/// bg task completing can auto-wake the agent). Generating it now means the
|
||||
/// recap is already in the scrollback when the user returns.
|
||||
fn should_pregenerate_away_recap(app: &AppView) -> bool {
|
||||
if !(app.session_recap_available
|
||||
&& app.notification_service.focus_tracker.recap_due()
|
||||
@@ -2206,6 +2205,7 @@ fn should_pregenerate_away_recap(app: &AppView) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Schedule the next animation tick if there are running entries and none is pending.
|
||||
fn schedule_tick(tick_at: &mut Option<Instant>, app: &AppView, interval: Duration) {
|
||||
if tick_at.is_none() {
|
||||
let interval = match app.tick_demand() {
|
||||
@@ -2531,8 +2531,6 @@ async fn drain_and_process(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Paste coalescing for terminals without bracketed paste ───────────
|
||||
|
||||
/// Timeout for the first extension round (detection). If no event
|
||||
/// arrives within this window the batch was a normal keystroke.
|
||||
const PASTE_DETECT_TIMEOUT: Duration = Duration::from_millis(2);
|
||||
@@ -2842,8 +2840,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::{KeyEvent, KeyEventState};
|
||||
|
||||
// ── plan_reconnect_load ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn plan_reconnect_load_requires_session_id() {
|
||||
let agent = crate::test_util::make_agent_view(None, "/work/project");
|
||||
@@ -2927,8 +2923,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── reconnect_restore_outcome ────────────────────────────────────────
|
||||
|
||||
/// The regression guard: one background tab fails, the active tab
|
||||
/// succeeds. The whole-reconnect flag goes false (toast says "failed"),
|
||||
/// but the active tab's OWN drain must still fire — a failed background tab
|
||||
@@ -3327,8 +3321,6 @@ mod tests {
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
// ── Multi-newline coalescing tests ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn coalesce_three_lines() {
|
||||
// "foo\nbar\nbaz" — 3 lines, 2 newlines.
|
||||
@@ -3368,8 +3360,6 @@ mod tests {
|
||||
assert_eq!(result[0], Event::Paste("a\nb\nc\nd\n".to_string()));
|
||||
}
|
||||
|
||||
// ── should_extend_for_paste tests ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extend_triggered_with_single_pasteable_key() {
|
||||
let events = vec![press(KeyCode::Char('a'))];
|
||||
@@ -3399,8 +3389,6 @@ mod tests {
|
||||
assert!(!should_extend_for_paste(&events));
|
||||
}
|
||||
|
||||
// ── merge_paste_fragments tests ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn merge_paste_and_key_fragments() {
|
||||
// Fragmented bracketed paste: Event::Paste + loose key events.
|
||||
@@ -3460,8 +3448,6 @@ mod tests {
|
||||
assert_eq!(result[0], Event::Paste("hello\nworld".to_string()));
|
||||
}
|
||||
|
||||
// ── is_pasteable_key_event filtering tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn pasteable_rejects_mouse_events() {
|
||||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
@@ -3601,8 +3587,6 @@ mod tests {
|
||||
assert_eq!(result.len(), 5);
|
||||
}
|
||||
|
||||
// ── Windows path-shape coalescing (drag-drop without bracketed paste) ─
|
||||
//
|
||||
// Windows-gated: the path-shape branch only exists on Windows
|
||||
// (other platforms reliably get bracketed paste for drag-drop).
|
||||
|
||||
@@ -3636,13 +3620,15 @@ mod tests {
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn coalesce_path_shape_rejects_short_or_non_path() {
|
||||
let short = "/foo.tx"; // 7 chars, below PATH_COALESCE_THRESHOLD
|
||||
// 7 chars, below PATH_COALESCE_THRESHOLD
|
||||
let short = "/foo.tx";
|
||||
assert!(
|
||||
coalesce_rapid_keys(press_run(short))
|
||||
.iter()
|
||||
.all(|e| matches!(e, Event::Key(_)))
|
||||
);
|
||||
let prose = "helloworld"; // 10 chars, no path anchor
|
||||
// 10 chars, no path anchor
|
||||
let prose = "helloworld";
|
||||
assert!(
|
||||
coalesce_rapid_keys(press_run(prose))
|
||||
.iter()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user