M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,889 @@
//! Agent status bar — composable right-aligned status items with separators.
//!
//! Provides [`AgentStatusBar`] which collects items as `Line<'static>` spans,
//! lays them out right-aligned with dim `│` separators, and renders into a
//! buffer row. Returns hit-test areas keyed by item ID.
//!
//! # Example
//!
//! ```ignore
//! let mut status = AgentStatusBar::new(&theme);
//! status.push("context", context_line);
//! status.push("badge", badge_line);
//! let areas = status.render(buf, status_bar_rect);
//! let context_area = areas.get("context");
//! ```
use std::collections::HashMap;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use super::context_bar::SEPARATOR;
use super::turn_status::SPINNER_DIVISOR;
use crate::app::agent::{GoalDisplayPhase, GoalDisplayState, GoalDisplayStatus};
use crate::app::agent_view::McpInitProgress;
use crate::theme::Theme;
/// A named status bar item.
struct StatusEntry {
/// Identifier for hit-test lookup (e.g., "context", "badge").
id: &'static str,
/// Pre-built styled content.
line: Line<'static>,
/// Display width in columns.
width: u16,
}
/// Builder for the agent status bar.
///
/// Collect items with [`push`], then call [`render`] to lay them out
/// right-aligned with separators and get back hit-test areas.
pub struct AgentStatusBar<'a> {
items: Vec<StatusEntry>,
theme: &'a Theme,
/// Padding from the right edge of the status bar area.
right_pad: u16,
}
impl<'a> AgentStatusBar<'a> {
/// Create a new empty status bar.
pub fn new(theme: &'a Theme) -> Self {
Self {
items: Vec::new(),
theme,
right_pad: 0,
}
}
/// Add an item to the status bar.
///
/// Items are rendered left-to-right in push order, but the entire
/// group is right-aligned within the status bar area.
pub fn push(&mut self, id: &'static str, line: Line<'static>) {
let width = line.width() as u16;
self.items.push(StatusEntry { id, line, width });
}
/// Build a separator span: ` │ ` in dim color.
fn separator(&self) -> Span<'static> {
Span::styled(
format!(" {SEPARATOR} "),
Style::default()
.fg(self.theme.gray_dim)
.bg(self.theme.bg_base),
)
}
/// Render all items right-aligned into the given area.
///
/// Layout: `··· item0 │ item1 │ item2` — separators appear only *between*
/// items, never before the first or after the last.
///
/// Returns a map of item ID → screen `Rect` for hit-testing.
pub fn render(self, buf: &mut Buffer, area: Rect) -> HashMap<&'static str, Rect> {
if area.height == 0 || area.width == 0 || self.items.is_empty() {
return HashMap::new();
}
// Fill background
buf.set_style(area, Style::default().bg(self.theme.bg_base));
let sep = self.separator();
let sep_w = sep.width() as u16; // 3
// Total width: items plus the separators *between* them only — no
// leading separator before the first item or trailing one after the
// last.
let items_width: u16 = self.items.iter().map(|e| e.width).sum();
let num_seps = (self.items.len() as u16).saturating_sub(1);
let total_width = items_width + num_seps * sep_w;
// Right-align: compute starting x
let start_x = area
.x
.saturating_add(area.width.saturating_sub(self.right_pad + total_width));
let mut x = start_x;
let mut areas = HashMap::new();
for (i, entry) in self.items.iter().enumerate() {
// Separator before every item except the first.
if i > 0 {
buf.set_span(x, area.y, &sep, sep_w);
x += sep_w;
}
// Render item
buf.set_line(x, area.y, &entry.line, entry.width);
areas.insert(
entry.id,
Rect {
x,
y: area.y,
width: entry.width,
height: 1,
},
);
x += entry.width;
}
areas
}
}
// ---------------------------------------------------------------------------
// Goal status line
// ---------------------------------------------------------------------------
/// Format a token count compactly: `500`, `1.5k`, `50k`, `1.5M`.
pub(crate) fn format_tokens_compact(tokens: i64) -> String {
let sign = if tokens < 0 { "-" } else { "" };
let abs = tokens.unsigned_abs();
if abs >= 1_000_000 {
let m = abs as f64 / 1_000_000.0;
format!("{sign}{}", format!("{m:.1}M").replace(".0M", "M"))
} else if abs >= 1_000 {
let k = abs as f64 / 1_000.0;
format!("{sign}{}", format!("{k:.1}k").replace(".0k", "k"))
} else {
tokens.to_string()
}
}
/// Format elapsed milliseconds compactly: `5s`, `3m`, `2h`.
fn format_elapsed_compact(ms: u64) -> String {
let secs = ms / 1000;
if secs >= 3600 {
format!("{}h", secs / 3600)
} else if secs >= 60 {
format!("{}m", secs / 60)
} else {
format!("{}s", secs)
}
}
/// Build the status-chip label. Paused variants render their
/// `pause_label()`, Budget → "Budget", Done → "Done"; an Active goal
/// uses the shared [`active_phase_label`] suffix.
fn goal_phase_label(goal: &GoalDisplayState) -> String {
match goal.status {
GoalDisplayStatus::UserPaused
| GoalDisplayStatus::BackOffPaused
| GoalDisplayStatus::NoProgressPaused
| GoalDisplayStatus::InfraPaused
| GoalDisplayStatus::Blocked => goal.status.pause_label().into(),
GoalDisplayStatus::BudgetLimited => "Budget".into(),
GoalDisplayStatus::Complete => "Done".into(),
GoalDisplayStatus::Active => active_phase_label(goal),
}
}
/// Live phase suffix for an Active goal — the single source of truth
/// shared by the status chip and the goal-detail modal so they cannot
/// disagree. The transient `verifying_completion` overlay wins, then
/// `planning`, then the steady-state phase.
pub fn active_phase_label(goal: &GoalDisplayState) -> String {
if goal.verifying_completion {
let attempts = classifier_attempts_label(goal);
// Omit the "(n/m)" suffix until the first counter arrives so the
// chip reads "Verifying" instead of a confusing "Verifying (0/0)".
return if attempts.is_empty() {
"Verifying".into()
} else {
format!("Verifying ({attempts})")
};
}
if goal.planning {
return "Planning".into();
}
match goal.phase {
GoalDisplayPhase::Idle => "Idle".into(),
GoalDisplayPhase::Planning => "Planning".into(),
GoalDisplayPhase::Executing => "Executing".into(),
}
}
/// Format the classifier "attempts: n/m" counter for both the
/// status chip and the modal so the two displays cannot drift.
/// Returns the empty string when both fields are absent / zero — no
/// classifier run has been reserved yet, so there is no meaningful
/// counter. Callers render it only when non-empty: the chip drops the
/// `(n/m)` suffix, the modal falls back to an em-dash.
pub fn classifier_attempts_label(goal: &GoalDisplayState) -> String {
let attempt = goal.classifier_runs_attempted.unwrap_or(0);
let max = goal.classifier_max_runs.unwrap_or(0);
if attempt == 0 && max == 0 {
return String::new();
}
format!("{attempt}/{max}")
}
/// Build a compact goal status `Line` for the agent status bar.
///
/// Format: `[Goal: {label}] {tokens} {elapsed}`
///
/// When `hovered` is true the label is bolded/underlined to signal
/// clickability. When the goal is `Active`, a braille spinner driven
/// by `tick` is prepended.
pub fn goal_status_line(
goal: &GoalDisplayState,
theme: &Theme,
hovered: bool,
tick: usize,
context_used: Option<u64>,
active_subagent_tokens: u64,
) -> Line<'static> {
let label = goal_phase_label(goal);
let tokens_str =
format_tokens_compact(goal.live_tokens_used(context_used, active_subagent_tokens));
let tokens_display = match goal.token_budget {
Some(budget) if budget > 0 => {
format!("{}/{} tokens", tokens_str, format_tokens_compact(budget))
}
_ => format!("{} tokens", tokens_str),
};
let elapsed_str = format_elapsed_compact(goal.live_elapsed_ms());
let dim_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
// Paused goals use an inverted warning-colour chip so the chip background
// visually matches the modal's `theme.warning` status row.
let mut label_style = if goal.status.is_paused() {
Style::default().fg(theme.bg_base).bg(theme.warning)
} else {
Style::default().fg(theme.accent_plan).bg(theme.bg_base)
};
if hovered {
label_style = label_style
.add_modifier(ratatui::style::Modifier::BOLD)
.add_modifier(ratatui::style::Modifier::UNDERLINED);
}
let is_active = matches!(goal.status, GoalDisplayStatus::Active);
let goal_text = if is_active {
let frames = crate::glyphs::dot_spinner_frames();
let frame = frames[(tick / 4) % frames.len()];
format!("{frame} Goal: {label}")
} else {
format!("Goal: {label}")
};
Line::from(vec![
Span::styled("[", dim_style),
Span::styled(goal_text, label_style),
Span::styled("]", dim_style),
Span::styled(format!(" {tokens_display} {elapsed_str}"), dim_style),
])
}
// ---------------------------------------------------------------------------
// MCP connecting indicator
// ---------------------------------------------------------------------------
/// Build the compact MCP-connecting indicator for the agent status bar.
///
/// Format: `⠋ MCP (1/4)` — a braille spinner (driven by `tick`, same cadence as
/// the turn-status spinner) followed by the connected/total server count.
/// Rendered in `theme.gray_dim` so it reads as dim, matching the directory path
/// shown on the same row.
///
/// Returns `None` while `progress.total == 0` (a startup seed). That state
/// renders `⠋ Starting session…` above the prompt (see
/// [`crate::views::turn_status`]) rather than as a chip here — the top-bar chip
/// only shows real server counts once the shell reports `total > 0`.
pub fn mcp_status_line(
progress: &McpInitProgress,
tick: u64,
theme: &Theme,
) -> Option<Line<'static>> {
if progress.total == 0 {
return None;
}
let frames = crate::glyphs::braille_spinner_frames();
let frame_idx = (tick / SPINNER_DIVISOR) as usize % frames.len();
let style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
Some(Line::from(vec![
Span::styled(format!("{} ", frames[frame_idx]), style),
Span::styled(
format!("MCP ({}/{})", progress.connected, progress.total),
style,
),
]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokens_compact_sub_thousand() {
assert_eq!(format_tokens_compact(0), "0");
assert_eq!(format_tokens_compact(500), "500");
assert_eq!(format_tokens_compact(999), "999");
}
#[test]
fn tokens_compact_thousands() {
assert_eq!(format_tokens_compact(1000), "1k");
assert_eq!(format_tokens_compact(1500), "1.5k");
assert_eq!(format_tokens_compact(12300), "12.3k");
assert_eq!(format_tokens_compact(50000), "50k");
assert_eq!(format_tokens_compact(100000), "100k");
}
#[test]
fn tokens_compact_negative() {
assert_eq!(format_tokens_compact(-500), "-500");
assert_eq!(format_tokens_compact(-1500), "-1.5k");
assert_eq!(format_tokens_compact(-1_000_000), "-1M");
}
#[test]
fn tokens_compact_millions() {
assert_eq!(format_tokens_compact(1_000_000), "1M");
assert_eq!(format_tokens_compact(1_500_000), "1.5M");
assert_eq!(format_tokens_compact(10_000_000), "10M");
}
#[test]
fn elapsed_compact_seconds() {
assert_eq!(format_elapsed_compact(0), "0s");
assert_eq!(format_elapsed_compact(5_000), "5s");
assert_eq!(format_elapsed_compact(59_999), "59s");
}
#[test]
fn elapsed_compact_minutes() {
assert_eq!(format_elapsed_compact(60_000), "1m");
assert_eq!(format_elapsed_compact(180_000), "3m");
assert_eq!(format_elapsed_compact(3_599_000), "59m");
}
#[test]
fn elapsed_compact_hours() {
assert_eq!(format_elapsed_compact(3_600_000), "1h");
assert_eq!(format_elapsed_compact(7_200_000), "2h");
}
fn make_goal(
status: GoalDisplayStatus,
phase: GoalDisplayPhase,
idx: Option<u32>,
total: u32,
completed: u32,
) -> GoalDisplayState {
GoalDisplayState {
goal_id: "g-1".into(),
objective: "Build widget".into(),
status,
phase,
token_budget: Some(50_000),
tokens_used: 12_300,
elapsed_ms: 180_000,
total_deliverables: total,
completed_deliverables: completed,
current_deliverable_id: idx,
current_deliverable_title: Some("Add CSS vars".into()),
current_subagent_role: None,
total_worker_rounds: 3,
total_verify_rounds: 1,
live_subagent_tokens: None,
live_tokens_by_model: Vec::new(),
live_context_pct: None,
live_turn_count: None,
live_tool_call_count: None,
last_event: None,
last_event_detail: None,
last_event_timestamp: None,
token_baseline: 0,
finished_subagent_tokens: 0,
deliverables: vec![],
pause_message: None,
classifier_runs_attempted: None,
classifier_max_runs: None,
last_classifier_verdict: None,
last_classifier_details_path: None,
last_classifier_details_exists: false,
verifying_completion: false,
planning: false,
received_at: std::time::Instant::now(),
elapsed_floor_ms: 0,
}
}
#[test]
fn phase_label_active_executing() {
let g = make_goal(
GoalDisplayStatus::Active,
GoalDisplayPhase::Executing,
None,
0,
0,
);
assert_eq!(goal_phase_label(&g), "Executing");
}
#[test]
fn phase_label_active_planning() {
let g = make_goal(
GoalDisplayStatus::Active,
GoalDisplayPhase::Planning,
None,
0,
0,
);
assert_eq!(goal_phase_label(&g), "Planning");
}
#[test]
fn phase_label_paused_variants() {
for (status, expected) in [
(GoalDisplayStatus::UserPaused, "Paused"),
(GoalDisplayStatus::BackOffPaused, "Paused (back-off)"),
(GoalDisplayStatus::NoProgressPaused, "Paused (no progress)"),
(GoalDisplayStatus::InfraPaused, "Paused (error)"),
(GoalDisplayStatus::Blocked, "Paused (verification blocked)"),
] {
let g = make_goal(status, GoalDisplayPhase::Executing, Some(0), 2, 0);
assert_eq!(goal_phase_label(&g), expected, "for {status:?}");
}
}
#[test]
fn goal_line_contains_pause_label_for_infra_paused() {
let g = make_goal(
GoalDisplayStatus::InfraPaused,
GoalDisplayPhase::Executing,
Some(1),
2,
0,
);
let t = Theme::current();
let line = goal_status_line(&g, &t, false, 0, None, 0);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("Paused (error)"));
}
#[test]
fn status_chip_shows_verifying_completion_when_flag_set() {
// Status-chip behaviour: an Active goal with
// `verifying_completion = true` renders the "Verifying (n/m)"
// label instead of the regular phase label so the user can see
// the classifier run.
let mut g = make_goal(
GoalDisplayStatus::Active,
GoalDisplayPhase::Executing,
None,
0,
0,
);
g.verifying_completion = true;
g.classifier_runs_attempted = Some(2);
g.classifier_max_runs = Some(3);
assert_eq!(goal_phase_label(&g), "Verifying (2/3)");
let t = Theme::current();
let line = goal_status_line(&g, &t, false, 0, None, 0);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("Verifying (2/3)"));
}
#[test]
fn status_chip_verifying_omits_counter_when_counts_absent() {
// Before the first counter arrives (both fields None) the chip reads
// "Verifying", not "Verifying (0/0)".
let mut g = make_goal(
GoalDisplayStatus::Active,
GoalDisplayPhase::Executing,
None,
0,
0,
);
g.verifying_completion = true;
g.classifier_runs_attempted = None;
g.classifier_max_runs = None;
assert_eq!(goal_phase_label(&g), "Verifying");
}
#[test]
fn classifier_attempts_label_empty_when_both_absent_or_zero() {
let mut g = make_goal(
GoalDisplayStatus::Active,
GoalDisplayPhase::Executing,
None,
0,
0,
);
// Both None → empty (no run reserved yet).
assert_eq!(classifier_attempts_label(&g), "");
// Explicit zeros also count as "no counter".
g.classifier_runs_attempted = Some(0);
g.classifier_max_runs = Some(0);
assert_eq!(classifier_attempts_label(&g), "");
// A configured cap (max > 0) makes the counter meaningful.
g.classifier_max_runs = Some(3);
assert_eq!(classifier_attempts_label(&g), "0/3");
g.classifier_runs_attempted = Some(2);
assert_eq!(classifier_attempts_label(&g), "2/3");
}
#[test]
fn live_elapsed_ms_clamps_to_carried_floor() {
// The displayed clock must never tick below the carried monotonic
// floor, even when the latest authoritative base is lower (the
// pager's extrapolation outran the shell's flush point).
let mut g = make_goal(
GoalDisplayStatus::UserPaused,
GoalDisplayPhase::Idle,
None,
0,
0,
);
g.elapsed_ms = 1_000;
g.elapsed_floor_ms = 5_000;
assert_eq!(g.live_elapsed_ms(), 5_000);
}
#[test]
fn live_elapsed_ms_uses_live_value_when_above_floor() {
let mut g = make_goal(
GoalDisplayStatus::UserPaused,
GoalDisplayPhase::Idle,
None,
0,
0,
);
g.elapsed_ms = 9_000;
g.elapsed_floor_ms = 5_000;
assert_eq!(g.live_elapsed_ms(), 9_000);
}
#[test]
fn status_chip_shows_planning_when_flag_set() {
// An Active goal with `planning = true` renders the "Planning"
// label instead of the regular phase label so the user can see
// the planner subagent run while it executes.
let mut g = make_goal(
GoalDisplayStatus::Active,
GoalDisplayPhase::Idle,
None,
0,
0,
);
g.planning = true;
assert_eq!(goal_phase_label(&g), "Planning");
let t = Theme::current();
let line = goal_status_line(&g, &t, false, 0, None, 0);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("Planning"));
}
#[test]
fn status_chip_verifying_wins_over_planning() {
// Deterministic precedence: the two flags never overlap in
// practice, but if both were set `verifying_completion` wins.
let mut g = make_goal(
GoalDisplayStatus::Active,
GoalDisplayPhase::Idle,
None,
0,
0,
);
g.planning = true;
g.verifying_completion = true;
g.classifier_runs_attempted = Some(1);
g.classifier_max_runs = Some(2);
assert_eq!(goal_phase_label(&g), "Verifying (1/2)");
}
#[test]
fn status_chip_planning_suppressed_on_non_active_status() {
// The "Planning" label is gated on `Active`: a paused goal that
// somehow still carries `planning = true` shows its terminal
// label, not the in-flight one.
let mut g = make_goal(
GoalDisplayStatus::UserPaused,
GoalDisplayPhase::Idle,
None,
0,
0,
);
g.planning = true;
assert_eq!(goal_phase_label(&g), "Paused");
}
#[test]
fn status_chip_verifying_suppressed_on_non_active_status() {
// The chip text is gated on `Active`: a paused / complete /
// budget-limited goal that somehow still carries
// `verifying_completion = true` must show its terminal label,
// not the in-flight one.
let mut g = make_goal(
GoalDisplayStatus::UserPaused,
GoalDisplayPhase::Executing,
None,
0,
0,
);
g.verifying_completion = true;
assert_eq!(goal_phase_label(&g), "Paused");
}
#[test]
fn goal_line_paused_chip_uses_warning_background() {
// Paused chips render with the
// `theme.warning` background to visually warn the user. Pin the
// background colour on the label span so a regression that drops
// the chip-vs-modal colour alignment gets caught.
//
// Use the unquantized `groknight()` theme directly so warning and
// bg_base remain distinguishable in the test env — `Theme::current()`
// collapses both to ANSI `Reset` on 16-colour terminals, which
// would defeat the assertion.
let g = make_goal(
GoalDisplayStatus::UserPaused,
GoalDisplayPhase::Executing,
Some(0),
2,
0,
);
let t = Theme::groknight();
let line = goal_status_line(&g, &t, false, 0, None, 0);
// The label span is the one whose content starts with "Goal:".
let label_span = line
.spans
.iter()
.find(|s| s.content.contains("Goal:"))
.expect("label span with `Goal:` prefix");
assert_eq!(label_span.style.bg, Some(t.warning));
assert_ne!(label_span.style.bg, Some(t.bg_base));
}
#[test]
fn goal_line_active_chip_does_not_use_warning_background() {
// Negative companion: an Active goal must keep the standard
// accent-plan-on-bg-base chip style.
let g = make_goal(
GoalDisplayStatus::Active,
GoalDisplayPhase::Executing,
Some(0),
2,
0,
);
let t = Theme::groknight();
let line = goal_status_line(&g, &t, false, 0, None, 0);
let label_span = line
.spans
.iter()
.find(|s| s.content.contains("Goal:"))
.expect("label span with `Goal:` prefix");
assert_eq!(label_span.style.bg, Some(t.bg_base));
assert_ne!(label_span.style.bg, Some(t.warning));
}
#[test]
fn phase_label_budget_limited() {
let g = make_goal(
GoalDisplayStatus::BudgetLimited,
GoalDisplayPhase::Executing,
Some(1),
2,
0,
);
assert_eq!(goal_phase_label(&g), "Budget");
}
#[test]
fn phase_label_complete() {
let g = make_goal(
GoalDisplayStatus::Complete,
GoalDisplayPhase::Idle,
None,
3,
3,
);
assert_eq!(goal_phase_label(&g), "Done");
}
#[test]
fn phase_label_active_idle() {
let g = make_goal(
GoalDisplayStatus::Active,
GoalDisplayPhase::Idle,
None,
0,
0,
);
assert_eq!(goal_phase_label(&g), "Idle");
}
#[test]
fn phase_label_executing_ignores_deliverables() {
let g = make_goal(
GoalDisplayStatus::Active,
GoalDisplayPhase::Executing,
None,
4,
2,
);
assert_eq!(goal_phase_label(&g), "Executing");
}
// The old deliverable-index parity test is removed because deliverables
// are no longer part of the simplified goal model.
#[test]
fn goal_line_contains_expected_text() {
let g = make_goal(
GoalDisplayStatus::Active,
GoalDisplayPhase::Executing,
Some(1),
4,
1,
);
let t = Theme::current();
let line = goal_status_line(&g, &t, false, 0, None, 0);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("Goal: Exec"));
assert!(text.contains("12.3k/50k tokens"));
assert!(text.contains("3m"));
}
#[test]
fn goal_line_without_budget() {
let mut g = make_goal(
GoalDisplayStatus::Active,
GoalDisplayPhase::Planning,
None,
0,
0,
);
g.token_budget = None;
g.tokens_used = 500;
let t = Theme::current();
let line = goal_status_line(&g, &t, false, 0, None, 0);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("500 tokens"));
assert!(!text.contains('/'));
}
#[test]
fn goal_line_no_title_in_status_bar() {
let g = make_goal(
GoalDisplayStatus::Active,
GoalDisplayPhase::Executing,
None,
1,
0,
);
let t = Theme::current();
let line = goal_status_line(&g, &t, false, 0, None, 0);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
// Title should NOT appear in status bar (only in the modal)
assert!(!text.contains("Add CSS vars"));
assert!(!text.contains("Build widget"));
}
#[test]
fn mcp_status_line_renders_compact_count() {
// total > 0 renders the compact `MCP (connected/total)` chip.
let progress = McpInitProgress {
total: 4,
connected: 1,
started_at: std::time::Instant::now(),
};
let t = Theme::current();
let line = mcp_status_line(&progress, 0, &t).expect("total > 0 must render a line");
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("MCP (1/4)"),
"expected 'MCP (1/4)', got: {text:?}"
);
}
#[test]
fn mcp_status_line_uses_dim_directory_color() {
// The chip must render in `theme.gray_dim` to match the directory path.
let t = Theme::groknight();
let progress = McpInitProgress {
total: 2,
connected: 0,
started_at: std::time::Instant::now(),
};
let line = mcp_status_line(&progress, 0, &t).expect("total > 0 must render a line");
for span in &line.spans {
assert_eq!(
span.style.fg,
Some(t.gray_dim),
"MCP chip spans must use theme.gray_dim"
);
}
}
#[test]
fn mcp_status_line_hidden_for_zero_total() {
// total == 0 (startup seed) renders nothing in the top bar — that state
// shows "Starting session…" above the prompt instead.
let progress = McpInitProgress {
total: 0,
connected: 0,
started_at: std::time::Instant::now(),
};
let t = Theme::current();
assert!(mcp_status_line(&progress, 0, &t).is_none());
}
/// Separators appear only *between* items — never before the first item or
/// after the last (no leading/trailing divider).
#[test]
fn status_bar_separators_only_between_items() {
let theme = Theme::current();
let mut bar = AgentStatusBar::new(&theme);
bar.push("a", Line::from("AA"));
bar.push("b", Line::from("BB"));
bar.push("c", Line::from("CC"));
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
bar.render(&mut buf, area);
let row: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect();
let trimmed = row.trim();
// Exactly two dividers (between the three items), none at the ends.
assert_eq!(trimmed.matches(SEPARATOR).count(), 2, "row = {trimmed:?}");
assert!(
!trimmed.starts_with(SEPARATOR),
"no leading divider, row = {trimmed:?}"
);
assert!(
!trimmed.ends_with(SEPARATOR),
"no trailing divider, row = {trimmed:?}"
);
assert_eq!(trimmed, format!("AA {SEPARATOR} BB {SEPARATOR} CC"));
}
/// A single item renders with no separators at all (it is both first and
/// last).
#[test]
fn status_bar_single_item_has_no_separators() {
let theme = Theme::current();
let mut bar = AgentStatusBar::new(&theme);
bar.push("only", Line::from("XX"));
let area = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::empty(area);
bar.render(&mut buf, area);
let row: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect();
assert_eq!(row.trim(), "XX");
assert!(!row.contains(SEPARATOR));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,876 @@
//! `/btw` side question inline panel.
//!
//! Renders as a compact bordered panel above the prompt input box,
//! below the scrollback. Shows the question and a loading indicator
//! while the response is in-flight. Once the response arrives the
//! panel stays on screen until the user presses Esc, at which point
//! the content is persisted to scrollback as a collapsed `BtwBlock`.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Widget};
use unicode_width::UnicodeWidthStr;
use crate::render::osc8::{LinkOverlay, scan_lines_for_url_overlays};
use crate::scrollback::blocks::markdown_content::MarkdownContent;
use crate::scrollback::render::map_hyperlinks_to_overlay;
use crate::scrollback::text_selection::{
ResolvedSelectableLine, ResolvedSelectionModel, VisibleBlockGeometry,
};
use crate::theme::Theme;
/// Synthetic entry index for btw overlay selection (never collides with real scrollback).
pub const BTW_OVERLAY_ENTRY_IDX: usize = usize::MAX;
const BTW_OVERLAY_RANGE_ID: u16 = 0;
/// State of the /btw inline panel.
#[derive(Debug, Clone)]
pub enum BtwOverlayState {
/// Waiting for the shell to respond.
Loading { question: String },
/// Response received — stays on screen until Esc.
Done {
question: String,
/// Rendered markdown content (same renderer as regular agent messages).
/// Boxed to keep the enum small (`MarkdownContent` is large).
content: Box<MarkdownContent>,
/// Line offset for scrolling through long responses.
scroll_offset: usize,
},
/// Request failed (shown until user presses Esc).
Error { question: String, error: String },
}
impl BtwOverlayState {
/// Build a `Done` state, rendering `response` as markdown via the same
/// [`MarkdownContent`] renderer used for regular agent messages so the
/// inline panel shows formatted tables, headings, lists, etc.
pub fn done(question: String, response: String) -> Self {
Self::Done {
question,
content: Box::new(MarkdownContent::new(response)),
scroll_offset: 0,
}
}
pub fn question(&self) -> &str {
match self {
Self::Loading { question }
| Self::Done { question, .. }
| Self::Error { question, .. } => question,
}
}
/// Scroll the Done response up by `n` lines. No-op for other states.
pub fn scroll_up(&mut self, n: usize) {
if let Self::Done { scroll_offset, .. } = self {
*scroll_offset = scroll_offset.saturating_sub(n);
}
}
/// Scroll the Done response down by `n` lines, clamped to `max_offset`.
pub fn scroll_down(&mut self, n: usize, max_offset: usize) {
if let Self::Done { scroll_offset, .. } = self {
*scroll_offset = (*scroll_offset + n).min(max_offset);
}
}
/// Current scroll offset (0 for non-Done states).
pub fn scroll_offset(&self) -> usize {
match self {
Self::Done { scroll_offset, .. } => *scroll_offset,
_ => 0,
}
}
/// Max scroll offset for the Done response at `content_width`.
/// Returns 0 if the response fits within `max_body_lines`.
pub fn max_scroll_offset(&self, content_width: usize, max_body_lines: usize) -> usize {
match self {
Self::Done { content, .. } => {
if content_width == 0 {
return 0;
}
let total = content.with_wrapped_lines(content_width, |w| w.lines.len());
total.saturating_sub(max_body_lines)
}
_ => 0,
}
}
pub fn full_selection_model(&self, content_width: usize) -> ResolvedSelectionModel {
let mut model = ResolvedSelectionModel::default();
let Self::Done { content, .. } = self else {
return model;
};
if content_width == 0 {
return model;
}
content.with_wrapped_lines(content_width, |wrapped| {
for (idx, (line, joiner)) in
wrapped.lines.iter().zip(wrapped.joiners.iter()).enumerate()
{
let text = line_plain_text(line);
// The markdown wrapper emits `None` for the first piece of each
// source line (reconstruct treats that as a newline) and
// `Some(" ")` for soft-wrapped continuations.
let joiner_to_previous = if idx == 0 { None } else { joiner.clone() };
model.push_line(ResolvedSelectableLine {
entry_idx: BTW_OVERLAY_ENTRY_IDX,
range_id: BTW_OVERLAY_RANGE_ID,
block_line_idx: idx,
screen_y: 0,
screen_x: 0,
selectable_cols: 0..text.width() as u16,
text,
joiner_to_previous,
});
}
});
model
}
}
/// Show each spinner frame for this many animation ticks.
const SPINNER_DIVISOR: u64 = 4;
/// Maximum body lines shown for a Done response.
pub const DONE_MAX_BODY_LINES: u16 = 12;
/// Concatenate a line's span contents into plain text (styles stripped).
///
/// Used to build the selection model from rendered markdown lines.
fn line_plain_text(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
/// Compute the desired height of the btw inline panel.
///
/// Returns 0 when there is nothing to show (state is `None`).
/// Loading / Error = 3 rows (top border + 1 body + bottom border).
/// Done = 2 (borders) + min(wrapped response lines, DONE_MAX_BODY_LINES).
///
/// `content_width` is the available width for body text (panel width minus
/// border and padding — typically `inner_width - 4`).
pub fn btw_panel_height(state: Option<&BtwOverlayState>, content_width: u16) -> u16 {
match state {
None => 0,
Some(BtwOverlayState::Loading { .. } | BtwOverlayState::Error { .. }) => 3,
Some(BtwOverlayState::Done { content, .. }) => {
let cw = content_width.saturating_sub(4) as usize; // border + pad
let total = if cw > 0 {
content.with_wrapped_lines(cw, |w| w.lines.len())
} else {
1
};
let body = total.clamp(1, DONE_MAX_BODY_LINES as usize) as u16;
2 + body // top border + body + bottom border
}
}
}
/// Render the /btw inline panel into the given rect.
///
/// The panel renders as a compact bordered box with the question in the
/// top border and the status in the body. It sits in the normal layout
/// flow (above queue / turn status / prompt).
///
/// When `link_overlay` is `Some`, markdown hyperlinks in the Done body are
/// mapped into screen-space overlay links (same path as scrollback) so OSC 8
/// and click-to-open work inside the panel.
#[allow(clippy::too_many_arguments)]
pub fn render_btw_panel(
buf: &mut Buffer,
state: &BtwOverlayState,
area: Rect,
tick: u64,
focused: bool,
hit_close: Option<&mut crate::app::agent_view::HitArea>,
selection_model: &mut ResolvedSelectionModel,
link_overlay: Option<&mut LinkOverlay>,
// Generated-media paths for resolving relative file-path link targets.
media_paths: &[std::path::PathBuf],
) {
if area.width < 12 || area.height < 3 {
return;
}
let theme = Theme::current();
let bg = theme.bg_base;
let content_x = area.x + 2;
let content_width = area.width.saturating_sub(4) as usize;
if content_width == 0 {
return;
}
// Only show focus (accent ring + ↑↓ hint) when there's actually something to
// scroll; `max_scroll_offset` is 0 for non-Done states and answers that fit.
let max_body = area.height.saturating_sub(2) as usize;
let focus_active = focused && state.max_scroll_offset(content_width, max_body) > 0;
let border_color = if focus_active {
theme.accent_user
} else {
theme.gray_dim
};
let border_style = Style::default().fg(border_color).bg(bg);
// ── Clear area and draw rounded border ──
Clear.render(area, buf);
buf.set_style(area, Style::default().bg(bg));
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.style(Style::default().bg(bg))
.render(area, buf);
// ── Hint in top border (right side): scroll position + [Esc] ──
// Built BEFORE the title so the title can reserve room for it and truncate
// the question, rather than the question pushing [Esc] off-screen. The close
// affordance ([Esc]) always stays visible: its columns are reserved here
// first, and on panels too narrow for the full Done-state hint we drop the
// scroll indicator and keep a bare "[Esc]" (fallback below).
let hint = match state {
BtwOverlayState::Loading { .. } | BtwOverlayState::Error { .. } => "[Esc]".to_string(),
BtwOverlayState::Done {
content,
scroll_offset,
..
} => {
let total = content.with_wrapped_lines(content_width, |w| w.lines.len());
if total > max_body {
// Clamp offset to valid range in case terminal resized or
// content_width differs from what the input handler estimated.
let offset = (*scroll_offset).min(total.saturating_sub(max_body));
let pos = offset + 1;
let end = (offset + max_body).min(total);
if focus_active {
format!("{pos}-{end}/{total} \u{2191}\u{2193} [Esc]")
} else {
// Not focused: arrows go to the prompt, so omit the ↑↓ hint.
format!("{pos}-{end}/{total} [Esc]")
}
} else {
"[Esc]".to_string()
}
}
};
let title_x = area.x + 2;
let mut hint_text = format!(" {hint} ");
let mut hint_w = hint_text.width() as u16;
// Right-align the hint just inside the right border, without underflowing on
// very narrow panels.
let mut hint_x = (area.x + area.width).saturating_sub(1 + hint_w);
// On a narrow panel the Done-state hint (scroll position + ↑↓ + [Esc]) can be
// wide enough to leave no room for the title (hint_x < title_x). Fall back to
// a bare "[Esc]" so the close affordance — and its mouse hit target — always
// survives; at 7 columns it fits at the minimum panel width (12), and the
// title regains room too.
if hint_x < title_x {
hint_text = " [Esc] ".to_string();
hint_w = hint_text.width() as u16;
hint_x = (area.x + area.width).saturating_sub(1 + hint_w);
}
// ── Title in top border: " /btw <question> " ──
// Reserve the hint's columns (everything left of `hint_x`, minus the title's
// own two padding spaces) so a long question truncates instead of hiding the
// hint.
let question = state.question();
let title_prefix = "/btw ";
let max_title = hint_x.saturating_sub(title_x).saturating_sub(2) as usize;
let title_style = Style::default()
.fg(theme.accent_user)
.bg(bg)
.add_modifier(Modifier::BOLD);
let full_title = format!("{title_prefix}{question}");
let truncated = if full_title.width() > max_title {
let mut s = String::new();
let mut w = 0;
for ch in full_title.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if w + cw + 1 > max_title {
break;
}
s.push(ch);
w += cw;
}
s.push('\u{2026}');
s
} else {
full_title
};
let title_text = format!(" {truncated} ");
let title_line = Line::from(Span::styled(title_text.clone(), title_style));
// Clamp the paint width so the title can never bleed into the hint region,
// even in degenerate narrow layouts.
let title_render_w = (title_text.width() as u16).min(hint_x.saturating_sub(title_x));
buf.set_line(title_x, area.y, &title_line, title_render_w);
// ── Render the hint (always visible — its space was reserved above) ──
if hint_w > 0 && hint_x >= title_x {
let is_hovered = hit_close.as_ref().is_some_and(|h| h.hovered);
let hint_style = if is_hovered {
Style::default()
.fg(theme.text_primary)
.bg(bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.gray).bg(bg)
};
let hint_line = Line::from(Span::styled(hint_text, hint_style));
buf.set_line(hint_x, area.y, &hint_line, hint_w);
// Set hit area for mouse click handling (top border row).
if let Some(hit) = hit_close {
hit.set(Some(Rect {
x: hint_x,
y: area.y,
width: hint_w,
height: 1,
}));
}
} else if let Some(hit) = hit_close {
hit.clear();
}
// ── Body (between borders) ──
let body_y = area.y + 1;
match state {
BtwOverlayState::Loading { .. } => {
let frames = crate::glyphs::braille_spinner_frames();
let frame_idx = ((tick / SPINNER_DIVISOR) % frames.len() as u64) as usize;
let spinner = frames[frame_idx];
let loading_style = Style::default().fg(theme.gray).bg(bg);
let line = Line::from(vec![
Span::styled(format!("{spinner} "), loading_style),
Span::styled("Answering\u{2026}", loading_style),
]);
buf.set_line(content_x, body_y, &line, content_width as u16);
}
BtwOverlayState::Done {
content,
scroll_offset,
..
} => {
// One wrap pass for paint, selection, and link mapping (same as
// scrollback reusing cached BlockOutput).
let block_output = content.output(content_width);
let total = block_output.lines.len();
let content_skip = (*scroll_offset).min(total.saturating_sub(max_body));
let end = (content_skip + max_body).min(total);
let visible_count = end.saturating_sub(content_skip);
for (row, idx) in (content_skip..end).enumerate() {
let bl = &block_output.lines[idx];
buf.set_line(
content_x,
body_y + row as u16,
&bl.content,
content_width as u16,
);
let text = line_plain_text(&bl.content);
let joiner_to_previous = if idx == 0 { None } else { bl.joiner.clone() };
selection_model.push_line(ResolvedSelectableLine {
entry_idx: BTW_OVERLAY_ENTRY_IDX,
range_id: BTW_OVERLAY_RANGE_ID,
block_line_idx: idx,
screen_y: body_y + row as u16,
screen_x: content_x,
selectable_cols: 0..text.width() as u16,
text,
joiner_to_previous,
});
}
if visible_count > 0 {
let body_area = Rect {
x: content_x,
y: body_y,
width: content_width as u16,
height: visible_count as u16,
};
selection_model.content_area = body_area;
selection_model.visible_blocks.push(VisibleBlockGeometry {
entry_idx: BTW_OVERLAY_ENTRY_IDX,
area: body_area,
content_area: body_area,
selection_area: body_area,
content_width: content_width as u16,
top_clipped: false,
bottom_clipped: false,
drag_startable: true,
});
}
// Markdown hyperlinks + plain-text URL / file-path scan (parity
// with scrollback's map_hyperlinks + scan_lines_for_url_overlays).
if let Some(overlay) = link_overlay {
let max_screen_y = body_y.saturating_add(visible_count as u16);
content.with_hyperlinks(|hyperlinks| {
if hyperlinks.is_empty() {
return;
}
map_hyperlinks_to_overlay(
hyperlinks,
&block_output,
content_skip,
body_y,
max_screen_y,
content_x,
/* content_line_offset */ 0,
media_paths,
overlay,
);
});
let visible_lines = block_output
.lines
.iter()
.enumerate()
.skip(content_skip)
.map(|(idx, bl)| {
let visible_offset = (idx - content_skip) as u16;
let screen_row = body_y + visible_offset;
(screen_row, &bl.content, bl.joiner.as_deref())
})
.take_while(|(screen_row, _, _)| *screen_row < max_screen_y);
scan_lines_for_url_overlays(visible_lines, content_x, media_paths, overlay);
}
}
BtwOverlayState::Error { error, .. } => {
let error_style = Style::default().fg(theme.accent_error).bg(bg);
let msg = if error.width() > content_width {
let mut s = String::new();
let mut w = 0;
for ch in error.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if w + cw + 1 > content_width {
break;
}
s.push(ch);
w += cw;
}
s.push('\u{2026}');
s
} else {
error.clone()
};
let line = Line::from(Span::styled(msg, error_style));
buf.set_line(content_x, body_y, &line, content_width as u16);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render_with_model(
state: &BtwOverlayState,
width: u16,
height: u16,
) -> ResolvedSelectionModel {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
let mut model = ResolvedSelectionModel::default();
render_btw_panel(&mut buf, state, area, 0, false, None, &mut model, None, &[]);
model
}
/// Render the panel and return the raw buffer for cell inspection.
fn render_to_buffer(state: &BtwOverlayState, width: u16, height: u16) -> Buffer {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
let mut model = ResolvedSelectionModel::default();
render_btw_panel(&mut buf, state, area, 0, false, None, &mut model, None, &[]);
buf
}
/// Concatenated symbols of buffer row `y` across `width` columns.
fn row_text(buf: &Buffer, width: u16, y: u16) -> String {
(0..width)
.filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
.collect()
}
fn render_with_links(
state: &BtwOverlayState,
width: u16,
height: u16,
) -> (ResolvedSelectionModel, LinkOverlay) {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
let mut model = ResolvedSelectionModel::default();
let mut links = LinkOverlay::new();
render_btw_panel(
&mut buf,
state,
area,
0,
false,
None,
&mut model,
Some(&mut links),
&[],
);
(model, links)
}
/// Build a Done state with an explicit scroll offset.
fn done_with_scroll(response: &str, scroll_offset: usize) -> BtwOverlayState {
let mut state = BtwOverlayState::done("q".to_string(), response.to_string());
if let BtwOverlayState::Done {
scroll_offset: so, ..
} = &mut state
{
*so = scroll_offset;
}
state
}
/// `n` distinct rendered lines via CommonMark hard breaks (two trailing
/// spaces), so each `lineNN` maps 1:1 to a rendered line.
fn hard_break_lines(n: usize) -> String {
(0..n)
.map(|i| format!("line{i:02}"))
.collect::<Vec<_>>()
.join(" \n")
}
#[test]
fn done_state_populates_selection_model() {
let state = BtwOverlayState::done(
"test".to_string(),
"line one and line two and line three".to_string(),
);
let model = render_with_model(&state, 40, 8);
assert!(!model.ranges.is_empty(), "should have selectable ranges");
let range = &model.ranges[0];
assert_eq!(range.entry_idx, BTW_OVERLAY_ENTRY_IDX);
assert_eq!(range.range_id, BTW_OVERLAY_RANGE_ID);
assert!(!range.lines.is_empty());
for (i, line) in range.lines.iter().enumerate() {
assert_eq!(line.block_line_idx, i);
assert_eq!(line.screen_y, 1 + i as u16); // body_y = area.y + 1
}
assert!(
!model.visible_blocks.is_empty(),
"should have visible block geometry"
);
}
#[test]
fn loading_state_does_not_populate_selection_model() {
let state = BtwOverlayState::Loading {
question: "q".to_string(),
};
let model = render_with_model(&state, 40, 4);
assert!(model.ranges.is_empty());
assert!(model.visible_blocks.is_empty());
}
#[test]
fn error_state_does_not_populate_selection_model() {
let state = BtwOverlayState::Error {
question: "q".to_string(),
error: "something went wrong".to_string(),
};
let model = render_with_model(&state, 40, 4);
assert!(model.ranges.is_empty());
assert!(model.visible_blocks.is_empty());
}
#[test]
fn scroll_offset_shifts_block_line_idx() {
// 10 short lines, each on its own rendered line (hard breaks).
let response = hard_break_lines(10);
let state_0 = done_with_scroll(&response, 0);
let state_2 = done_with_scroll(&response, 2);
// height=6 → max_body=4; 10 lines → offset 2 is valid.
let model_0 = render_with_model(&state_0, 40, 6);
let model_2 = render_with_model(&state_2, 40, 6);
assert!(!model_0.ranges.is_empty());
assert!(!model_2.ranges.is_empty());
assert_eq!(model_0.ranges[0].lines[0].block_line_idx, 0);
assert_eq!(model_2.ranges[0].lines[0].block_line_idx, 2);
}
#[test]
fn full_selection_model_spans_entire_response() {
let response = hard_break_lines(20);
let state = done_with_scroll(&response, 8);
let model = state.full_selection_model(40);
assert_eq!(model.ranges.len(), 1);
assert_eq!(model.ranges[0].lines.len(), 20);
assert_eq!(model.ranges[0].lines[0].block_line_idx, 0);
assert_eq!(model.ranges[0].lines[19].block_line_idx, 19);
assert_eq!(model.ranges[0].lines[19].text, "line19");
}
#[test]
fn copy_includes_lines_scrolled_out_of_view() {
use crate::scrollback::text_selection::{
ActiveTextDrag, RangeHit, reconstruct_selection_text,
};
let response = hard_break_lines(20);
let state = done_with_scroll(&response, 8);
let full_model = state.full_selection_model(40);
let drag = ActiveTextDrag {
anchor: RangeHit {
entry_idx: BTW_OVERLAY_ENTRY_IDX,
range_id: BTW_OVERLAY_RANGE_ID,
block_line_idx: 2,
col_within_range: 0,
},
head: RangeHit {
entry_idx: BTW_OVERLAY_ENTRY_IDX,
range_id: BTW_OVERLAY_RANGE_ID,
block_line_idx: 14,
col_within_range: 5,
},
kind: Default::default(),
anchor_content_width: None,
};
let text = reconstruct_selection_text(&full_model, &drag).expect("reconstruct");
let expected = (2..=14)
.map(|i| format!("line{i:02}"))
.collect::<Vec<_>>()
.join("\n");
assert_eq!(text, expected);
}
/// Regression for the actual bug: the Done overlay must render markdown
/// (bold, headings, tables) instead of echoing the raw source.
#[test]
fn done_state_renders_markdown_not_raw_source() {
let response =
"**Bold intro**\n\n### Heading\n\n| Item | Qty |\n|------|-----|\n| Bow | 1 |";
let state = BtwOverlayState::done("q".to_string(), response.to_string());
// Wide + tall enough to render the whole response without scrolling.
let model = render_with_model(&state, 60, 16);
let rendered: String = model
.ranges
.iter()
.flat_map(|r| r.lines.iter())
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
// Markdown syntax is consumed, not shown literally.
assert!(
!rendered.contains("**"),
"bold markers should be rendered away, got: {rendered:?}"
);
assert!(
!rendered.contains("###"),
"heading markers should be rendered away, got: {rendered:?}"
);
assert!(
!rendered.contains("---"),
"table separator should be rendered, not raw dashes, got: {rendered:?}"
);
// Content survives and the table is drawn with box-drawing borders.
assert!(rendered.contains("Bold intro"), "got: {rendered:?}");
assert!(rendered.contains("Heading"), "got: {rendered:?}");
assert!(
rendered.contains("Item") && rendered.contains("Bow"),
"table cells should render, got: {rendered:?}"
);
assert!(
rendered.contains('│') || rendered.contains('─'),
"table should render with box-drawing chars, got: {rendered:?}"
);
}
/// Regression: Done overlay must expose markdown hyperlinks as overlay
/// links (OSC 8 / click-to-open), not only paint styled text.
#[test]
fn done_state_maps_markdown_links_to_overlay() {
let url = "https://example.com/btw-link";
let response = format!("See [docs]({url}) for details.");
let state = BtwOverlayState::done("q".to_string(), response);
let (_model, overlay) = render_with_links(&state, 60, 8);
assert!(
!overlay.is_empty(),
"expected at least one overlay link for markdown href"
);
let found = overlay.links().iter().any(|l| l.url.as_ref() == url);
assert!(
found,
"overlay should contain {url}, got: {:?}",
overlay
.links()
.iter()
.map(|l| l.url.as_ref())
.collect::<Vec<_>>()
);
// Links live in the body (row >= 1), not the title border.
for link in overlay.links() {
assert!(
link.screen_row >= 1,
"link should be in panel body, got row {}",
link.screen_row
);
assert!(link.col_end > link.col_start);
}
}
#[test]
fn done_state_maps_plain_url_autolinks() {
let url = "https://example.com/plain";
let state = BtwOverlayState::done("q".to_string(), format!("Visit {url} please."));
let (_model, overlay) = render_with_links(&state, 60, 8);
assert!(
overlay.links().iter().any(|l| l.url.as_ref() == url),
"plain URL should become an overlay link, got: {:?}",
overlay
.links()
.iter()
.map(|l| l.url.as_ref())
.collect::<Vec<_>>()
);
}
#[test]
fn done_state_scans_file_paths_like_scrollback() {
// Absolute path text (not a markdown hyperlink) should still become a
// file:// overlay via scan_lines_for_url_overlays.
let path = "/Users/test/project/src/main.rs";
let state = BtwOverlayState::done("q".to_string(), format!("See {path} for details."));
let (_model, overlay) = render_with_links(&state, 80, 8);
let urls: Vec<&str> = overlay.links().iter().map(|l| l.url.as_ref()).collect();
assert!(
urls.iter()
.any(|u| u.contains("main.rs") && u.starts_with("file://")),
"file path should map to file:// overlay, got: {urls:?}"
);
}
#[test]
fn scrolled_links_use_visible_rows_only() {
// Many lines so scroll_offset > 0; a link on the last line should map
// to a body row, not the pre-scroll absolute line index.
let mut lines: Vec<String> = (0..20).map(|i| format!("line{i:02}")).collect();
let url = "https://example.com/scrolled";
lines.push(format!("[end]({url})"));
let response = lines.join(" \n");
let mut state = BtwOverlayState::done("q".to_string(), response);
if let BtwOverlayState::Done {
scroll_offset: so, ..
} = &mut state
{
// 20 lineNN + 1 link = 21 lines. height=6 → max_body=4; offset is
// clamped to totalmax_body = 17, so visible indices 17..20 and the
// link (idx 20) is the last visible row.
*so = 18;
}
let (_model, overlay) = render_with_links(&state, 60, 6);
let link = overlay
.links()
.iter()
.find(|l| l.url.as_ref() == url)
.expect("scrolled link should still map when visible");
// Body starts at row 1; clamped offset 17 + 4 visible rows → link at
// visible index 3 → screen_row = 1 + 3 = 4.
assert_eq!(
link.screen_row, 4,
"link should be on last visible body row"
);
}
/// Regression: a long question must not push the [Esc] hint off the top
/// border. The question truncates (…) and the hint stays visible.
#[test]
fn long_question_truncates_title_but_keeps_esc_hint() {
let long_q = "please also double-check the error handling and the retry \
logic across every single call site in the whole module";
let state = BtwOverlayState::Loading {
question: long_q.to_string(),
};
let width = 40;
let buf = render_to_buffer(&state, width, 4);
let top = row_text(&buf, width, 0);
assert!(
top.contains("[Esc]"),
"[Esc] hint must stay visible when the question is long, got: {top:?}"
);
assert!(
top.contains('\u{2026}'),
"long question should be truncated with an ellipsis, got: {top:?}"
);
}
/// A short question keeps its full title AND the [Esc] hint (no regression
/// to the common case).
#[test]
fn short_question_shows_full_title_and_esc_hint() {
let state = BtwOverlayState::Loading {
question: "hi".to_string(),
};
let width = 40;
let buf = render_to_buffer(&state, width, 4);
let top = row_text(&buf, width, 0);
assert!(top.contains("/btw hi"), "full title expected, got: {top:?}");
assert!(top.contains("[Esc]"), "hint expected, got: {top:?}");
assert!(
!top.contains('\u{2026}'),
"short title must not be truncated, got: {top:?}"
);
}
/// Regression: on a panel too narrow for the Done-state scroll hint
/// ("pos-end/total [Esc]"), the overlay falls back to a bare "[Esc]" so the
/// close affordance never disappears (and the wide scroll indicator is
/// dropped rather than hiding [Esc]).
#[test]
fn done_narrow_panel_falls_back_to_bare_esc() {
// 50 lines → answer overflows, so the hint gains a "1-4/50" scroll prefix
// that is far too wide for a 14-col panel.
let response = hard_break_lines(50);
let state = done_with_scroll(&response, 0);
let width = 14; // below the full-hint width, above the 12-col minimum
let buf = render_to_buffer(&state, width, 6);
let top = row_text(&buf, width, 0);
assert!(
top.contains("[Esc]"),
"[Esc] must survive on a narrow Done panel, got: {top:?}"
);
assert!(
!top.contains("/50"),
"the wide scroll indicator should be dropped in favor of a bare [Esc], got: {top:?}"
);
}
/// The clickable [Esc] hit area is still registered even when the question
/// is long enough to force truncation.
#[test]
fn long_question_still_registers_esc_hit_area() {
let state = BtwOverlayState::Loading {
question: "x".repeat(200),
};
let area = Rect::new(0, 0, 40, 4);
let mut buf = Buffer::empty(area);
let mut model = ResolvedSelectionModel::default();
let mut hit = crate::app::agent_view::HitArea::default();
render_btw_panel(
&mut buf,
&state,
area,
0,
false,
Some(&mut hit),
&mut model,
None,
&[],
);
let rect = hit
.rect
.expect("[Esc] hit area must be set even with a long question");
assert_eq!(rect.y, 0, "hit area lives on the top border row");
assert!(rect.width > 0, "hit area must be non-empty");
// The hit area sits inside the right border, not off-screen.
assert!(
rect.x + rect.width <= area.width,
"hit area must be within the panel: {rect:?}"
);
}
}
@@ -0,0 +1,414 @@
//! Dropdown renderer for shell command completion suggestions.
//!
//! Mirrors `slash_dropdown.rs` layout: aligned label column, description,
//! selection highlight, mouse hover, and scrollbar when items exceed
//! `MAX_VISIBLE_ROWS`.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use crate::render::SafeBuf;
use crate::render::line_utils::truncate_str;
use crate::render::scrollbar::render_scrollbar_styled;
use crate::theme::Theme;
use crate::views::suggestion_controller::{CompletionDropdownState, CompletionItemParsed};
/// Maximum visible rows in the completion dropdown.
pub const MAX_VISIBLE_ROWS: u16 = 6;
/// Hard cap on label column width.
const LABEL_CAP: usize = 40;
/// Gap between label and description columns.
const LABEL_DESC_GAP: usize = 2;
/// Prefix width (`" "` or `" "`).
const PREFIX_W: usize = 2;
/// Height needed for the dropdown (separator + items), or 0 when hidden.
pub fn dropdown_height(state: &CompletionDropdownState) -> u16 {
if !state.open || state.items.is_empty() {
return 0;
}
let item_rows = (state.items.len() as u16).min(MAX_VISIBLE_ROWS);
1 + item_rows // separator + items
}
/// Compute the scroll offset so the selected row stays centred.
pub fn scroll_offset(state: &CompletionDropdownState) -> usize {
let total = state.items.len();
let visible = MAX_VISIBLE_ROWS as usize;
let selected = state.selected.min(total.saturating_sub(1));
if total <= visible || selected < visible / 2 {
0
} else if selected + visible / 2 >= total {
total.saturating_sub(visible)
} else {
selected.saturating_sub(visible / 2)
}
}
fn compute_label_column_w(items: &[CompletionItemParsed], content_w: usize) -> usize {
let budget = (content_w * 3 / 5).min(LABEL_CAP);
let max_w = items
.iter()
.map(|r| r.display.width())
.filter(|&w| w <= LABEL_CAP)
.max()
.unwrap_or(0);
max_w.min(budget)
}
/// Render completion dropdown items into `area` (no borders — caller draws chrome).
pub fn render_dropdown(
buf: &mut Buffer,
area: Rect,
state: &CompletionDropdownState,
theme: &Theme,
) {
if area.height == 0 || area.width < 4 || !state.open {
return;
}
let items = &state.items;
let selected = state.selected.min(items.len().saturating_sub(1));
let hovered = state.hovered;
let content_w = area.width as usize;
let visible_rows = area.height as usize;
let needs_scrollbar = items.len() > visible_rows;
let row_w = if needs_scrollbar {
content_w.saturating_sub(2)
} else {
content_w
};
let label_col_w = compute_label_column_w(items, row_w.saturating_sub(PREFIX_W));
let scroll = scroll_offset(state);
for vis_row in 0..visible_rows {
let item_idx = scroll + vis_row;
if item_idx >= items.len() {
break;
}
let item = &items[item_idx];
let y = area.y + vis_row as u16;
let is_selected = item_idx == selected;
let is_hovered = hovered == Some(item_idx) && !is_selected;
let row_bg = match crate::views::modal_window::embedded_row_style(theme, is_selected) {
Some(e) => e.bg,
None if is_selected => theme.bg_visual,
None if is_hovered => theme.bg_hover,
None => theme.bg_light,
};
let line = build_item_line(item, is_selected, label_col_w, row_w, row_bg, theme);
// Skip rows that fall outside the buffer (resize race).
if y < buf.area.y || y >= buf.area.bottom() || area.x >= buf.area.right() {
continue;
}
let clamped_w = row_w.min(buf.area.right().saturating_sub(area.x) as usize) as u16;
let row_rect = Rect {
x: area.x,
y,
width: clamped_w,
height: 1,
};
buf.set_style(row_rect, Style::default().bg(row_bg));
buf.set_line_safe(area.x, y, &line, row_w as u16);
}
if needs_scrollbar {
let sb_x = area.x + area.width.saturating_sub(1);
let sb_y = area.y.max(buf.area.y);
let sb_bottom = (area.y.saturating_add(area.height)).min(buf.area.bottom());
if sb_x < buf.area.right() && sb_bottom > sb_y {
let sb_area = Rect {
x: sb_x,
y: sb_y,
width: 1,
height: sb_bottom - sb_y,
};
let track = Style::default().bg(theme.bg_dark);
let thumb = Style::default().fg(theme.gray_dim).bg(theme.bg_dark);
render_scrollbar_styled(
buf,
Some(sb_area),
items.len() as u16,
sb_area.height,
scroll as u16,
track,
thumb,
);
}
}
}
fn build_item_line(
item: &CompletionItemParsed,
is_selected: bool,
label_col_w: usize,
total_w: usize,
row_bg: ratatui::style::Color,
theme: &Theme,
) -> Line<'static> {
let bold = if is_selected {
Modifier::BOLD
} else {
Modifier::empty()
};
let embed = crate::views::modal_window::embedded_row_style(theme, is_selected);
let primary_fg = embed.map_or(theme.text_primary, |e| e.fg(theme.text_primary));
let desc_fg = embed.map_or(theme.gray, |e| e.fg(theme.gray));
let normal = Style::default()
.fg(primary_fg)
.bg(row_bg)
.add_modifier(bold);
let desc_style = Style::default().fg(desc_fg).bg(row_bg);
let bg_style = Style::default().bg(row_bg);
let prefix = if is_selected {
crate::glyphs::prompt_arrow()
} else {
" "
};
let prefix_span = Span::styled(
prefix.to_string(),
if is_selected { normal } else { bg_style },
);
let label = truncate_str(&item.display, label_col_w);
let label_w = label.width();
let padding = label_col_w.saturating_sub(label_w);
let label_span = Span::styled(label, normal);
let desc_indent = PREFIX_W + label_col_w + LABEL_DESC_GAP;
let desc_w = total_w.saturating_sub(desc_indent).max(1);
let desc = truncate_str(&item.description, desc_w);
let mut spans = vec![prefix_span, label_span];
if padding > 0 {
spans.push(Span::styled(" ".repeat(padding), bg_style));
}
if !desc.is_empty() {
spans.push(Span::styled(" ".to_string(), bg_style));
spans.push(Span::styled(desc, desc_style));
}
Line::from(spans).style(bg_style)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::views::suggestion_controller::SuggestionSource;
fn make_item(display: &str, desc: &str, insert: &str) -> CompletionItemParsed {
CompletionItemParsed {
display: display.into(),
description: desc.into(),
insert_text: insert.into(),
source: SuggestionSource::History,
priority: 0,
replace_range: None,
token_text: None,
truncated: false,
}
}
#[test]
fn height_zero_when_closed() {
let state = CompletionDropdownState::default();
assert_eq!(dropdown_height(&state), 0);
}
/// Items area past buffer bottom must not panic on resize races.
#[test]
fn render_dropdown_past_buffer_bottom_does_not_panic() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = Theme::current();
let items: Vec<CompletionItemParsed> = (0..12)
.map(|i| make_item(&format!("item{i}"), "desc", &format!("item{i}")))
.collect();
let state = CompletionDropdownState {
open: true,
items,
selected: 0,
..Default::default()
};
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
let area = Rect::new(2, 8, 76, 8);
render_dropdown(&mut buf, area, &state, &theme);
}
#[test]
fn height_zero_when_empty() {
let state = CompletionDropdownState {
open: true,
..Default::default()
};
assert_eq!(dropdown_height(&state), 0);
}
#[test]
fn height_with_items() {
let state = CompletionDropdownState {
open: true,
items: vec![
make_item("ls", "list", "ls"),
make_item("cd", "change dir", "cd"),
],
..Default::default()
};
assert_eq!(dropdown_height(&state), 3); // 1 separator + 2 items
}
#[test]
fn height_capped_at_max() {
let items: Vec<_> = (0..20)
.map(|i| make_item(&format!("cmd{i}"), "", &format!("cmd{i}")))
.collect();
let state = CompletionDropdownState {
open: true,
items,
..Default::default()
};
assert_eq!(dropdown_height(&state), 1 + MAX_VISIBLE_ROWS);
}
#[test]
fn move_selection_wraps() {
let mut state = CompletionDropdownState {
open: true,
items: vec![
make_item("a", "", "a"),
make_item("b", "", "b"),
make_item("c", "", "c"),
],
selected: 0,
..Default::default()
};
state.move_selection(-1);
assert_eq!(state.selected, 2);
state.move_selection(1);
assert_eq!(state.selected, 0);
state.move_selection(1);
assert_eq!(state.selected, 1);
}
#[test]
fn scroll_selection_clamps_at_edges() {
let mut state = CompletionDropdownState {
open: true,
items: vec![
make_item("a", "", "a"),
make_item("b", "", "b"),
make_item("c", "", "c"),
],
selected: 0,
..Default::default()
};
// Scrolling up at the first item stays put (no wrap).
state.scroll_selection(-1);
assert_eq!(state.selected, 0);
state.scroll_selection(1);
assert_eq!(state.selected, 1);
// Scrolling down at the last item stays put (no wrap).
state.scroll_selection(1);
assert_eq!(state.selected, 2);
state.scroll_selection(1);
assert_eq!(state.selected, 2);
}
#[test]
fn accept_returns_item_and_closes() {
let mut state = CompletionDropdownState {
open: true,
items: vec![make_item("ls -la", "list all", "ls -la /tmp")],
selected: 0,
..Default::default()
};
let item = state.accept().expect("selected item accepted");
assert_eq!(item.insert_text, "ls -la /tmp");
assert!(!state.open);
}
#[test]
fn accept_without_items_returns_none() {
let mut state = CompletionDropdownState::default();
assert!(state.accept().is_none());
}
/// `accept` is independent of the `open` render flag: the
/// single-candidate insta-accept consumes an item that was never shown.
#[test]
fn accept_works_on_closed_dropdown_with_items() {
let mut state = CompletionDropdownState {
open: false,
items: vec![make_item("ls", "", "ls -la")],
..Default::default()
};
let item = state.accept().expect("item accepted while closed");
assert_eq!(item.insert_text, "ls -la");
}
#[test]
fn scroll_offset_no_scroll_needed() {
let state = CompletionDropdownState {
open: true,
items: vec![make_item("a", "", "a")],
selected: 0,
..Default::default()
};
assert_eq!(scroll_offset(&state), 0);
}
#[test]
fn scroll_offset_centres_selected() {
let items: Vec<_> = (0..20)
.map(|i| make_item(&format!("c{i}"), "", &format!("c{i}")))
.collect();
let state = CompletionDropdownState {
open: true,
items,
selected: 10,
..Default::default()
};
let offset = scroll_offset(&state);
let visible = MAX_VISIBLE_ROWS as usize;
assert!(offset <= 10);
assert!(offset + visible > 10);
}
#[test]
fn close_resets_state() {
let mut state = CompletionDropdownState {
open: true,
items: vec![make_item("a", "", "a")],
selected: 0,
hovered: Some(0),
generation: 5,
request_text: "a".into(),
request_cursor: 1,
};
state.close();
assert!(!state.open);
assert_eq!(state.selected, 0);
assert!(state.hovered.is_none());
assert_eq!(state.generation, 5); // generation preserved
assert!(state.items.is_empty());
// Anchor left in place (inert without items); the next landing
// overwrites it atomically with the new items.
assert_eq!(state.request_text, "a");
assert_eq!(state.request_cursor, 1);
}
}
@@ -0,0 +1,432 @@
//! Context usage bar — shows token usage in the status bar.
//!
//! Default builds a `Line<'static>` of styled spans: `8.5K / 1.0M` (actual tokens,
//! colored by usage percentage). On hover, replaces the tokens with a progress
//! bar + percentage, e.g. `█████ 42.0%`. The bar width is derived from the
//! default string length so the hover line is the same total width — no layout
//! shift on hover. The default is right-padded to a minimum of 6 columns so the
//! width invariant holds even for degenerate inputs like `0 / 9`.
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use super::progress_bar::progress_bar_spans;
use crate::theme::Theme;
// ---------------------------------------------------------------------------
// Formatting utilities
// ---------------------------------------------------------------------------
/// Format a percentage as a fixed-width 5-char string.
///
/// - `< 10`: `"X.XX%"` (e.g. `"0.00%"`, `"5.12%"`)
/// - `1099`: `"XX.X%"` (e.g. `"20.1%"`, `"99.9%"`)
/// - `≥ 100`: `"MAX %"`
pub fn fmt_pct5(pct: f64) -> String {
if pct >= 100.0 {
"MAX %".to_string()
} else if pct < 10.0 {
format!("{pct:.2}%")
} else {
format!("{pct:.1}%")
}
}
/// Format a token count as a compact string (≤4 chars).
///
/// - `0999`: `"0"`, `"12"`, `"999"`
/// - `1K9.9K`: `"1.2K"` (4 chars)
/// - `10K999K`: `"12K"`, `"999K"` (≤4 chars)
/// - `1M9.9M`: `"1.2M"` (4 chars)
/// - `10M+`: `"12M"`, `"123M"` (≤4 chars)
pub fn fmt_tokens(n: u64) -> String {
if n < 1_000 {
n.to_string()
} else if n < 10_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else if n < 1_000_000 {
format!("{}K", n / 1_000)
} else if n < 10_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else {
format!("{}M", n / 1_000_000)
}
}
// ---------------------------------------------------------------------------
// Color blending
// ---------------------------------------------------------------------------
/// A breakpoint for color blending: at `pct` percent, the bar color is `color`.
#[derive(Debug, Clone, Copy)]
pub struct ColorBreakpoint {
pub pct: f64,
pub color: Color,
}
/// Default breakpoints: text_primary → accent_user → warning → accent_error.
///
/// Breakpoint colors are raw RGB. The final color produced by [`blend_color`]
/// is quantized by the caller (see [`context_bar_line`]) so the output always
/// matches the terminal's capability level.
pub fn default_breakpoints(theme: &Theme) -> Vec<ColorBreakpoint> {
vec![
ColorBreakpoint {
pct: 0.0,
color: theme.text_primary,
},
ColorBreakpoint {
pct: 50.0,
color: theme.accent_user,
},
ColorBreakpoint {
pct: 65.0,
color: theme.accent_user,
},
ColorBreakpoint {
pct: 75.0,
color: theme.warning,
},
ColorBreakpoint {
pct: 85.0,
color: theme.warning,
},
ColorBreakpoint {
pct: 95.0,
color: theme.accent_error,
},
]
}
/// Blend between breakpoints for a given percentage.
pub fn blend_color(pct: f64, breakpoints: &[ColorBreakpoint]) -> Color {
if breakpoints.is_empty() {
return Color::Reset;
}
if pct <= breakpoints[0].pct {
return breakpoints[0].color;
}
for i in 1..breakpoints.len() {
if pct <= breakpoints[i].pct {
let t = (pct - breakpoints[i - 1].pct) / (breakpoints[i].pct - breakpoints[i - 1].pct);
return lerp_color(breakpoints[i - 1].color, breakpoints[i].color, t as f32);
}
}
breakpoints.last().unwrap().color
}
/// Linear interpolation between two colors.
///
/// When either input is `Color::Indexed`, the result is quantized back to
/// the nearest indexed color so the output stays terminal-compatible.
fn lerp_color(a: Color, b: Color, t: f32) -> Color {
let (ar, ag, ab) = color_to_rgb(a);
let (br, bg, bb) = color_to_rgb(b);
let t = t.clamp(0.0, 1.0);
let r = (ar as f32 + (br as f32 - ar as f32) * t).round() as u8;
let g = (ag as f32 + (bg as f32 - ag as f32) * t).round() as u8;
let b_ch = (ab as f32 + (bb as f32 - ab as f32) * t).round() as u8;
match (a, b) {
(Color::Indexed(_), _) | (_, Color::Indexed(_)) => {
Color::Indexed(crate::render::color::nearest_indexed(r, g, b_ch))
}
_ => Color::Rgb(r, g, b_ch),
}
}
/// RGB for any color variant, using a neutral fallback for `Reset`.
///
/// Necessary so a gradient that lerps across named breakpoints (after
/// the theme has quantized to ANSI on lower-color terminals) still
/// produces meaningful intermediate colors instead of collapsing all
/// inputs onto one fallback.
fn color_to_rgb(c: Color) -> (u8, u8, u8) {
// (198, 198, 198) matches the FG-equivalent used elsewhere when the
// terminal owns the actual default fg color.
crate::render::color::resolve_to_rgb(c).unwrap_or((198, 198, 198))
}
// ---------------------------------------------------------------------------
// Status bar separator
// ---------------------------------------------------------------------------
/// The separator character between status bar items.
pub const SEPARATOR: &str = "";
// ---------------------------------------------------------------------------
// Context bar line builder
// ---------------------------------------------------------------------------
/// Width of the percentage field on hover (`fmt_pct5` always returns 5 chars).
const PCT_WIDTH: u16 = 5;
/// Width of the gap between the progress bar and the percentage on hover.
const BAR_PCT_GAP: u16 = 1;
// BAR_BG removed — use theme.bg_highlight directly (already quantized).
/// Build the context usage bar as a `Line<'static>`.
///
/// Normal: `8.5K / 1.0M` — actual token usage, colored by the same percentage
/// gradient the hover bar uses so the urgency signal stays visible at a glance.
/// Hovered: `█████ 42.0%` — progress bar + colored percentage, sized to match.
///
/// The bar width is derived from the default token string length so the
/// hovered line has the same total width as the default (no layout shift on
/// hover). The default is right-padded to a minimum of 6 columns
/// (`BAR_PCT_GAP + PCT_WIDTH`) so the invariant holds for every input — without
/// the pad, degenerate cases like `0 / 9` (5 chars) would mismatch the hovered
/// line, which always rounds up to 6 (zero-width bar + gap + percentage).
///
/// Returns `None` if token data is unavailable.
///
/// Gateway light-frontend (`kind: "chat"`) sessions must not display Build /
/// local sampler context usage — call with `gateway_chat = true` to suppress
/// the bar entirely (remote owns context; no mapped totals yet). remote settings
/// opt-in for chat entry can reuse the same gate later.
pub fn context_bar_line(
used_tokens: Option<u64>,
total_tokens: Option<u64>,
hovered: bool,
theme: &Theme,
) -> Option<Line<'static>> {
context_bar_line_for_session(used_tokens, total_tokens, hovered, theme, false)
}
/// Like [`context_bar_line`], but omits the bar for gateway/chat-kind sessions.
pub fn context_bar_line_for_session(
used_tokens: Option<u64>,
total_tokens: Option<u64>,
hovered: bool,
theme: &Theme,
gateway_chat: bool,
) -> Option<Line<'static>> {
if gateway_chat {
return None;
}
let used = used_tokens?;
let total = total_tokens.filter(|&t| t > 0)?;
let pct = kigi_token_estimation::usage_percentage(used, total);
// Default form drives the line width: `used / total`, right-padded to the
// minimum hover width so the two states always render at the same width.
let mut token_str = format!("{} / {}", fmt_tokens(used), fmt_tokens(total));
let natural_width = token_str.chars().count() as u16;
let min_width = BAR_PCT_GAP + PCT_WIDTH;
if natural_width < min_width {
token_str.push_str(&" ".repeat((min_width - natural_width) as usize));
}
let total_width = natural_width.max(min_width);
// Urgency color shared by both branches so the default still surfaces
// high-usage warnings without requiring the user to hover.
let breakpoints = default_breakpoints(theme);
let color = crate::theme::quantize(blend_color(pct, &breakpoints));
if hovered {
// Bar fills the space the default tokens would occupy, minus the gap
// and the percentage. `total_width >= min_width` by construction, so
// this subtraction is safe.
let bar_width = total_width - min_width;
let mut spans =
progress_bar_spans(bar_width, pct as f32 / 100.0, color, theme.bg_highlight);
spans.push(Span::styled(" ", Style::default().bg(theme.bg_base)));
spans.push(Span::styled(
fmt_pct5(pct),
Style::default().fg(theme.text_secondary).bg(theme.bg_base),
));
Some(Line::from(spans))
} else {
Some(Line::from(Span::styled(
token_str,
Style::default().fg(color).bg(theme.bg_base),
)))
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fmt_pct5_under_10() {
assert_eq!(fmt_pct5(0.0), "0.00%");
assert_eq!(fmt_pct5(5.123), "5.12%");
assert_eq!(fmt_pct5(9.99), "9.99%");
}
#[test]
fn test_fmt_pct5_10_to_99() {
assert_eq!(fmt_pct5(10.0), "10.0%");
assert_eq!(fmt_pct5(20.16), "20.2%"); // rounds
assert_eq!(fmt_pct5(99.9), "99.9%");
}
#[test]
fn test_fmt_pct5_max() {
assert_eq!(fmt_pct5(100.0), "MAX %");
assert_eq!(fmt_pct5(150.0), "MAX %");
}
#[test]
fn test_fmt_pct5_all_5_chars() {
for pct in [0.0, 0.01, 1.0, 5.55, 9.99, 10.0, 50.0, 99.9, 100.0] {
let s = fmt_pct5(pct);
assert_eq!(s.len(), 5, "fmt_pct5({pct}) = {s:?} should be 5 chars");
}
}
#[test]
fn test_fmt_tokens_small() {
assert_eq!(fmt_tokens(0), "0");
assert_eq!(fmt_tokens(12), "12");
assert_eq!(fmt_tokens(999), "999");
}
#[test]
fn test_fmt_tokens_thousands() {
assert_eq!(fmt_tokens(1_200), "1.2K");
assert_eq!(fmt_tokens(9_960), "10.0K"); // rounds up
assert_eq!(fmt_tokens(9_940), "9.9K");
assert_eq!(fmt_tokens(12_000), "12K");
assert_eq!(fmt_tokens(123_000), "123K");
assert_eq!(fmt_tokens(999_000), "999K");
}
#[test]
fn test_fmt_tokens_millions() {
assert_eq!(fmt_tokens(1_200_000), "1.2M");
assert_eq!(fmt_tokens(12_000_000), "12M");
assert_eq!(fmt_tokens(123_000_000), "123M");
}
#[test]
fn test_fmt_tokens_max_4_chars() {
for n in [
0, 1, 999, 1_200, 9_900, 12_000, 999_000, 1_200_000, 12_000_000,
] {
let s = fmt_tokens(n);
assert!(s.len() <= 4, "fmt_tokens({n}) = {s:?} should be ≤4 chars");
}
}
#[test]
fn test_blend_color_at_breakpoints() {
// Use unquantized theme — blend_color needs raw RGB values for lerp math.
let theme = Theme::default();
let bps = default_breakpoints(&theme);
// At 0%, should be theme.text_primary
let c0 = blend_color(0.0, &bps);
assert_eq!(c0, theme.text_primary);
// At 95%, should be theme.accent_error
let c95 = blend_color(95.0, &bps);
assert_eq!(c95, theme.accent_error);
}
/// Concatenate all span content into one string for assertions.
fn line_text(line: &Line<'static>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn test_context_bar_default_shows_compact_token_usage() {
// Default (non-hovered) state shows `used / total` with no padding.
let theme = Theme::default();
let line = context_bar_line(Some(8_500), Some(1_000_000), false, &theme)
.expect("token data provided");
let text = line_text(&line);
assert_eq!(text, "8.5K / 1.0M");
}
#[test]
fn test_context_bar_hover_shows_bar_and_percentage() {
// Hovered state shows the progress bar followed by the percentage.
let theme = Theme::default();
let line =
context_bar_line(Some(420_000), Some(1_000_000), true, &theme).expect("token data");
let text = line_text(&line);
assert!(
text.ends_with("42.0%"),
"expected hovered line to end with '42.0%', got: {text:?}"
);
}
#[test]
fn test_context_bar_hover_width_matches_default() {
// For each (used, total) combo, the hovered line must be the same
// width as the default — toggling hover should never shift layout.
let theme = Theme::default();
for (used, total) in [
(8_500u64, 1_000_000u64),
(500, 1_000_000),
(123_456, 1_000_000),
(999_999, 999_999),
(12_000_000, 12_000_000),
// Degenerate sub-min-width case: default natural width is 5
// ("0 / 9"), padded to 6 so the hover line still matches.
(0, 9),
] {
let default_line = context_bar_line(Some(used), Some(total), false, &theme)
.expect("token data provided");
let hover_line = context_bar_line(Some(used), Some(total), true, &theme)
.expect("token data provided");
assert_eq!(
default_line.width(),
hover_line.width(),
"default vs hover width mismatch for used={used} total={total}: \
default={:?} hover={:?}",
line_text(&default_line),
line_text(&hover_line),
);
}
}
#[test]
fn test_context_bar_hover_bar_grows_with_token_string() {
// The bar size should scale with the default string length.
// `500 / 1.0M` (10 chars) → bar = 10 - 6 = 4 chars.
// `8.5K / 1.0M` (11 chars) → bar = 11 - 6 = 5 chars.
let theme = Theme::default();
let short = context_bar_line(Some(500), Some(1_000_000), true, &theme).unwrap();
let long = context_bar_line(Some(8_500), Some(1_000_000), true, &theme).unwrap();
assert!(
short.width() < long.width(),
"expected shorter default to produce shorter hover line; \
short={:?} ({} cols), long={:?} ({} cols)",
line_text(&short),
short.width(),
line_text(&long),
long.width(),
);
}
#[test]
fn test_context_bar_returns_none_without_tokens() {
// Mirror across hover states so a future refactor that moves the
// unavailability checks into per-branch arms can't silently regress
// one path.
let theme = Theme::default();
for hovered in [false, true] {
assert!(context_bar_line(None, Some(1_000_000), hovered, &theme).is_none());
assert!(context_bar_line(Some(1_000), None, hovered, &theme).is_none());
// Zero total is treated as missing.
assert!(context_bar_line(Some(1_000), Some(0), hovered, &theme).is_none());
}
}
#[test]
fn gateway_chat_suppresses_context_bar_even_with_tokens() {
let theme = Theme::default();
assert!(
context_bar_line_for_session(Some(1_000), Some(1_000_000), false, &theme, true)
.is_none()
);
assert!(
context_bar_line_for_session(Some(1_000), Some(1_000_000), false, &theme, false)
.is_some()
);
}
}
@@ -0,0 +1,817 @@
//! Credit balance indicator for the agent status bar.
//!
//! Shows the user's coding credit usage as a compact status bar item.
//! Fetches real data from the `x.ai/billing` agent extension.
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use crate::theme::Theme;
/// Credit balance state from the billing API.
#[derive(Debug, Clone)]
pub struct CreditBalance {
/// Usage as a percentage of the allowance (0.0100.0).
pub usage_pct: f64,
/// Usage as a percentage of total budget (free + on-demand when enabled).
pub effective_usage_pct: f64,
/// Billing period end as a formatted local wall-clock string (no zone
/// label), e.g. "Mar 31, 12:00".
pub period_end_display: Option<String>,
/// Whether pay-as-you-go (on-demand) billing is enabled.
pub pay_as_you_go: bool,
/// On-demand spending cap in USD cents (e.g. 500 = $5.00).
pub on_demand_cap_cents: Option<i64>,
/// On-demand usage this period in USD cents.
pub on_demand_used_cents: Option<i64>,
/// Remaining prepaid ("bought") credit balance in USD cents.
pub prepaid_balance_cents: Option<i64>,
/// Usage period type from the billing response (the proto enum name, e.g.
/// `USAGE_PERIOD_TYPE_WEEKLY`). Drives the "Weekly/Monthly limit" label.
pub period_type: Option<String>,
/// From credits config `is_unified_billing_user` (`None` if absent).
/// `Some(true)` = unified pool / buy-credits UX; `Some(false)` = legacy
/// on-demand / PAYG UX.
pub is_unified_billing_user: Option<bool>,
}
impl CreditBalance {
/// Label for the percentage allowance, chosen from the period type:
/// "Weekly limit" / "Monthly limit", falling back to "Usage" when unknown.
pub fn usage_label(&self) -> &'static str {
match self.period_type.as_deref() {
Some(t) if t.contains("WEEKLY") => "Weekly limit",
Some(t) if t.contains("MONTHLY") => "Monthly limit",
_ => "Usage",
}
}
}
/// Auto top-up rule data used by the `/usage` summary.
#[derive(Debug, Clone)]
pub struct AutoTopupInfo {
/// Whether auto top-up is enabled.
pub enabled: bool,
/// Per-trigger top-up amount in USD cents.
pub topup_amount_cents: Option<i64>,
/// Optional maximum monthly top-up amount in USD cents.
pub max_amount_cents: Option<i64>,
}
impl AutoTopupInfo {
/// A known "no / disabled auto top-up" state — distinct from an unresolved
/// `None`, which means the rule hasn't been fetched yet.
pub fn disabled() -> Self {
Self {
enabled: false,
topup_amount_cents: None,
max_amount_cents: None,
}
}
}
/// Outcome of an auto top-up rule fetch, so a transient failure doesn't clear a
/// previously cached rule.
#[derive(Debug, Clone)]
pub enum AutoTopupFetch {
/// A definitive rule state (a real rule, or [`AutoTopupInfo::disabled`] when
/// the backend reports none). Stored as the *known* auto top-up state.
Resolved(AutoTopupInfo),
/// Fetch failed — keep the cached value (last-known-good). A stored `None`
/// therefore means "not yet known", not "no auto top-up".
Unchanged,
/// The rule is not applicable (no prepaid credits) — reset the cache to
/// "unknown" so a later credits period doesn't read a stale rule.
Cleared,
}
/// Format `cents` as a dollar string: whole dollars as `$N`, otherwise `$N.NN`.
fn fmt_dollars(cents: i64) -> String {
let dollars = cents as f64 / 100.0;
if dollars.fract() == 0.0 {
format!("${dollars:.0}")
} else {
format!("${dollars:.2}")
}
}
/// Build the `/usage` summary block shown in scrollback.
///
/// Always shows usage % and (when known) the next reset time. The credits
/// block is rendered only when the user has a positive prepaid balance:
/// - no prepaid balance → credits block omitted entirely
/// - auto top-up off/unknown → `Auto topup: disabled` (no max line)
/// - auto top-up on, no max → `Auto topup: $N`
/// - auto top-up on, max set → `Auto topup: $N` + `Max monthly topup: $M`
pub fn format_usage_summary(balance: &CreditBalance, autotopup: Option<&AutoTopupInfo>) -> String {
// Floor to match the backend SpendingLimiter's `as u8` truncation
// (99.994% → 99%, never 100% until truly exhausted).
let mut lines = vec![format!(
"{}: {}%",
balance.usage_label(),
balance.usage_pct.floor() as i64
)];
if let Some(reset) = &balance.period_end_display {
lines.push(format!("Next reset: {reset}"));
}
// Billing stores credit / top-up amounts as negative cents (accounting
// convention); display the absolute USD value, matching the web clients.
if let Some(prepaid) = balance
.prepaid_balance_cents
.map(i64::abs)
.filter(|c| *c > 0)
{
lines.push(String::new());
lines.push(format!("Credits: {}", fmt_dollars(prepaid)));
match autotopup {
Some(at) if at.enabled && at.topup_amount_cents.is_some() => {
lines.push(format!(
"Auto topup: {}",
fmt_dollars(at.topup_amount_cents.unwrap().abs())
));
if let Some(max) = at.max_amount_cents {
lines.push(format!("Max monthly topup: {}", fmt_dollars(max.abs())));
}
}
_ => lines.push("Auto topup: disabled".to_string()),
}
}
// Legacy on-demand (pay-as-you-go) billing — shown only when enabled, for
// users on the older monthly + on-demand model. Amounts always carry cents
// (e.g. `$50.00`), matching the web client.
if balance.pay_as_you_go {
let used = balance.on_demand_used_cents.unwrap_or(0).abs() as f64 / 100.0;
let cap = balance.on_demand_cap_cents.unwrap_or(0).abs() as f64 / 100.0;
lines.push(String::new());
lines.push(format!("Pay-as-you-go: ${used:.2} used of ${cap:.2} limit"));
}
lines.join("\n")
}
/// Low-balance ($10) and pay-as-you-go critical ($5) warning thresholds, in cents.
const LOW_BALANCE_CENTS: i64 = 1000;
const PAY_AS_YOU_GO_CRITICAL_CENTS: i64 = 500;
/// The prompt's usage/credits warning as `(text, critical)`, or `None`
/// (`critical` = yellow, else grey; team users with `usage_visible = false`
/// never warn). Behaviour splits by billing model — prepaid credits,
/// pay-as-you-go on-demand, or the included-allowance percentage — with exact
/// thresholds and copy pinned by the unit tests.
///
/// Gateway light-frontend (`kind: "chat"`) sessions must not surface Build
/// coding-credit warnings — use [`usage_warning_for_session`] with
/// `gateway_chat = true` so the prompt shows no fake local sampler telemetry.
pub fn usage_warning(
balance: &CreditBalance,
autotopup: Option<&AutoTopupInfo>,
usage_visible: bool,
) -> Option<(String, bool)> {
usage_warning_for_session(balance, autotopup, usage_visible, false)
}
/// Like [`usage_warning`], but suppresses output for gateway/chat-kind sessions.
pub fn usage_warning_for_session(
balance: &CreditBalance,
autotopup: Option<&AutoTopupInfo>,
usage_visible: bool,
gateway_chat: bool,
) -> Option<(String, bool)> {
if gateway_chat || !usage_visible {
return None;
}
// A non-zero prepaid balance (stored as signed cents) means the credits model.
let credits = balance
.prepaid_balance_cents
.map(i64::abs)
.filter(|c| *c > 0);
let Some(credits_cents) = credits else {
// Pay-as-you-go (legacy on-demand): warn on dollars left in the cap once
// the included allowance is spent.
if balance.pay_as_you_go {
if balance.usage_pct >= 100.0 {
let cap = balance.on_demand_cap_cents.unwrap_or(0).abs();
let used = balance.on_demand_used_cents.unwrap_or(0).abs();
let remaining = (cap - used).max(0);
if remaining <= LOW_BALANCE_CENTS {
let text = format!("Pay-as-you-go limit left: {}", fmt_dollars(remaining));
return Some((text, remaining <= PAY_AS_YOU_GO_CRITICAL_CENTS));
}
}
return None;
}
let pct = balance.effective_usage_pct;
if pct > 90.0 {
// "Left" = complement of floored usage, so it agrees with the
// floored summary (99.994% → "1% left", not "0%").
let remaining = (100 - pct.floor() as i64).max(0);
let label = balance.usage_label();
return Some((format!("{label} left: {remaining}%"), pct > 95.0));
}
return None;
};
// Credits are only drawn down at 100% usage; don't warn before then.
if balance.usage_pct < 100.0 {
return None;
}
let credits_warning = || {
(
format!("Credits left: {}", fmt_dollars(credits_cents)),
true,
)
};
// Auto top-up gates the warning: unknown → silent; disabled → warn when low;
// enabled w/o max → never; enabled w/ max → warn below one top-up amount.
match autotopup {
None => None,
Some(at) if !at.enabled => (credits_cents <= LOW_BALANCE_CENTS).then(credits_warning),
Some(at) if at.max_amount_cents.is_none() => None,
Some(at) => at
.topup_amount_cents
.map(i64::abs)
.and_then(|amt| (credits_cents < amt).then(credits_warning)),
}
}
/// Build the credit balance indicator as a `Line<'static>`.
///
/// Shows `Credits used: XX%` in the status bar.
///
/// Gateway light-frontend (`kind: "chat"`) sessions must not show Build coding
/// credits — use [`credit_bar_line_for_session`] with `gateway_chat = true`
/// (returns `None`). remote settings / managed opt-in for chat entry can share the
/// same gate later; for now it only zeros/suppresses misleading local telemetry.
pub fn credit_bar_line(balance: &CreditBalance, hovered: bool, theme: &Theme) -> Line<'static> {
credit_bar_line_for_session(balance, hovered, theme, false)
.expect("non-chat credit_bar_line always renders")
}
/// Like [`credit_bar_line`], but returns `None` for gateway/chat-kind sessions
/// so the status bar never implies Build sampler / coding-credit usage.
pub fn credit_bar_line_for_session(
balance: &CreditBalance,
_hovered: bool,
theme: &Theme,
gateway_chat: bool,
) -> Option<Line<'static>> {
if gateway_chat {
return None;
}
let pct = balance.usage_pct;
let color = if pct >= 100.0 {
theme.accent_error
} else if pct >= 80.0 {
theme.warning
} else {
theme.accent_success
};
let text = format!("Credits used: {pct:.0}%");
let style = Style::default().fg(color).bg(theme.bg_base);
Some(Line::from(Span::styled(text, style)))
}
#[cfg(test)]
mod tests {
use super::*;
fn bal(pct: f64) -> CreditBalance {
CreditBalance {
usage_pct: pct,
effective_usage_pct: pct,
period_end_display: None,
pay_as_you_go: false,
on_demand_cap_cents: None,
on_demand_used_cents: None,
prepaid_balance_cents: None,
period_type: None,
is_unified_billing_user: None,
}
}
fn topup(enabled: bool, amount: Option<i64>, max: Option<i64>) -> AutoTopupInfo {
AutoTopupInfo {
enabled,
topup_amount_cents: amount,
max_amount_cents: max,
}
}
#[test]
fn summary_no_credits_omits_credits_block() {
let b = CreditBalance {
period_end_display: Some("June 14, 16:00".into()),
prepaid_balance_cents: Some(0),
..bal(25.0)
};
// Even with an auto-topup rule present, zero prepaid → no credits block.
let out = format_usage_summary(&b, Some(&topup(true, Some(2000), Some(10000))));
assert_eq!(out, "Usage: 25%\nNext reset: June 14, 16:00");
}
#[test]
fn summary_credits_without_autotopup_shows_disabled() {
let b = CreditBalance {
prepaid_balance_cents: Some(10000),
..bal(25.0)
};
assert_eq!(
format_usage_summary(&b, None),
"Usage: 25%\n\nCredits: $100\nAuto topup: disabled"
);
// A disabled rule renders the same.
assert_eq!(
format_usage_summary(&b, Some(&topup(false, Some(2000), Some(10000)))),
"Usage: 25%\n\nCredits: $100\nAuto topup: disabled"
);
}
#[test]
fn summary_autotopup_enabled_without_max_omits_max() {
let b = CreditBalance {
prepaid_balance_cents: Some(10000),
..bal(25.0)
};
assert_eq!(
format_usage_summary(&b, Some(&topup(true, Some(2000), None))),
"Usage: 25%\n\nCredits: $100\nAuto topup: $20"
);
}
#[test]
fn summary_autotopup_enabled_with_max_renders_all() {
let b = CreditBalance {
period_end_display: Some("June 14, 16:00".into()),
prepaid_balance_cents: Some(10000),
..bal(25.0)
};
assert_eq!(
format_usage_summary(&b, Some(&topup(true, Some(2000), Some(10000)))),
"Usage: 25%\nNext reset: June 14, 16:00\n\nCredits: $100\nAuto topup: $20\nMax monthly topup: $100"
);
}
#[test]
fn summary_formats_fractional_dollars() {
let b = CreditBalance {
prepaid_balance_cents: Some(1250),
..bal(25.0)
};
assert_eq!(
format_usage_summary(&b, Some(&topup(true, Some(550), None))),
"Usage: 25%\n\nCredits: $12.50\nAuto topup: $5.50"
);
}
#[test]
fn summary_abs_negative_billing_amounts() {
// Billing returns credit / top-up amounts as negative cents; the
// summary must render them as positive USD (matching the web).
let b = CreditBalance {
prepaid_balance_cents: Some(-500),
..bal(100.0)
};
assert_eq!(
format_usage_summary(&b, Some(&topup(true, Some(-500), Some(-1000)))),
"Usage: 100%\n\nCredits: $5\nAuto topup: $5\nMax monthly topup: $10"
);
}
#[test]
fn summary_pay_as_you_go_enabled_renders_used_of_limit() {
let b = CreditBalance {
pay_as_you_go: true,
on_demand_used_cents: Some(355),
on_demand_cap_cents: Some(5000),
period_type: Some("USAGE_PERIOD_TYPE_MONTHLY".into()),
period_end_display: Some("June 30, 16:00".into()),
..bal(91.0)
};
assert_eq!(
format_usage_summary(&b, None),
"Monthly limit: 91%\nNext reset: June 30, 16:00\n\nPay-as-you-go: $3.55 used of $50.00 limit"
);
}
#[test]
fn summary_pay_as_you_go_disabled_omits_line() {
let b = CreditBalance {
pay_as_you_go: false,
period_type: Some("USAGE_PERIOD_TYPE_MONTHLY".into()),
period_end_display: Some("June 30, 16:00".into()),
..bal(91.0)
};
assert_eq!(
format_usage_summary(&b, None),
"Monthly limit: 91%\nNext reset: June 30, 16:00"
);
}
// ── usage_label / period type ────────────────────────────────────
fn bal_period(pct: f64, period_type: &str) -> CreditBalance {
CreditBalance {
period_type: Some(period_type.to_string()),
..bal(pct)
}
}
#[test]
fn usage_label_from_period_type() {
assert_eq!(
bal_period(0.0, "USAGE_PERIOD_TYPE_WEEKLY").usage_label(),
"Weekly limit"
);
assert_eq!(
bal_period(0.0, "USAGE_PERIOD_TYPE_MONTHLY").usage_label(),
"Monthly limit"
);
// Unknown / unspecified / absent → falls back to "Usage".
assert_eq!(
bal_period(0.0, "USAGE_PERIOD_TYPE_UNSPECIFIED").usage_label(),
"Usage"
);
assert_eq!(bal(0.0).usage_label(), "Usage");
}
#[test]
fn summary_uses_period_label() {
let weekly = bal_period(25.0, "USAGE_PERIOD_TYPE_WEEKLY");
assert_eq!(format_usage_summary(&weekly, None), "Weekly limit: 25%");
let monthly = bal_period(25.0, "USAGE_PERIOD_TYPE_MONTHLY");
assert_eq!(format_usage_summary(&monthly, None), "Monthly limit: 25%");
}
#[test]
fn warning_uses_period_label() {
let weekly = bal_period(92.0, "USAGE_PERIOD_TYPE_WEEKLY");
assert_eq!(
usage_warning(&weekly, None, true),
Some(("Weekly limit left: 8%".to_string(), false))
);
}
#[test]
fn summary_floors_usage_percent() {
// Match the backend SpendingLimiter (`as u8` truncation): 99.994% must
// render as 99%, not round up to 100%.
let almost = bal_period(99.994, "USAGE_PERIOD_TYPE_WEEKLY");
assert_eq!(format_usage_summary(&almost, None), "Weekly limit: 99%");
// A true 100% still shows 100%.
let full = bal_period(100.0, "USAGE_PERIOD_TYPE_WEEKLY");
assert_eq!(format_usage_summary(&full, None), "Weekly limit: 100%");
}
#[test]
fn warning_percent_left_is_floor_complement() {
// 99.994% used → floored to 99% → "1% left" (not "0% left"), so the
// warning and the floored summary always sum to 100.
let almost = bal_period(99.994, "USAGE_PERIOD_TYPE_WEEKLY");
assert_eq!(
usage_warning(&almost, None, true),
Some(("Weekly limit left: 1%".to_string(), true))
);
// A true 100% (no credits) → "0% left".
let full = bal_period(100.0, "USAGE_PERIOD_TYPE_WEEKLY");
assert_eq!(
usage_warning(&full, None, true),
Some(("Weekly limit left: 0%".to_string(), true))
);
}
// ── usage_warning (prompt info row) ──────────────────────────────
#[test]
fn warning_usage_model_thresholds() {
assert_eq!(usage_warning(&bal(50.0), None, true), None);
assert_eq!(
usage_warning(&bal(92.0), None, true),
Some(("Usage left: 8%".to_string(), false))
);
assert_eq!(
usage_warning(&bal(97.0), None, true),
Some(("Usage left: 3%".to_string(), true))
);
}
#[test]
fn warning_hidden_for_team_users() {
assert_eq!(usage_warning(&bal(99.0), None, false), None);
let credits = CreditBalance {
prepaid_balance_cents: Some(100),
..bal(0.0)
};
assert_eq!(usage_warning(&credits, None, false), None);
}
#[test]
fn warning_credits_unknown_topup_is_suppressed() {
// At 100% usage with prepaid credits, but the rule isn't known yet
// (None) — never warn; it resolves on the next billing fetch.
let b = CreditBalance {
prepaid_balance_cents: Some(100),
..bal(100.0)
};
assert_eq!(usage_warning(&b, None, true), None);
}
#[test]
fn warning_credits_suppressed_below_full_usage() {
// Low credits + no auto top-up, but the included allowance still has
// room (usage < 100%) → no warning (credits aren't being spent yet).
let disabled = topup(false, None, None);
let low = CreditBalance {
prepaid_balance_cents: Some(453),
..bal(0.0)
};
assert_eq!(usage_warning(&low, Some(&disabled), true), None);
// Same balance once the allowance is exhausted → warn.
let exhausted = CreditBalance {
prepaid_balance_cents: Some(453),
..bal(100.0)
};
assert_eq!(
usage_warning(&exhausted, Some(&disabled), true),
Some(("Credits left: $4.53".to_string(), true))
);
}
#[test]
fn warning_credits_no_topup_low_shows_dollars() {
// "No auto top-up" is a known, disabled rule (not an unresolved None).
let b = CreditBalance {
prepaid_balance_cents: Some(453),
..bal(100.0)
};
let disabled = topup(false, None, None);
assert_eq!(
usage_warning(&b, Some(&disabled), true),
Some(("Credits left: $4.53".to_string(), true))
);
}
#[test]
fn warning_credits_no_topup_above_threshold_silent() {
let disabled = topup(false, None, None);
let b = CreditBalance {
prepaid_balance_cents: Some(1500),
..bal(100.0)
};
assert_eq!(usage_warning(&b, Some(&disabled), true), None);
// Exactly $10 is still "low".
let at_ten = CreditBalance {
prepaid_balance_cents: Some(1000),
..bal(100.0)
};
assert_eq!(
usage_warning(&at_ten, Some(&disabled), true),
Some(("Credits left: $10".to_string(), true))
);
}
#[test]
fn warning_credits_topup_no_max_never_warns() {
let b = CreditBalance {
prepaid_balance_cents: Some(1),
..bal(100.0)
};
assert_eq!(
usage_warning(&b, Some(&topup(true, Some(2000), None)), true),
None
);
}
#[test]
fn warning_credits_topup_with_max_below_topup_amount() {
// $15 balance, $20 top-up amount, $100 max → below one top-up → warn.
let b = CreditBalance {
prepaid_balance_cents: Some(1500),
..bal(100.0)
};
assert_eq!(
usage_warning(&b, Some(&topup(true, Some(2000), Some(10000))), true),
Some(("Credits left: $15".to_string(), true))
);
let plenty = CreditBalance {
prepaid_balance_cents: Some(2500),
..bal(100.0)
};
assert_eq!(
usage_warning(&plenty, Some(&topup(true, Some(2000), Some(10000))), true),
None
);
}
#[test]
fn warning_credits_handles_negative_cents() {
let b = CreditBalance {
prepaid_balance_cents: Some(-453),
..bal(100.0)
};
assert_eq!(
usage_warning(&b, Some(&topup(true, Some(-2000), Some(-10000))), true),
Some(("Credits left: $4.53".to_string(), true))
);
}
#[test]
fn warning_credits_take_precedence_over_usage() {
// A credits user below 100% usage gets no warning at all (no usage-%
// warning, and credits aren't being spent yet) — unlike a non-credits
// user, who would see "Usage left: 1%" at 99%.
let b = CreditBalance {
prepaid_balance_cents: Some(5000),
..bal(99.0)
};
assert_eq!(
usage_warning(&b, Some(&topup(false, None, None)), true),
None
);
// Zero prepaid falls back to the usage model.
let zero = CreditBalance {
prepaid_balance_cents: Some(0),
..bal(99.0)
};
assert_eq!(
usage_warning(&zero, None, true),
Some(("Usage left: 1%".to_string(), true))
);
}
// ── usage_warning: pay-as-you-go (monthly on-demand) ─────────────
fn pay_as_you_go(usage_pct: f64, cap_cents: i64, used_cents: i64) -> CreditBalance {
CreditBalance {
pay_as_you_go: true,
on_demand_cap_cents: Some(cap_cents),
on_demand_used_cents: Some(used_cents),
period_type: Some("USAGE_PERIOD_TYPE_MONTHLY".into()),
..bal(usage_pct)
}
}
#[test]
fn warning_pay_as_you_go_low_dollars_shows_remaining() {
// $50 cap, $42 used → $8 left → grey (above $5).
let grey = pay_as_you_go(100.0, 5000, 4200);
assert_eq!(
usage_warning(&grey, None, true),
Some(("Pay-as-you-go limit left: $8".to_string(), false))
);
// $50 cap, $46 used → $4 left → critical (yellow).
let yellow = pay_as_you_go(100.0, 5000, 4600);
assert_eq!(
usage_warning(&yellow, None, true),
Some(("Pay-as-you-go limit left: $4".to_string(), true))
);
}
#[test]
fn warning_pay_as_you_go_boundaries() {
// Exactly $10 left → show, grey.
let at_ten = pay_as_you_go(100.0, 5000, 4000);
assert_eq!(
usage_warning(&at_ten, None, true),
Some(("Pay-as-you-go limit left: $10".to_string(), false))
);
// Exactly $5 left → critical (yellow).
let at_five = pay_as_you_go(100.0, 5000, 4500);
assert_eq!(
usage_warning(&at_five, None, true),
Some(("Pay-as-you-go limit left: $5".to_string(), true))
);
}
#[test]
fn warning_pay_as_you_go_above_threshold_silent() {
// $20 left (> $10) → no warning.
let b = pay_as_you_go(100.0, 5000, 3000);
assert_eq!(usage_warning(&b, None, true), None);
}
#[test]
fn warning_pay_as_you_go_suppressed_below_full_usage() {
// Pay-as-you-go users get NO percentage warning before the included
// allowance is exhausted, even with low on-demand room remaining.
let b = pay_as_you_go(95.0, 5000, 4800);
assert_eq!(usage_warning(&b, None, true), None);
}
#[test]
fn warning_pay_as_you_go_fractional_dollars() {
// $50 cap, $46.50 used → $3.50 left → critical, fractional formatting.
let b = pay_as_you_go(100.0, 5000, 4650);
assert_eq!(
usage_warning(&b, None, true),
Some(("Pay-as-you-go limit left: $3.50".to_string(), true))
);
}
#[test]
fn test_credit_bar_line_shows_percentage() {
let theme = Theme::default();
let line = credit_bar_line(&bal(24.0), false, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "Credits used: 24%");
}
#[test]
fn test_color_thresholds() {
let theme = Theme::default();
let low = credit_bar_line(&bal(50.0), false, &theme);
assert_eq!(low.spans[0].style.fg, Some(theme.accent_success));
let high = credit_bar_line(&bal(85.0), false, &theme);
assert_eq!(high.spans[0].style.fg, Some(theme.warning));
let over = credit_bar_line(&bal(100.0), false, &theme);
assert_eq!(over.spans[0].style.fg, Some(theme.accent_error));
}
#[test]
fn test_zero_percent() {
let theme = Theme::default();
let line = credit_bar_line(&bal(0.0), false, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "Credits used: 0%");
assert_eq!(line.spans[0].style.fg, Some(theme.accent_success));
}
#[test]
fn test_boundary_at_80_percent() {
let theme = Theme::default();
// Exactly 80% should be warning (yellow).
let at_80 = credit_bar_line(&bal(80.0), false, &theme);
assert_eq!(at_80.spans[0].style.fg, Some(theme.warning));
// Just below 80% should be success (green).
let below_80 = credit_bar_line(&bal(79.9), false, &theme);
assert_eq!(below_80.spans[0].style.fg, Some(theme.accent_success));
}
#[test]
fn test_boundary_at_100_percent() {
let theme = Theme::default();
// Exactly 100% should be error (red).
let at_100 = credit_bar_line(&bal(100.0), false, &theme);
assert_eq!(at_100.spans[0].style.fg, Some(theme.accent_error));
// Just below 100% should be warning (yellow).
let below_100 = credit_bar_line(&bal(99.9), false, &theme);
assert_eq!(below_100.spans[0].style.fg, Some(theme.warning));
}
#[test]
fn test_over_100_percent() {
let theme = Theme::default();
let line = credit_bar_line(&bal(150.0), false, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "Credits used: 150%");
assert_eq!(line.spans[0].style.fg, Some(theme.accent_error));
}
#[test]
fn test_fractional_percentage_rounds_display() {
let theme = Theme::default();
let line = credit_bar_line(&bal(33.7), false, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "Credits used: 34%");
}
#[test]
fn test_credit_balance_with_on_demand_fields() {
let balance = CreditBalance {
effective_usage_pct: 25.0,
period_end_display: Some("Jun 1, 00:00".into()),
pay_as_you_go: true,
on_demand_cap_cents: Some(2000),
on_demand_used_cents: Some(500),
..bal(50.0)
};
let theme = Theme::default();
// The credit bar uses usage_pct (not effective_usage_pct).
let line = credit_bar_line(&balance, false, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "Credits used: 50%");
}
#[test]
fn gateway_chat_suppresses_credit_bar_and_usage_warning() {
let theme = Theme::default();
let b = bal(90.0);
assert!(credit_bar_line_for_session(&b, false, &theme, true).is_none());
assert!(usage_warning_for_session(&b, None, true, true).is_none());
// Build path still renders.
assert!(credit_bar_line_for_session(&b, false, &theme, false).is_some());
}
}
@@ -0,0 +1,736 @@
//! Pure layout computation for the dashboard view.
use ratatui::layout::Rect;
/// Minimum width at which the dashboard can render meaningful rows.
/// Below this, the renderer falls back to a stripped, single-column
/// view; row labels are middle-truncated.
pub const MIN_DASHBOARD_WIDTH: u16 = 40;
/// Minimum total height at which the peek panel is allowed to render.
/// Below this we drop the peek section even when toggled on so the
/// row list still has room to breathe.
pub const MIN_PEEK_HEIGHT: u16 = 12;
/// Outer horizontal padding for the dispatch box (cols on each side).
///
/// Matches `LayoutConfig::outer_hpad_left/right = 2` from the agent
/// view's default appearance config.
pub const DISPATCH_OUTER_HPAD: u16 = 2;
/// Outer horizontal padding for the top page header (cols on each side).
/// Slightly less than the list to give the title and status chips a bit
/// more horizontal real estate.
pub const HEADER_OUTER_HPAD: u16 = 1;
/// Outer horizontal padding for the row list (cols on each side).
///
/// Gives the list (rows + group headers + scrollbar) breathing room so
/// selection markers, group header rules (`────`), and row text don't
/// sit flush against the terminal edges.
pub const LIST_OUTER_HPAD: u16 = 2;
/// Output of [`compute_layout`].
#[derive(Debug, Clone, Copy)]
pub struct DashboardLayout {
/// Top margin row (blank space above the header). Height: 0 or 1.
/// Matches the welcome view's `v_margin` so the
/// dashboard's title row doesn't sit flush against the alt-screen
/// top edge.
pub top_margin: Rect,
/// Header row (title + summary). Height: 0 or 1.
pub header: Rect,
/// Vertical breathing room between the header and the row list.
/// Height: 0 or 1. Drops to 0 on short terminals (`area.height
/// <= 10`, mirroring the dispatch/shortcuts gap threshold).
/// No sub-renderer ever touches this rect — the area-wide
/// `bg_base` fill paints it. Conceptually it's the same "blank
/// breathing-room row" as the dispatch_gap / shortcuts_gap (it's
/// kept as a named rect rather than an anonymous y-cursor bump
/// only so tests can pin its position and threshold).
pub header_gap: Rect,
/// Scrollable list area (rows + group headers).
pub list: Rect,
/// Peek panel area, or `Rect::default()` when hidden.
pub peek: Rect,
/// Bottom dispatch input area.
pub dispatch: Rect,
/// Footer / shortcut hint row.
pub footer: Rect,
/// Bottom margin row (blank space below the shortcuts bar).
/// Height: 0 or 1. Matches the agent view's
/// `bottom_vpad` from `eff_outer_vpad` in `LayoutConfig::default`
/// so the dashboard's shortcuts bar doesn't sit flush against the
/// alt-screen's bottom edge. Drops to 0 on short terminals
/// (`area.height <= 16`, same threshold as
/// `views::agent::AgentViewLayout::compute`).
pub bottom_margin: Rect,
}
/// Compute the dashboard layout for a given content area.
///
/// `peek_visible` requests the peek panel; the layout shows it only
/// when the area has enough vertical room (edge case 9). When the area
/// is narrower than [`MIN_DASHBOARD_WIDTH`], the layout still returns a
/// valid arrangement (the renderer truncates labels).
pub fn compute_layout(area: Rect, peek_visible: bool) -> DashboardLayout {
// Single text row is the default — callers that support a growing
// multiline dispatch box (Shift+Enter newlines) use
// [`compute_layout_with_dispatch`] to request more.
compute_layout_with_dispatch(area, peek_visible, 1)
}
/// Like [`compute_layout`] but lets the caller request a taller
/// dispatch box. `dispatch_text_rows` is the number of *text* rows the
/// dispatch input wants (≥1); the box adds 2 more for its top/bottom
/// border chrome. Used to grow the box as the user inserts newlines
/// (Shift+Enter) so multiline dispatch prompts are fully visible.
///
/// The caller is responsible for clamping `dispatch_text_rows` so the
/// row list keeps usable space; this function only enforces a ≥1 floor.
pub fn compute_layout_with_dispatch(
area: Rect,
peek_visible: bool,
dispatch_text_rows: u16,
) -> DashboardLayout {
// When `area.height == 0`, every subrect collapses
// to zero. A footer_h = 1 default would produce a non-zero
// footer rect even on a 0-height area.
if area.height == 0 {
let z = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 0,
};
return DashboardLayout {
top_margin: z,
header: z,
header_gap: z,
list: z,
peek: z,
dispatch: z,
footer: z,
bottom_margin: z,
};
}
// Match the welcome / agent view's top margin so
// the dashboard's header doesn't sit flush against the alt-screen's
// top edge. The welcome view uses `v_margin = 1` (see
// `views::welcome::render_welcome`). Dropped to 0 on very short
// terminals so we don't starve the row list.
let top_margin_h: u16 = if area.height > 6 { 1 } else { 0 };
let header_h: u16 = if area.height > 4 { 1 } else { 0 };
// 1-row gap between the header and the row list so the title /
// status chips don't sit flush against the first row (or the
// first group header). Collapses on short terminals so the row
// list isn't starved (same threshold as the dispatch/shortcuts
// gaps).
let header_gap_h: u16 = if area.height > 10 { 1 } else { 0 };
let footer_h: u16 = if area.height >= 2 { 1 } else { 0 };
// Vertical gaps around the dispatch box, matching
// the agent view's `prompt_gap` and `shortcuts_gap` (both = 1) so
// the dispatch chrome doesn't sit flush against the list above or
// the footer below. Gaps drop to 0 on short terminals so the row
// list still gets visible space. Computed BEFORE `dispatch_h` so the
// content-sized peek box can leave the row list at least one row.
let dispatch_gap_h: u16 = if area.height > 10 { 1 } else { 0 };
let shortcuts_gap_h: u16 = if area.height > 10 { 1 } else { 0 };
// Bottom margin below the shortcuts bar, matching
// the agent view's `bottom_vpad` (`outer_vpad = 1` from
// `LayoutConfig::default` dropped to 0 when `area.height <= 16`).
let bottom_margin_h: u16 = if area.height > 16 { 1 } else { 0 };
// The peek panel sizes to its CONTENT instead of a fixed
// height. Its inner rows are: status (1) + wrapped response (N) +
// one blank breathing row (1) + ` reply` (1); the caller passes
// that inner content count via `dispatch_text_rows` (floored at
// status + blank + reply = 3 when there's no response yet). Adding
// the 2 borders gives the box height, clamped so the row list keeps
// at least one visible row.
//
// Otherwise (no peek) the dispatch reserves 2 borders + N text rows
// so the rounded box reads as a real input field and grows for
// multiline (Alt+Enter) prompts. Very short terminals (height ≤ 8)
// fall back to a single line so the row list isn't starved.
let dispatch_h: u16 = if peek_visible {
if area.height <= 8 {
1
} else {
let fixed_overhead = top_margin_h
+ header_h
+ header_gap_h
+ footer_h
+ dispatch_gap_h
+ shortcuts_gap_h
+ bottom_margin_h;
let content = dispatch_text_rows.max(3);
let desired = content + 2;
// Keep ≥1 row for the list; never collapse below a 3-row box.
let max_box = area.height.saturating_sub(fixed_overhead + 1).max(3);
desired.min(max_box)
}
} else if area.height > 8 {
2 + dispatch_text_rows.max(1)
} else {
1
};
// Standalone peek rect retired. Peek now renders
// INSIDE the dispatch rect (which grows when `peek_visible`,
// computed above). Kept as a zero-height field for ABI compat
// with the existing call sites that still destructure
// `layout.peek`; the field can be removed in a follow-up
// cleanup.
let peek_h: u16 = 0;
let remaining = area.height.saturating_sub(
top_margin_h
+ header_h
+ header_gap_h
+ footer_h
+ dispatch_h
+ peek_h
+ dispatch_gap_h
+ shortcuts_gap_h
+ bottom_margin_h,
);
let mut y = area.y;
let top_margin = Rect {
x: area.x,
y,
width: area.width,
height: top_margin_h,
};
y += top_margin_h;
// Inset the top page header using its own (slightly smaller) padding
// so the title and status chips have breathing room without losing
// as much width as the list content.
let header_inner_pad = HEADER_OUTER_HPAD.saturating_mul(2);
let header_width = area.width.saturating_sub(header_inner_pad);
let header_x = if header_width > 0 {
area.x.saturating_add(HEADER_OUTER_HPAD)
} else {
area.x
};
let header = Rect {
x: header_x,
y,
width: if header_width > 0 {
header_width
} else {
area.width
},
height: header_h,
};
y += header_h;
// 1-row gap between header and list (collapsed on short
// terminals). Painted by `render_dashboard`'s full-area fill —
// no sub-renderer touches it.
let header_gap = Rect {
x: area.x,
y,
width: area.width,
height: header_gap_h,
};
y += header_gap_h;
// Polish — inset the list by LIST_OUTER_HPAD on each side so the
// row content and group header rules have side breathing room.
// The outer columns stay painted bg_base by the area-wide fill in
// render_dashboard. Mirrors the dispatch inset pattern but with a
// smaller pad (1 vs 2) because row text is long and dense.
let list_inner_pad = LIST_OUTER_HPAD.saturating_mul(2);
let list_width = area.width.saturating_sub(list_inner_pad);
let list_x = if list_width > 0 {
area.x.saturating_add(LIST_OUTER_HPAD)
} else {
area.x
};
let list = Rect {
x: list_x,
y,
width: if list_width > 0 {
list_width
} else {
area.width
},
height: remaining,
};
y += remaining;
let peek = if peek_h > 0 {
let r = Rect {
x: area.x,
y,
width: area.width,
height: peek_h,
};
y += peek_h;
r
} else {
Rect::default()
};
// 1-row gap between list/peek and the dispatch
// box (mirrors `prompt_gap` in `views::agent::AgentViewLayout`).
y += dispatch_gap_h;
// Single-line dispatch input keeps its 2-col
// outer padding so the `` prefix lines up with the row content
// (rows are indented past the marker column too).
let dispatch_inner_pad = DISPATCH_OUTER_HPAD.saturating_mul(2);
let dispatch_width = area.width.saturating_sub(dispatch_inner_pad);
let dispatch_x = if dispatch_width > 0 {
area.x.saturating_add(DISPATCH_OUTER_HPAD)
} else {
area.x
};
let dispatch = Rect {
x: dispatch_x,
y,
width: if dispatch_width > 0 {
dispatch_width
} else {
area.width
},
height: dispatch_h,
};
y += dispatch_h;
// 1-row gap between the dispatch box and the
// shortcuts footer (mirrors `shortcuts_gap`).
y += shortcuts_gap_h;
let footer = Rect {
x: area.x,
y,
width: area.width,
height: footer_h,
};
y += footer_h;
// Bottom margin row below the shortcuts bar
// (matches the agent view's `bottom_vpad`). Painted with
// `bg_base` by `render_dashboard`'s full-area fill — no
// sub-renderer ever touches this rect.
let bottom_margin = Rect {
x: area.x,
y,
width: area.width,
height: bottom_margin_h,
};
DashboardLayout {
top_margin,
header,
header_gap,
list,
peek,
dispatch,
footer,
bottom_margin,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layout_assigns_disjoint_areas() {
let area = Rect::new(0, 0, 80, 30);
let layout = compute_layout(area, true);
// top_margin + header + list + peek + dispatch + footer
// + bottom_margin == total. Three blank rows sit between the
// content rects: the header_gap (between header and list),
// the dispatch_gap (between list/peek and dispatch), and the
// shortcuts_gap (between dispatch and footer). The
// bottom_margin IS a rect with bg_base, so it stays inside
// `total`. Same mental model as the dispatch/shortcuts
// gaps — those are intentional blank breathing-room rows
// that the area-wide bg fill paints without any dedicated
// sub-renderer.
let total = layout.top_margin.height
+ layout.header.height
+ layout.list.height
+ layout.peek.height
+ layout.dispatch.height
+ layout.footer.height
+ layout.bottom_margin.height;
// 3 rows are absorbed by the gaps (header_gap +
// dispatch_gap + shortcuts_gap).
assert_eq!(total + 3, area.height);
}
/// Multiline dispatch: the box grows by exactly one row per extra
/// text row (2 border rows + N text rows), and the row list gives up
/// the space so the totals still tile the area.
#[test]
fn dispatch_box_grows_for_multiline_input() {
let area = Rect::new(0, 0, 80, 30);
let single = compute_layout(area, false);
// Single-line default is 3 rows (top border + 1 text + bottom).
assert_eq!(single.dispatch.height, 3);
let three = compute_layout_with_dispatch(area, false, 3);
assert_eq!(
three.dispatch.height, 5,
"3 text rows → 2 border + 3 text = 5 rows",
);
// The list absorbs the extra two rows the dispatch box took.
assert_eq!(
three.list.height + 2,
single.list.height,
"the row list must shrink by exactly the dispatch growth",
);
// Dispatch still sits above the footer with the same gap.
assert_eq!(three.footer.y, three.dispatch.y + three.dispatch.height + 1);
}
/// A `dispatch_text_rows` of 0 is floored to a single text row so the
/// box never collapses below its single-line chrome.
#[test]
fn dispatch_box_floors_at_single_text_row() {
let area = Rect::new(0, 0, 80, 30);
let zero = compute_layout_with_dispatch(area, false, 0);
assert_eq!(zero.dispatch.height, 3, "0 text rows floors to 1 (3 total)");
}
/// The dashboard reserves 1 row of bottom margin
/// below the shortcuts bar on tall enough terminals so the
/// shortcuts don't sit flush against the alt-screen's bottom edge.
/// Mirrors the agent view's `bottom_vpad` (`outer_vpad = 1`,
/// dropped to 0 at `area.height <= 16`).
#[test]
fn layout_reserves_bottom_margin_on_tall_terminals() {
let area = Rect::new(0, 0, 80, 30);
let layout = compute_layout(area, false);
assert_eq!(
layout.bottom_margin.height, 1,
"tall terminal must reserve a bottom margin row",
);
// Bottom margin must sit directly below the footer.
assert_eq!(
layout.bottom_margin.y,
layout.footer.y + layout.footer.height,
"bottom_margin must sit directly below the footer",
);
// Bottom margin must end exactly at area.height — no slack
// before the alt-screen's bottom edge.
assert_eq!(
layout.bottom_margin.y + layout.bottom_margin.height,
area.y + area.height,
"bottom_margin must extend to the bottom of `area`",
);
}
/// Bottom margin collapses to 0 on short
/// terminals (`area.height <= 16`, matching the agent view's
/// threshold) so the row list isn't starved.
#[test]
fn layout_drops_bottom_margin_on_short_terminals() {
let area = Rect::new(0, 0, 80, 16);
let layout = compute_layout(area, false);
assert_eq!(layout.bottom_margin.height, 0);
}
/// Dispatch box gets `DISPATCH_OUTER_HPAD` cols of
/// outer padding on each side so its rounded border doesn't reach
/// the terminal edge. Matches the agent view's `outer_hpad_left/right`.
#[test]
fn layout_applies_outer_hpad_to_dispatch_box() {
let area = Rect::new(0, 0, 80, 30);
let layout = compute_layout(area, false);
assert_eq!(
layout.dispatch.x,
area.x + DISPATCH_OUTER_HPAD,
"dispatch must be inset by DISPATCH_OUTER_HPAD on the left",
);
assert_eq!(
layout.dispatch.width,
area.width - DISPATCH_OUTER_HPAD * 2,
"dispatch width must lose DISPATCH_OUTER_HPAD on each side",
);
}
/// Header is inset by HEADER_OUTER_HPAD; list by LIST_OUTER_HPAD.
/// Footer remains full-width.
#[test]
fn layout_insets_header_and_list() {
let area = Rect::new(0, 0, 80, 30);
let layout = compute_layout(area, false);
assert_eq!(
layout.header.width,
area.width - HEADER_OUTER_HPAD * 2,
"header must be inset by HEADER_OUTER_HPAD on each side",
);
assert_eq!(layout.footer.width, area.width);
// List is intentionally inset by LIST_OUTER_HPAD on each side.
assert_eq!(layout.list.width, area.width - LIST_OUTER_HPAD * 2);
}
/// Polish — the list rect is inset by LIST_OUTER_HPAD cols on each
/// side so row content (markers, rules, text) has breathing room
/// and doesn't touch the terminal edges. The outer columns remain
/// bg_base (painted by the top-level area fill).
#[test]
fn layout_applies_outer_hpad_to_list() {
let area = Rect::new(0, 0, 80, 30);
let layout = compute_layout(area, false);
assert_eq!(
layout.list.x,
area.x + LIST_OUTER_HPAD,
"list must be inset by LIST_OUTER_HPAD on the left",
);
assert_eq!(
layout.list.width,
area.width - LIST_OUTER_HPAD * 2,
"list width must lose LIST_OUTER_HPAD on each side",
);
}
/// The header rect is inset by HEADER_OUTER_HPAD (slightly less
/// than the list) for side breathing room on the title and status chips.
#[test]
fn layout_applies_outer_hpad_to_header() {
let area = Rect::new(0, 0, 80, 30);
let layout = compute_layout(area, false);
assert_eq!(
layout.header.x,
area.x + HEADER_OUTER_HPAD,
"header must be inset by HEADER_OUTER_HPAD on the left",
);
assert_eq!(
layout.header.width,
area.width - HEADER_OUTER_HPAD * 2,
"header width must lose HEADER_OUTER_HPAD on each side",
);
}
/// A 1-row gap separates the list/peek from the
/// dispatch box, and another 1-row gap separates the dispatch box
/// from the footer. Mirrors `prompt_gap` + `shortcuts_gap` in the
/// agent view's layout.
#[test]
fn layout_reserves_dispatch_and_shortcuts_gaps() {
let area = Rect::new(0, 0, 80, 30);
let layout = compute_layout(area, false);
// 1-row gap before dispatch: dispatch.y - (list.y + list.height) == 1.
let list_end = layout.list.y + layout.list.height;
assert_eq!(
layout.dispatch.y - list_end,
1,
"expected 1-row gap between list and dispatch, got {} (list_end={list_end}, dispatch.y={})",
layout.dispatch.y - list_end,
layout.dispatch.y,
);
// 1-row gap before footer: footer.y - (dispatch.y + dispatch.height) == 1.
let dispatch_end = layout.dispatch.y + layout.dispatch.height;
assert_eq!(
layout.footer.y - dispatch_end,
1,
"expected 1-row gap between dispatch and footer, got {} (dispatch_end={dispatch_end}, footer.y={})",
layout.footer.y - dispatch_end,
layout.footer.y,
);
}
/// Gaps collapse to 0 on short terminals so the
/// row list isn't starved. Threshold mirrors the top-margin
/// threshold pattern (`> 10`).
#[test]
fn layout_drops_gaps_on_short_terminals() {
let area = Rect::new(0, 0, 80, 10);
let layout = compute_layout(area, false);
let list_end = layout.list.y + layout.list.height;
let dispatch_end = layout.dispatch.y + layout.dispatch.height;
assert_eq!(
layout.dispatch.y - list_end,
0,
"short terminal must collapse dispatch gap",
);
assert_eq!(
layout.footer.y - dispatch_end,
0,
"short terminal must collapse shortcuts gap",
);
}
/// The dashboard reserves a 1-row gap between
/// the header and the row list on tall enough terminals so the
/// status chips / `Dashboard` label don't sit flush against the
/// first group header or row. The gap sits immediately below the
/// header and immediately above the list.
#[test]
fn layout_reserves_header_gap_on_tall_terminals() {
let area = Rect::new(0, 0, 80, 30);
let layout = compute_layout(area, false);
assert_eq!(
layout.header_gap.height, 1,
"tall terminal must reserve a header gap row",
);
// Header gap sits directly below the header.
assert_eq!(
layout.header_gap.y,
layout.header.y + layout.header.height,
"header_gap must sit directly below the header",
);
// List starts directly below the header gap.
assert_eq!(
layout.list.y,
layout.header_gap.y + layout.header_gap.height,
"list must start directly below the header_gap",
);
}
/// The header gap collapses to 0 on short
/// terminals (`area.height <= 10`, same threshold as the
/// dispatch / shortcuts gaps) so the row list still gets visible
/// space.
#[test]
fn layout_drops_header_gap_on_short_terminals() {
let area = Rect::new(0, 0, 80, 10);
let layout = compute_layout(area, false);
assert_eq!(
layout.header_gap.height, 0,
"short terminal must collapse header_gap",
);
// And the list must start immediately below the header (no
// implicit gap left behind).
assert_eq!(
layout.list.y,
layout.header.y + layout.header.height,
"list must start directly below the header when the gap collapses",
);
}
/// The dashboard reserves one row of top margin
/// on terminals tall enough to spare it, mirroring the welcome
/// view's `v_margin`. Below the height threshold the margin
/// collapses to 0 so the row list isn't starved.
#[test]
fn layout_reserves_top_margin_on_tall_terminals() {
let area = Rect::new(0, 0, 80, 30);
let layout = compute_layout(area, false);
assert_eq!(
layout.top_margin.height, 1,
"tall terminal must reserve a top margin row",
);
assert_eq!(
layout.header.y,
area.y + 1,
"header must sit below the top margin",
);
}
/// Short terminals collapse the top margin to 0
/// so the row list still gets visible space. Threshold matches
/// the dispatch chrome's threshold (`area.height > 6`).
#[test]
fn layout_drops_top_margin_on_short_terminals() {
let area = Rect::new(0, 0, 80, 6);
let layout = compute_layout(area, false);
assert_eq!(layout.top_margin.height, 0);
}
#[test]
fn layout_hides_peek_when_too_short() {
let area = Rect::new(0, 0, 80, 8);
let layout = compute_layout(area, true);
assert_eq!(layout.peek.height, 0);
}
#[test]
fn layout_at_minimum_width_returns_valid_rect() {
let area = Rect::new(0, 0, MIN_DASHBOARD_WIDTH, 30);
let layout = compute_layout(area, false);
// List is inset by LIST_OUTER_HPAD on each side even at the
// minimum dashboard width (40 cols → 38 usable for content).
assert_eq!(layout.list.width, MIN_DASHBOARD_WIDTH - LIST_OUTER_HPAD * 2);
}
// Boundary tests: the existing three tests cover the happy paths;
// the following four close the boundary gaps explicitly.
/// Zero-height area produces zero-height sub-rects
/// (and doesn't panic on saturating subtraction).
#[test]
fn layout_height_zero_produces_zero_subrects() {
let area = Rect::new(0, 0, 80, 0);
let layout = compute_layout(area, true);
assert_eq!(layout.header.height, 0);
assert_eq!(layout.list.height, 0);
assert_eq!(layout.peek.height, 0);
assert_eq!(layout.dispatch.height, 0);
assert_eq!(layout.footer.height, 0);
}
/// One row below the peek minimum hides the peek.
#[test]
fn layout_just_below_min_peek_height_hides_peek() {
let area = Rect::new(0, 0, 80, MIN_PEEK_HEIGHT - 1);
let layout = compute_layout(area, true);
assert_eq!(layout.peek.height, 0);
}
/// The standalone peek rect was retired (peek now
/// renders INSIDE the dispatch box). The peek rect is always
/// zero-height; what changes when `peek_visible == true` is
/// the dispatch rect, which grows from 3 to 5 rows to host
/// the peek's status + reply input.
#[test]
fn layout_grows_dispatch_when_peek_visible() {
let area = Rect::new(0, 0, 80, 30);
let no_peek = compute_layout(area, false);
let with_peek = compute_layout(area, true);
assert_eq!(no_peek.peek.height, 0);
assert_eq!(with_peek.peek.height, 0);
assert!(
with_peek.dispatch.height > no_peek.dispatch.height,
"peek-visible must grow the dispatch rect, no_peek={} with_peek={}",
no_peek.dispatch.height,
with_peek.dispatch.height,
);
}
/// The peek box sizes to its content: 2 borders + the
/// inner content rows (status + response + blank + reply) the caller
/// passes via `dispatch_text_rows`. A bigger response → taller box.
#[test]
fn peek_box_sizes_to_content_rows() {
let area = Rect::new(0, 0, 80, 40);
// content = status(1) + blank(1) + reply(1) = 3 → box 5 (no response).
let empty = compute_layout_with_dispatch(area, true, 3);
// content = status + 3 response + blank + reply = 6 → box 8.
let full = compute_layout_with_dispatch(area, true, 6);
assert_eq!(empty.dispatch.height, 5);
assert_eq!(full.dispatch.height, 8);
assert!(full.dispatch.height > empty.dispatch.height);
// The list reclaims the rows the smaller box doesn't use.
assert!(empty.list.height > full.list.height);
}
/// Zero-width area returns valid zero-width rects.
#[test]
fn layout_width_zero_returns_zero_width_subrects() {
let area = Rect::new(0, 0, 0, 30);
let layout = compute_layout(area, false);
assert_eq!(layout.list.width, 0);
assert_eq!(layout.dispatch.width, 0);
}
/// A 39-wide area (below `MIN_DASHBOARD_WIDTH=40`)
/// returns valid sub-rects — the renderer will fall back to
/// narrow mode. List still receives its outer hpad inset.
#[test]
fn layout_width_below_min_returns_valid_subrects() {
let area = Rect::new(0, 0, MIN_DASHBOARD_WIDTH - 1, 30);
let layout = compute_layout(area, false);
assert_eq!(
layout.list.width,
MIN_DASHBOARD_WIDTH - 1 - LIST_OUTER_HPAD * 2
);
assert!(layout.list.height > 0);
}
}
@@ -0,0 +1,152 @@
//! Agent Dashboard — top-level overview of every session in flight.
//!
//! Centralised, agent-native list of every top-level agent and its subagents,
//! grouped by state, with peek, attach, and dispatch affordances.
//!
//! Owned by `AppView::dashboard` (`Option<DashboardState>`); active only when
//! `app.active_view == ActiveView::AgentDashboard`. State survives the user
//! closing and reopening the dashboard within a single pager process (the
//! `Option` is reset only on shutdown).
//!
//! ## Module layout
//!
//! - [`state`] — public `DashboardState`, `DashboardRowId`, `RowState`,
//! `Grouping`, `Filter`, `FilterValue`, `PersistedDashboard`.
//! - [`row`] — `DashboardRow`, `build_rows()`, classifiers, sort.
//! - [`layout`] — pure rect computation.
//! - [`render`] — `Widget`-style rendering routine.
//! - [`peek`] — peek panel state + rendering.
//!
//! ## Lifetime
//!
//! Rows are rebuilt every render frame off `app.agents` — no caching. The
//! per-row sort key (state + last_change_at) is recomputed each frame; for
//! a single pager process with single-digit numbers of agents this is
//! free.
pub mod layout;
pub mod peek;
pub mod render;
pub mod row;
pub mod state;
pub use render::render_dashboard;
pub use render::{
DashboardOverlayChrome, popup_rect, render_dashboard_session_header,
render_dashboard_session_overlay, render_popup_overlay,
};
pub use row::{
DashboardRow, RowBadge, build_rows, build_rows_with_roster, classify_subagent,
classify_top_level, sort_rows,
};
pub use state::{
DashboardDispatchMode, DashboardRowId, DashboardState, Filter, FilterValue, Focusable,
Grouping, LocationCandidate, LocationPickerState, PendingDispatchModel, PersistedDashboard,
PersistedRowId, RowState, SectionKey, SessionIdResolver, ShortcutsModalState, load_persisted,
parse_filter, parse_row_state_token,
};
/// Top-level agents visible in the dashboard's row list, in the
/// exact order [`render_dashboard`] paints them. Used by the
/// session overlay's cycle (the `[]` / `[]` chips and
/// `dispatch_dashboard_overlay_cycle`) so "previous" / "next"
/// follow what the user actually sees instead of the agent map's
/// insertion order. Subagent rows and `… N more` placeholders
/// are skipped — only attachable top-level rows show up.
pub fn overlay_cycle_order(
state: &DashboardState,
agents: &indexmap::IndexMap<crate::app::agent::AgentId, crate::app::agent_view::AgentView>,
) -> Vec<crate::app::agent::AgentId> {
let home = render::cached_home();
let rows = build_rows(
agents,
&state.pinned,
&state.reorder,
None,
state.grouping,
&state.filter,
home,
);
rows.iter()
.filter_map(|r| match &r.id {
DashboardRowId::TopLevel(id) if !r.is_more_placeholder => Some(*id),
_ => None,
})
.collect()
}
/// Whether the dashboard feature is enabled.
///
/// Order: env override (`KIGI_AGENT_DASHBOARD=0` → off) wins, else the
/// persisted `[dashboard].enabled` flag (default `true`).
///
/// The slash command and CLI subcommand check this before opening; on
/// `false` they print a friendly toast and stay where they are.
///
/// `var_os` avoids the per-call allocation of `var`.
pub fn dashboard_enabled() -> bool {
if std::env::var_os("KIGI_AGENT_DASHBOARD")
.as_deref()
.is_some_and(|v| v == std::ffi::OsStr::new("0"))
{
return false;
}
state::load_persisted_enabled().unwrap_or(true)
}
/// Command to name in the "use /X to switch between sessions" session
/// banners (the `/new` session-created banner and the fork marker).
///
/// Minimal mode has no dashboard — `/dashboard` is refused there — but the
/// `/resume` session picker still works, so point at it instead (regardless
/// of the dashboard flag, which gates a surface minimal doesn't have).
/// Outside minimal, `/dashboard` when the feature is enabled; `None` when it
/// is off — the tip would point at a refused command, so callers fall back
/// to a plain session-id banner.
pub(crate) fn session_switch_hint_command(minimal: bool) -> Option<&'static str> {
if minimal {
Some("/resume")
} else if dashboard_enabled() {
Some("/dashboard")
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Minimal mode always points at `/resume`: the dashboard is refused
/// there no matter what the feature flag says, so the hint must not
/// depend on it. Runs under the same serial key as the other
/// `KIGI_AGENT_DASHBOARD` env-mutating tests.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn switch_hint_minimal_is_resume_even_with_dashboard_disabled() {
// SAFETY: the test temporarily mutates a process-wide env var.
// `serial_test`'s lock ensures no other test marked with the same
// `KIGI_AGENT_DASHBOARD` key reads it concurrently.
unsafe { std::env::set_var("KIGI_AGENT_DASHBOARD", "0") };
assert_eq!(session_switch_hint_command(true), Some("/resume"));
unsafe { std::env::remove_var("KIGI_AGENT_DASHBOARD") };
}
/// Outside minimal the hint mirrors the dashboard flag: `None` when the
/// env override disables it (the tip would name a refused command),
/// otherwise whatever `dashboard_enabled()` says — asserted as
/// consistency, not a fixed value, so the test doesn't depend on the
/// machine's persisted `[dashboard].enabled`.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn switch_hint_non_minimal_follows_dashboard_flag() {
// SAFETY: see above — serialized on the KIGI_AGENT_DASHBOARD key.
unsafe { std::env::set_var("KIGI_AGENT_DASHBOARD", "0") };
assert_eq!(session_switch_hint_command(false), None);
unsafe { std::env::remove_var("KIGI_AGENT_DASHBOARD") };
assert_eq!(
session_switch_hint_command(false),
dashboard_enabled().then_some("/dashboard")
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,93 @@
//! Theme-agnostic chrome for debug overlays (scroll HUD, FPS HUD).
//!
//! Debug overlays float above themed content and must read identically on
//! every theme. Theme-relative styling fails on dark palettes — Oscura
//! Midnight's base background is `#030304`, so a panel that inherits the
//! theme background (or paints low-contrast foregrounds like `Color::Gray`)
//! blends straight into the frame behind it. These styles pin explicit
//! ANSI-16 colors (never the theme palette, never `Color::Reset`, which
//! defers to the terminal default) and build on [`Style::reset()`] so every
//! painted cell also sheds the modifiers (bold/dim/italic/underline) of
//! whatever themed text it covers.
//!
//! Contract: apply one of these styles to EVERY cell of the overlay rect,
//! trailing padding included, so no themed cell bleeds through the panel.
//! [`render_panel`] is the shared scaffold that enforces it structurally —
//! overlays build lines and call it rather than hand-painting cells.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
/// Body text: white on black, all inherited modifiers cleared.
pub fn overlay_body() -> Style {
Style::reset().fg(Color::White).bg(Color::Black)
}
/// Title/emphasis text: yellow on black, all inherited modifiers cleared.
pub fn overlay_title() -> Style {
Style::reset().fg(Color::Yellow).bg(Color::Black)
}
/// Paint a debug panel hugging `area`'s right edge, `top_offset` rows down:
/// the first line in the title style, the rest in the body style, every
/// line truncated/padded to `width` (clamped to the area) so the explicit
/// debug chrome covers the whole rect.
pub fn render_panel(area: Rect, buf: &mut Buffer, top_offset: u16, width: u16, lines: &[&str]) {
let w = width.min(area.width);
if w == 0 {
return;
}
let x = area.x + area.width - w;
let y0 = area.y.saturating_add(top_offset);
let bottom = area.y + area.height;
if y0 >= bottom {
return;
}
let h = (lines.len() as u16).min(bottom - y0);
// Pre-fill the panel rect so the explicit debug bg owns every cell.
buf.set_style(
Rect {
x,
y: y0,
width: w,
height: h,
},
overlay_body(),
);
for (i, line) in lines.iter().take(h as usize).enumerate() {
// Pad to the panel width so the background forms a solid block.
let mut text: String = line.chars().take(w as usize).collect();
for _ in text.chars().count()..w as usize {
text.push(' ');
}
let style = if i == 0 {
overlay_title()
} else {
overlay_body()
};
buf.set_string(x, y0 + i as u16, &text, style);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Modifier;
/// The styles must carry explicit ANSI-16 colors and subtract every
/// modifier — `Style::reset()` is the mechanism that clears themed
/// bold/italic/dim from covered cells.
#[test]
fn styles_are_explicit_and_modifier_clearing() {
for (style, fg) in [
(overlay_body(), Color::White),
(overlay_title(), Color::Yellow),
] {
assert_eq!(style.fg, Some(fg));
assert_eq!(style.bg, Some(Color::Black));
assert_eq!(style.add_modifier, Modifier::empty());
assert_eq!(style.sub_modifier, Modifier::all());
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,367 @@
//! @-context detection: parses `@query` tokens from prompt text + cursor position.
//!
//! Given the prompt text and cursor position, determines whether the cursor is
//! inside an `@`-token and extracts the query string for fuzzy matching.
//!
//! ## Rules
//!
//! - The `@` must NOT be preceded by an alphanumeric character or underscore
//! (avoids triggering on email addresses like `user@example.com`).
//! - The token extends from `@` to the first whitespace, comma, or semicolon.
//! - The cursor must be within the token range.
//! - The query is the text between `@` (exclusive) and the cursor.
//!
//! ## Special modes
//!
//! - **Dir mode**: query ends with `/` → restrict matches to directories only.
//! - **Hidden mode**: query starts with `!` → show hidden/gitignored files.
use std::ops::Range;
/// Context for the current @-completion token.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AtContext {
/// Byte range in the input text (includes the `@` as the first character).
pub range: Range<usize>,
/// Cursor byte position within the input text.
pub cursor: usize,
/// Query string: text after `@` (and after `!` if hidden mode) up to cursor.
pub query: String,
}
impl AtContext {
/// Whether the query requests directory-only results (ends with `/`).
pub fn is_dir_mode(&self) -> bool {
self.query.ends_with('/')
}
/// Whether the query requests hidden/gitignored files (starts with `!`).
pub fn is_hidden_mode(&self) -> bool {
self.query.starts_with('!')
}
/// The effective query for the fuzzy matcher (strips leading `!`).
pub fn matcher_query(&self) -> &str {
self.query.strip_prefix('!').unwrap_or(&self.query)
}
/// Byte range covering only the path portion of the @-token: starts
/// after the leading `@` and (in hidden mode) the `!` prefix, ends at
/// the @-token end. This is the range that should be replaced when
/// inserting a path while preserving the `@` and any hidden-mode
/// marker (see `accept_file_search_result_no_space` and
/// `FileSearchState::try_replace`).
pub fn path_range(&self) -> Range<usize> {
let prefix = 1 + if self.is_hidden_mode() { 1 } else { 0 };
self.range.start + prefix..self.range.end
}
}
/// Detect an @-completion context from prompt text and cursor position.
///
/// Returns `None` if the cursor is not inside an @-token, or if the `@` is
/// preceded by an alphanumeric/underscore character (e.g., `email@`).
pub fn detect(text: &str, cursor: usize) -> Option<AtContext> {
detect_with_drill(text, cursor, None)
}
/// Like [`detect`], but treats whitespace *inside* `drill_prefix` (the path of
/// the directory being drilled into) as part of the @-token, so `@my dir/` stays
/// one token. Self-validating: inert once the path content stops matching it.
pub fn detect_with_drill(
text: &str,
cursor: usize,
drill_prefix: Option<&str>,
) -> Option<AtContext> {
// Cursor must be within text bounds and on a char boundary.
if cursor > text.len() || !text.is_char_boundary(cursor) {
return None;
}
// Find the rightmost `@` before the cursor.
let at_idx = text[..cursor].rfind('@')?;
// Reject if `@` is preceded by alphanumeric or underscore (email-like).
if let Some(ch) = text[..at_idx].chars().next_back()
&& (ch.is_alphanumeric() || ch == '_')
{
return None;
}
// Path content starts after `@` (+ optional `!` hidden-mode marker).
let content_start = at_idx + 1;
let after_bang = if text[content_start..].starts_with('!') {
content_start + 1
} else {
content_start
};
// Whitespace inside the drilled prefix is path content, not a terminator.
let internal_until = drill_prefix.and_then(|prefix| {
text.get(after_bang..)
.filter(|rest| rest.starts_with(prefix))
.map(|_| after_bang + prefix.len())
});
// Find the end of the @-token: first whitespace, comma, or semicolon after `@`.
let token_end = text[at_idx + 1..]
.char_indices()
.find_map(|(offset, ch)| {
let abs = at_idx + 1 + offset;
if (ch.is_whitespace() || matches!(ch, ',' | ';'))
&& internal_until.is_none_or(|until| abs >= until)
{
Some(abs)
} else {
None
}
})
.unwrap_or(text.len());
// Cursor must be within the @-token.
if cursor > token_end {
return None;
}
Some(AtContext {
range: at_idx..token_end,
cursor,
query: text[at_idx + 1..cursor].to_owned(),
})
}
/// Normalize a display path (strip leading `./`).
pub fn normalize_display_path(path: &str) -> &str {
path.strip_prefix("./").unwrap_or(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_at_token() {
let ctx = detect("@foo", 4).unwrap();
assert_eq!(ctx.range, 0..4);
assert_eq!(ctx.query, "foo");
assert!(!ctx.is_dir_mode());
assert!(!ctx.is_hidden_mode());
}
#[test]
fn at_with_prefix_text() {
let ctx = detect("hello @bar/baz", 14).unwrap();
assert_eq!(ctx.range, 6..14);
assert_eq!(ctx.query, "bar/baz");
}
#[test]
fn cursor_mid_token() {
let ctx = detect("@foo/bar", 5).unwrap();
assert_eq!(ctx.range, 0..8);
assert_eq!(ctx.query, "foo/");
assert!(ctx.is_dir_mode());
}
#[test]
fn cursor_at_sign_only() {
let ctx = detect("@", 1).unwrap();
assert_eq!(ctx.range, 0..1);
assert_eq!(ctx.query, "");
}
#[test]
fn rejected_email_like() {
// @ preceded by alphanumeric — should not trigger.
assert!(detect("user@example", 12).is_none());
assert!(detect("test_@foo", 9).is_none());
}
#[test]
fn cursor_past_token() {
// Cursor is after the space following the token — no match.
assert!(detect("@foo bar", 5).is_none());
assert!(detect("@foo bar", 8).is_none());
}
#[test]
fn hidden_mode() {
let ctx = detect("@!foo", 5).unwrap();
assert!(ctx.is_hidden_mode());
assert_eq!(ctx.matcher_query(), "foo");
}
#[test]
fn dir_mode() {
let ctx = detect("@src/", 5).unwrap();
assert!(ctx.is_dir_mode());
assert_eq!(ctx.query, "src/");
assert_eq!(ctx.matcher_query(), "src/");
}
#[test]
fn hidden_dir_mode() {
let ctx = detect("@!.config/", 10).unwrap();
assert!(ctx.is_hidden_mode());
assert!(ctx.is_dir_mode());
assert_eq!(ctx.matcher_query(), ".config/");
}
#[test]
fn multiple_at_picks_rightmost() {
let ctx = detect("@first @second", 14).unwrap();
assert_eq!(ctx.query, "second");
assert_eq!(ctx.range, 7..14);
}
#[test]
fn at_after_special_chars() {
// @ preceded by space, parens, etc. — should trigger.
assert!(detect("(@foo", 5).is_some());
assert!(detect(" @foo", 5).is_some());
assert!(detect(",@foo", 5).is_some());
}
#[test]
fn empty_text() {
assert!(detect("", 0).is_none());
}
#[test]
fn cursor_at_zero() {
assert!(detect("@foo", 0).is_none());
}
#[test]
fn normalize_path() {
assert_eq!(normalize_display_path("./foo/bar"), "foo/bar");
assert_eq!(normalize_display_path("foo/bar"), "foo/bar");
assert_eq!(normalize_display_path("./"), "");
}
#[test]
fn token_delimited_by_comma() {
let ctx = detect("@foo,@bar", 4).unwrap();
assert_eq!(ctx.range, 0..4);
assert_eq!(ctx.query, "foo");
}
#[test]
fn token_delimited_by_semicolon() {
let ctx = detect("@foo;rest", 4).unwrap();
assert_eq!(ctx.range, 0..4);
assert_eq!(ctx.query, "foo");
}
#[test]
fn path_range_skips_at_only() {
// Plain @-token: path_range starts after `@`, ends at token end.
let ctx = detect("@src/foo", 8).unwrap();
assert_eq!(ctx.range, 0..8);
assert_eq!(ctx.path_range(), 1..8);
}
#[test]
fn path_range_skips_at_and_bang_in_hidden_mode() {
// Hidden mode: path_range skips both `@` and `!`.
let ctx = detect("@!src/foo", 9).unwrap();
assert!(ctx.is_hidden_mode());
assert_eq!(ctx.range, 0..9);
assert_eq!(ctx.path_range(), 2..9);
}
#[test]
fn path_range_with_prefix_text_offset() {
// @-token preceded by other text: path_range respects the
// absolute offset of the @ in the input.
let ctx = detect("hello @bar", 10).unwrap();
assert_eq!(ctx.range, 6..10);
assert_eq!(ctx.path_range(), 7..10);
}
// ── Drill-aware detection (whitespace inside a drilled dir name) ─────
#[test]
fn drill_prefix_allows_internal_space() {
let ctx = detect_with_drill("@my dir", 7, Some("my dir")).unwrap();
assert_eq!(ctx.range, 0..7);
assert_eq!(ctx.query, "my dir");
assert!(!ctx.is_dir_mode());
}
#[test]
fn drill_prefix_enters_dir_mode_with_trailing_slash() {
let ctx = detect_with_drill("@my dir/", 8, Some("my dir")).unwrap();
assert_eq!(ctx.query, "my dir/");
assert!(ctx.is_dir_mode());
}
#[test]
fn drill_prefix_allows_internal_tab() {
let ctx = detect_with_drill("@my\tdir", 7, Some("my\tdir")).unwrap();
assert_eq!(ctx.range, 0..7);
assert_eq!(ctx.query, "my\tdir");
}
#[test]
fn drill_prefix_with_hidden_mode() {
let ctx = detect_with_drill("@!my dir", 8, Some("my dir")).unwrap();
assert!(ctx.is_hidden_mode());
assert_eq!(ctx.matcher_query(), "my dir");
}
#[test]
fn drill_prefix_mismatch_falls_back_to_whitespace_terminator() {
// Prefix mismatch → space terminates as usual (sentence typing preserved).
assert!(detect_with_drill("@foo bar", 8, Some("my dir")).is_none());
}
#[test]
fn drill_prefix_whitespace_after_prefix_terminates() {
// Whitespace beyond the drilled prefix still ends the token.
assert!(detect_with_drill("@my dir extra", 13, Some("my dir")).is_none());
}
#[test]
fn no_drill_prefix_space_still_terminates() {
// Without a prefix, behavior is identical to plain `detect`.
assert!(detect("@my dir", 7).is_none());
assert!(detect_with_drill("@my dir", 7, None).is_none());
}
#[test]
fn drill_prefix_cursor_mid_token() {
// Cursor inside the drilled name still resolves the full token range.
let ctx = detect_with_drill("@my dir/sub", 5, Some("my dir")).unwrap();
assert_eq!(ctx.range, 0..11);
assert_eq!(ctx.query, "my d");
}
#[test]
fn drill_prefix_inert_when_backspaced_out_of_prefix() {
// Self-validation: `@my di` no longer starts with `my dir`, so the
// anchor goes inert and the space re-terminates.
assert!(detect_with_drill("@my di", 6, Some("my dir")).is_none());
}
#[test]
fn drill_prefix_allows_multibyte_dir_name() {
// `é` is two bytes; guards the `after_bang + prefix.len()` byte math.
let ctx = detect_with_drill("@café dir", 10, Some("café dir")).unwrap();
assert_eq!(ctx.range, 0..10);
assert_eq!(ctx.query, "café dir");
}
#[test]
fn drill_prefix_empty_collapses_to_no_prefix() {
// Empty prefix anchors nothing → terminates as if no prefix were set.
assert!(detect_with_drill("@my dir", 7, Some("")).is_none());
}
#[test]
fn drill_prefix_allows_second_level_space_segment() {
// Both spaces fall inside the drilled prefix → one token.
let ctx = detect_with_drill("@a b/c d", 8, Some("a b/c d")).unwrap();
assert_eq!(ctx.range, 0..8);
assert_eq!(ctx.query, "a b/c d");
}
}
@@ -0,0 +1,237 @@
//! Dropdown list renderer for @-completion results.
//!
//! Renders fuzzy match results as a scrollable list with:
//! - Selection highlight (background color on selected row)
//! - Fuzzy match character highlighting (accent color on matched chars)
//! - Scrollbar when results exceed visible height
//! - Truncation with `…` for long paths
//! - Result count hint (e.g., "12/345") in the separator line
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use kigi_workspace::file_system::FuzzyMatchResult;
use crate::render::scrollbar::render_scrollbar_styled;
use crate::theme::Theme;
use super::context::normalize_display_path;
use super::state::FileSearchState;
/// Maximum number of visible rows in the dropdown (excluding separator).
pub const MAX_DROPDOWN_ROWS: u16 = 8;
/// Render the file search dropdown items into the given area.
///
/// This renders ONLY the result rows (no borders or separators).
/// Panel chrome (clear, borders, count hint) is handled by the caller
/// (AgentView). The `area` covers just the item rows.
pub fn render_dropdown(buf: &mut Buffer, area: Rect, file_search: &FileSearchState, theme: &Theme) {
if area.height == 0 || area.width < 4 || !file_search.is_visible() {
return;
}
let results = file_search.results();
let topk = &results.topk;
let selected = file_search.selected();
let scroll = file_search.scroll_offset();
let dir_mode = file_search.is_dir_mode();
// Reserve 2 columns on the right for scrollbar (gap + track).
let needs_scrollbar = topk.len() > area.height as usize;
let content_width = if needs_scrollbar {
area.width.saturating_sub(2)
} else {
area.width
};
let visible_rows = area.height as usize;
let hovered = file_search.hovered();
let hover_bg = theme.bg_hover;
for row in 0..visible_rows {
let idx = scroll + row;
if idx >= topk.len() {
break;
}
let item = &topk[idx];
let y = area.y + row as u16;
let is_selected = idx == selected;
let is_hovered = hovered == Some(idx) && !is_selected;
render_fuzzy_item(
buf,
area.x,
y,
content_width,
item,
is_selected,
is_hovered,
hover_bg,
dir_mode,
theme,
);
}
// ── Scrollbar ───────────────────────────────────────────────────────
if needs_scrollbar {
let scrollbar_area = Rect {
x: area.x + area.width - 1,
y: area.y,
width: 1,
height: area.height,
};
let track_style = Style::default().bg(theme.bg_dark);
let thumb_style = Style::default().fg(theme.gray_dim).bg(theme.bg_dark);
render_scrollbar_styled(
buf,
Some(scrollbar_area),
topk.len() as u16,
area.height,
scroll as u16,
track_style,
thumb_style,
);
}
}
/// Desired height for the dropdown (separator + min(results, max_rows)).
pub fn dropdown_height(file_search: &FileSearchState, max_rows: u16) -> u16 {
if !file_search.is_visible() {
return 0;
}
let result_rows = (file_search.result_count() as u16).min(max_rows);
1 + result_rows // separator + results
}
/// Non-selected prefix — same width as the arrow, just spaces.
const ITEM_PREFIX: &str = " ";
const PREFIX_WIDTH: u16 = crate::glyphs::PROMPT_ARROW_WIDTH;
/// Render a single fuzzy match item with character-level match highlighting.
#[allow(clippy::too_many_arguments)]
fn render_fuzzy_item(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
item: &FuzzyMatchResult,
is_selected: bool,
is_hovered: bool,
hover_bg: ratatui::style::Color,
dir_mode: bool,
theme: &Theme,
) {
if width < PREFIX_WIDTH + 1 {
return;
}
let path_str = item.path.to_string();
let path = normalize_display_path(&path_str);
let embed = crate::views::modal_window::embedded_row_style(theme, is_selected);
let row_bg = match embed {
Some(e) => e.bg,
None if is_selected => theme.bg_visual,
None if is_hovered => hover_bg,
None => theme.bg_light,
};
let text_fg = embed.map_or(theme.text_primary, |e| e.fg(theme.text_primary));
let bold = if is_selected {
Modifier::BOLD
} else {
Modifier::empty()
};
// Fill the row with background.
for col in x..x + width {
if let Some(cell) = buf.cell_mut((col, y)) {
cell.set_char(' ');
cell.set_style(Style::default().bg(row_bg));
}
}
// Arrow on the selected row, blank gutter on the rest.
let prefix = if is_selected {
crate::glyphs::prompt_arrow()
} else {
ITEM_PREFIX
};
let prefix_style = Style::default().fg(text_fg).bg(row_bg).add_modifier(bold);
for (i, ch) in prefix.chars().enumerate() {
let px = x + i as u16;
if px < x + width
&& let Some(cell) = buf.cell_mut((px, y))
{
cell.set_char(ch);
cell.set_style(if is_selected {
prefix_style
} else {
Style::default().bg(row_bg)
});
}
}
// Styles: primary FG for text (not dimmed), BLUE for match chars.
// Selected rows get bold via the modifier.
let match_style = Style::default()
.fg(embed.map_or(theme.fuzzy_accent, |e| e.fg(theme.fuzzy_accent)))
.bg(row_bg)
.add_modifier(bold);
let normal_style = Style::default().fg(text_fg).bg(row_bg).add_modifier(bold);
// Render path characters after prefix, with match highlighting.
let mut indices = &item.indices[..];
let mut col = x + PREFIX_WIDTH;
let max_col = x + width;
for (char_idx, (byte_idx, ch)) in path.char_indices().enumerate() {
let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if col + ch_width > max_col {
// Truncation: replace last visible char with '…'
if col > x + PREFIX_WIDTH
&& let Some(cell) = buf.cell_mut((col.saturating_sub(1), y))
{
cell.set_char('…');
}
break;
}
let is_match = indices.first() == Some(&(char_idx as u32));
if is_match {
indices = &indices[1..];
}
let style = if is_match { match_style } else { normal_style };
// Write the character.
let ch_str = &path[byte_idx..byte_idx + ch.len_utf8()];
if let Some(cell) = buf.cell_mut((col, y)) {
cell.set_symbol(ch_str);
cell.set_style(style);
}
// For wide chars, fill continuation cell.
if ch_width > 1 {
for w in 1..ch_width {
if let Some(cell) = buf.cell_mut((col + w, y)) {
cell.set_char(' ');
cell.set_style(style);
}
}
}
col += ch_width;
}
// In dir mode, append '/' after the path.
if dir_mode
&& col < max_col
&& let Some(cell) = buf.cell_mut((col, y))
{
cell.set_char('/');
cell.set_style(normal_style);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
//! @-provider: fuzzy file completion for `@foo/bar` references.
//!
//! # Architecture
//!
//! - [`context`] — parses `@query` tokens from text + cursor position
//! - [`state`] — owns the fuzzy matcher daemon, results, and dropdown state
//! - [`dropdown`] — dropdown list rendering (ListPane wrapper, Phase 1)
//! - [`line_viewer`] — centered popup file viewer (Phase 3, not yet implemented)
//! - [`preview`] — file preview alongside dropdown (Phase 4, not yet implemented)
pub mod context;
pub mod dropdown;
pub mod line_viewer;
mod state;
pub use context::AtContext;
pub use state::{FileSearchReplacement, FileSearchState};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use crate::theme::Theme;
/// Build a styled `@path` or `@path:N-M` display line.
///
/// Style: `@` and `:` in `theme.gray`, path in `theme.path`, numbers in `theme.gray_bright`.
/// Set `at_prefix` to include the leading `@` (prompt chip) or omit it (viewer title).
/// Used by both the prompt element chip and the line viewer title bar.
pub fn styled_file_ref<'a>(
path: &str,
line_range: Option<&str>,
theme: &Theme,
at_prefix: bool,
) -> Line<'a> {
let dim = Style::default().fg(theme.gray);
let path_style = Style::default().fg(theme.path);
let num_style = Style::default().fg(theme.gray_bright);
let mut spans = Vec::new();
if at_prefix {
spans.push(Span::styled("@", dim));
}
spans.push(Span::styled(path.to_owned(), path_style));
if let Some(range) = line_range {
spans.push(Span::styled(":", dim));
spans.push(Span::styled(range.to_owned(), num_style));
}
Line::from(spans)
}
@@ -0,0 +1,386 @@
//! File search state: owns the fuzzy matcher daemon, results, and dropdown state.
//!
//! This is the core engine for @-completion. It manages:
//! - A background [`FuzzyFileMatcherDaemon`] that walks the directory tree
//! - The current [`AtContext`] (parsed from prompt text + cursor)
//! - Cached fuzzy match results (polled on tick)
//! - Dropdown selection state (selected index, scroll offset)
//! - Text replacement logic when a result is accepted
use std::path::{Path, PathBuf};
use std::sync::Arc;
use kigi_workspace::file_system::{
FuzzyFileMatcher, FuzzyFileMatcherDaemon, FuzzyMatchResult, FuzzyMatcherDaemonResults,
};
use super::context::{self, AtContext, normalize_display_path};
/// Top-K results to request from the fuzzy matcher.
const MATCHER_TOP_K: usize = 1000;
/// Replacement to apply to the prompt text after accepting a fuzzy result.
#[derive(Debug, Clone)]
pub struct FileSearchReplacement {
/// Byte range in the prompt text to replace (excludes the `@`).
pub range: std::ops::Range<usize>,
/// Replacement text (the normalized path, possibly with trailing space or `/`).
pub text: String,
/// Where to place the cursor after replacement.
pub cursor: usize,
/// Whether the @-context should be cleared (file accepted, not dir drill-down).
pub dismiss: bool,
}
/// File search state for @-completion.
pub struct FileSearchState {
/// Directory the matcher walks. Mirrors the daemon's root (which is
/// otherwise moved into its worker thread) so callers can introspect
/// where `@`-completion is currently pointed.
root: PathBuf,
/// Background fuzzy matcher daemon.
daemon: FuzzyFileMatcherDaemon,
/// Latest results snapshot from the daemon.
results: FuzzyMatcherDaemonResults,
/// Current @-context (if cursor is inside an @-token).
context: Option<AtContext>,
/// Selected index in the dropdown list (keyboard-driven).
selected: usize,
/// Hovered index in the dropdown list (mouse-driven).
/// `None` when the mouse is not over any item.
hovered: Option<usize>,
/// Scroll offset for the dropdown list.
scroll_offset: usize,
/// Generation counter to prevent stale results from flickering in.
min_generation: usize,
/// Directory being drilled into; keeps the @-token alive when its name has
/// whitespace (`my dir`). Self-validating — applies only while the path matches.
drill_prefix: Option<String>,
}
impl FileSearchState {
/// Create a new file search state rooted at the given path.
pub fn new(root: &Path) -> Self {
Self {
root: root.to_owned(),
daemon: FuzzyFileMatcherDaemon::new(FuzzyFileMatcher::new(root), MATCHER_TOP_K),
results: FuzzyMatcherDaemonResults::default(),
context: None,
selected: 0,
hovered: None,
scroll_offset: 0,
min_generation: 0,
drill_prefix: None,
}
}
/// Replace the underlying matcher with a new one rooted at `root`.
///
/// Used after worktree creation to point @-completion at the new tree.
pub fn retarget(&mut self, root: &Path) {
self.root = root.to_owned();
self.daemon = FuzzyFileMatcherDaemon::new(FuzzyFileMatcher::new(root), MATCHER_TOP_K);
self.results = FuzzyMatcherDaemonResults::default();
self.context = None;
self.selected = 0;
self.hovered = None;
self.scroll_offset = 0;
self.min_generation = 0;
self.drill_prefix = None;
}
/// The directory the matcher currently walks (the `@`-completion root).
pub fn root(&self) -> &Path {
&self.root
}
// ── Visibility ──────────────────────────────────────────────────────
/// Whether the dropdown should be visible.
pub fn is_visible(&self) -> bool {
self.context.is_some() && !self.results.topk.is_empty()
}
/// The current @-context, if any.
pub fn context(&self) -> Option<&AtContext> {
self.context.as_ref()
}
/// The current results snapshot.
pub fn results(&self) -> &FuzzyMatcherDaemonResults {
&self.results
}
/// Currently selected index in the results.
pub fn selected(&self) -> usize {
self.selected
}
/// Scroll offset for the dropdown.
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
/// Currently hovered index (mouse-driven), if any.
pub fn hovered(&self) -> Option<usize> {
self.hovered
}
/// Set the hovered index. Returns `true` if changed.
pub fn set_hovered(&mut self, index: Option<usize>) -> bool {
let clamped = index.filter(|&i| i < self.results.topk.len());
let changed = clamped != self.hovered;
self.hovered = clamped;
changed
}
/// Whether the current query is in directory-only mode.
pub fn is_dir_mode(&self) -> bool {
self.context.as_ref().is_some_and(|c| c.is_dir_mode())
}
// ── Context updates ─────────────────────────────────────────────────
/// Anchor (or clear) the drilled directory for whitespace-aware detection.
pub fn set_drill_prefix(&mut self, prefix: Option<String>) {
self.drill_prefix = prefix;
}
/// Recompute the @-context from the current prompt text and cursor position.
///
/// Called after every text change or cursor movement.
pub fn update_context(&mut self, text: &str, cursor: usize) {
let new_ctx = context::detect_with_drill(text, cursor, self.drill_prefix.as_deref());
match (&self.context, &new_ctx) {
(None, Some(ctx)) => {
// Fresh `@` token is never a drill — drop any stale anchor.
self.drill_prefix = None;
// Entering @-mode: restart the directory walk.
self.daemon.restart_walk(ctx.is_hidden_mode());
// A trailing `/` scopes the query to a folder; it must not hide
// that folder's files, so never filter to directories only.
self.daemon.set_query(ctx.matcher_query(), false);
self.min_generation += 1;
self.selected = 0;
self.hovered = None;
self.scroll_offset = 0;
}
(Some(old), Some(new)) => {
// Drop a stale anchor once the @-token's path content no longer
// starts with it (e.g. undo/paste reverted the drill), so it
// can't silently re-match on a later edit.
let anchor_stale = self.drill_prefix.as_deref().is_some_and(|prefix| {
!text
.get(new.path_range().start..)
.is_some_and(|rest| rest.starts_with(prefix))
});
if anchor_stale {
self.drill_prefix = None;
}
// Staying in @-mode: check if hidden mode toggled (needs re-walk).
if old.is_hidden_mode() != new.is_hidden_mode() {
self.daemon.restart_walk(new.is_hidden_mode());
}
self.daemon.set_query(new.matcher_query(), false);
self.min_generation += 1;
// Reset selection when query changes to avoid showing stale
// matches from an obscure position in the list.
self.selected = 0;
self.hovered = None;
self.scroll_offset = 0;
}
(Some(_), None) => {
// Leaving @-mode: clear results and the drill anchor.
self.context = None;
self.drill_prefix = None;
self.results = FuzzyMatcherDaemonResults::default();
return;
}
(None, None) => return,
}
self.context = new_ctx;
}
/// Clear the context (e.g., on Esc).
pub fn clear_context(&mut self) {
self.context = None;
self.drill_prefix = None;
self.results = FuzzyMatcherDaemonResults::default();
}
// ── Tick / polling ──────────────────────────────────────────────────
/// Poll the daemon for new results. Returns `true` if results changed.
///
/// Should be called on every tick (~4ms) while the dropdown is potentially visible.
pub fn poll(&mut self) -> bool {
if self.context.is_none() {
return false;
}
let results = self.daemon.get();
// Check if results actually changed (pointer comparison on Arc).
if Arc::ptr_eq(&results.topk, &self.results.topk) {
return false;
}
// Avoid flickering: skip empty intermediate results unless matching is done.
if !results.topk.is_empty() || results.status.done {
// Skip stale generations (e.g., from a previous @-context).
if results.generation >= self.min_generation {
self.min_generation = results.generation;
self.results = results;
// Clamp selection to new result count.
if !self.results.topk.is_empty() {
self.selected = self.selected.min(self.results.topk.len() - 1);
}
return true;
}
}
false
}
// ── Navigation ──────────────────────────────────────────────────────
/// Move selection by `delta` items (negative = up, positive = down).
pub fn move_selection(&mut self, delta: isize) {
let len = self.results.topk.len();
if len == 0 {
return;
}
let max_idx = len - 1;
let current = self.selected.min(max_idx);
self.selected = (current as isize + delta).clamp(0, max_idx as isize) as usize;
}
/// Move selection by a page (half of visible height).
pub fn page_move(&mut self, delta: isize, visible_rows: usize) {
let half = (visible_rows / 2).max(1) as isize;
self.move_selection(delta * half);
}
/// Ensure the selected item is visible in the dropdown viewport.
pub fn ensure_visible(&mut self, visible_rows: usize) {
if visible_rows == 0 {
return;
}
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
} else if self.selected >= self.scroll_offset + visible_rows {
self.scroll_offset = self.selected + 1 - visible_rows;
}
}
// ── Selection / replacement ─────────────────────────────────────────
/// Select the hovered item (for click-to-accept).
/// Returns `true` if there was a valid hovered item to select.
pub fn select_hovered(&mut self) -> bool {
if let Some(idx) = self.hovered
&& idx < self.results.topk.len()
{
self.selected = idx;
return true;
}
false
}
/// Get the currently selected fuzzy match result.
pub fn selected_result(&self) -> Option<&FuzzyMatchResult> {
self.results.topk.get(self.selected)
}
/// Compute the text replacement for accepting the currently selected result.
///
/// The `src` parameter is the full prompt text (needed to detect edge cases
/// like "replacement is a no-op" for directory drill-down).
pub fn try_replace(&mut self, src: &str) -> Option<FileSearchReplacement> {
let ctx = self.context.as_ref()?;
let res = self.results.topk.get(self.selected)?;
let path_str = res.path.to_string();
let mut text = normalize_display_path(&path_str).to_owned();
// Replace only the path portion of the @-token (preserving `@`
// and any hidden-mode `!` marker). See `AtContext::path_range`.
let range = ctx.path_range();
let mut cursor = range.start + text.len() + 1;
let dismiss;
if ctx.is_dir_mode() {
// Directory mode: append `/` and stay in completion for drill-down.
text = format!("{text}/");
if range.end <= src.len() && src[range.clone()] == text[..] {
// No-op replacement (same text already there) — treat as "done".
cursor += 1;
if range.end == src.len() {
text = format!("{text} ");
}
dismiss = true;
} else {
dismiss = false; // Stay in completion mode (drill-down).
}
} else {
// File mode: append trailing space if at end of input.
if range.end == src.len() {
text = format!("{text} ");
}
dismiss = true;
}
if dismiss {
self.context = None;
self.drill_prefix = None;
}
Some(FileSearchReplacement {
range,
text,
cursor,
dismiss,
})
}
/// Number of result items.
pub fn result_count(&self) -> usize {
self.results.topk.len()
}
/// Total items the matcher knows about (for "k/n" display).
pub fn total_items(&self) -> usize {
self.results.num_items
}
/// Test-only: install a fake context + results snapshot so tests can drive
/// acceptance flows without spinning up the background fuzzy daemon.
///
/// **Mixing with daemon polling is unsupported.** This helper assigns
/// `generation = self.min_generation` without bumping `min_generation`,
/// which means a real daemon poll occurring after `set_test_state` could
/// deliver same-generation results that overwrite the seeded fake state
/// non-deterministically. Tests that use this helper must not also drive
/// real daemon polls; if a future test needs both, bump
/// `self.min_generation` here so any in-flight daemon results are
/// rejected.
#[cfg(test)]
pub(crate) fn set_test_state(
&mut self,
context: AtContext,
results: Vec<FuzzyMatchResult>,
selected: usize,
) {
self.context = Some(context);
self.results = FuzzyMatcherDaemonResults {
topk: Arc::from(results),
num_items: 0,
status: Default::default(),
generation: self.min_generation,
};
self.selected = selected;
}
}
@@ -0,0 +1,265 @@
//! Release-safe FPS readout — `/debug fps`, `KIGI_FPS` on release builds.
//!
//! The full frame profiler (`render::frame_metrics`, `KIGI_FPS`) is compiled
//! only in debug/dev builds because it threads per-phase timings
//! through `draw_frame`. This HUD measures the one thing that needs no
//! pipeline change — the wall-clock duration of the whole `draw_frame` call
//! (render + flush + writer handoff) — so it compiles into release builds
//! behind a runtime toggle (the scroll-debug HUD precedent) and profiles the
//! production render path with zero fidelity gap.
//!
//! `KIGI_FPS` ownership: in debug/dev builds the env feeds `FrameMetrics` as
//! always and this HUD stays toggle-only (no double overlay); on release
//! binaries — where that overlay does not exist — the same env enables this
//! HUD from startup, so `KIGI_FPS=1` is never a silent no-op
//! ([`HONORS_KIGI_FPS_ENV`]).
//!
//! "fps" here is render throughput (1 / mean frame cost), not paint
//! frequency: the pager draws on demand, so an idle UI paints nothing and a
//! busy one is bounded by this number.
//!
//! Invariant (shared with the scroll HUD): pure observation. Disabled cost
//! is one bool check per frame; rendering only paints buffer cells.
use super::debug_style;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use std::collections::VecDeque;
use std::time::{Duration, Instant};
/// Frame-duration ring buffer capacity (~4s at 30fps).
const SAMPLE_CAP: usize = 120;
/// Overlay text refresh cadence; avoids re-sorting/formatting every frame.
const REFRESH: Duration = Duration::from_millis(250);
/// Panel width in cells; each line is padded/truncated to this.
const PANEL_WIDTH: u16 = 32;
/// Whether this HUD owns the `KIGI_FPS` env gate: only where the dev
/// `FrameMetrics` overlay is compiled out. In debug/dev builds the env keeps
/// feeding that overlay alone.
const HONORS_KIGI_FPS_ENV: bool = true;
/// Runtime state for the FPS HUD. `KIGI_FPS` enables it at startup on
/// release binaries ([`HONORS_KIGI_FPS_ENV`]); `/debug fps` toggles it
/// live everywhere. Deliberately NOT a settings-registry entry: it is a
/// diagnostic, not a preference to persist.
pub struct FpsHud {
enabled: bool,
samples: VecDeque<Duration>,
/// Cached stats line, rewritten at most every [`REFRESH`].
body: String,
last_refresh: Option<Instant>,
}
impl Default for FpsHud {
fn default() -> Self {
Self::new()
}
}
impl FpsHud {
pub fn new() -> Self {
Self::with_env(std::env::var("KIGI_FPS").ok())
}
/// `env` is the raw `KIGI_FPS` value; the truthiness rule (nonempty and
/// not `"0"`) matches `FrameMetrics` and `KIGI_SCROLL_DEBUG`.
fn with_env(env: Option<String>) -> Self {
let env_on = HONORS_KIGI_FPS_ENV && env.is_some_and(|v| !v.is_empty() && v != "0");
Self {
enabled: env_on,
samples: VecDeque::with_capacity(SAMPLE_CAP),
body: String::new(),
last_refresh: None,
}
}
/// Whether the HUD is currently enabled.
pub fn enabled(&self) -> bool {
self.enabled
}
/// `/debug fps` runtime toggle.
pub fn toggle(&mut self) {
self.enabled = !self.enabled;
self.samples.clear();
self.body.clear();
self.last_refresh = None;
}
/// Rows the overlay occupies when enabled (for stacking overlays).
pub fn overlay_height(&self) -> u16 {
if self.enabled { 2 } else { 0 }
}
/// Record one frame's `draw_frame` wall duration.
pub fn record(&mut self, frame: Duration) {
if !self.enabled {
return;
}
if self.samples.len() >= SAMPLE_CAP {
self.samples.pop_front();
}
self.samples.push_back(frame);
}
/// Owned per-frame render params (`None` unless enabled), assembled by
/// `AppView::draw` BEFORE the frame closure — the `ScrollDebugPanel`
/// pattern. `top_offset` leaves rows for overlays stacked above.
pub fn overlay(&mut self, top_offset: u16) -> Option<FpsOverlay> {
if !self.enabled {
return None;
}
if self.last_refresh.is_none_or(|at| at.elapsed() >= REFRESH) {
self.body = format_stats(&self.samples);
self.last_refresh = Some(Instant::now());
}
Some(FpsOverlay {
body: self.body.clone(),
top_offset,
})
}
}
/// Mean/percentile line from the ring buffer; placeholder before samples.
fn format_stats(samples: &VecDeque<Duration>) -> String {
if samples.is_empty() {
return "fps:- p50:- p95:-".to_string();
}
let mut ms: Vec<f64> = samples.iter().map(|d| d.as_secs_f64() * 1000.0).collect();
ms.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mean_ms = ms.iter().sum::<f64>() / ms.len() as f64;
let fps = if mean_ms > 1e-6 {
1000.0 / mean_ms
} else {
0.0
};
let p50 = percentile(&ms, 50.0);
let p95 = percentile(&ms, 95.0);
format!("fps:{fps:.0} p50:{p50:.1}ms p95:{p95:.1}ms")
}
/// Linear-interpolation percentile from a sorted slice.
fn percentile(sorted: &[f64], pct: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
let rank = (pct / 100.0) * (sorted.len() - 1) as f64;
let lower = rank.floor() as usize;
if lower + 1 >= sorted.len() {
return sorted[lower];
}
let frac = rank - lower as f64;
sorted[lower] + (sorted[lower + 1] - sorted[lower]) * frac
}
/// Owned render params for one frame (title + stats line, top-right).
pub struct FpsOverlay {
body: String,
/// Rows left free for overlays above (the dev `KIGI_FPS` line).
pub top_offset: u16,
}
impl FpsOverlay {
/// Paint the two-line panel in the top-right corner of `area`, in the
/// shared theme-agnostic debug chrome (every cell, padding included).
pub fn render(&self, area: Rect, buf: &mut Buffer) {
debug_style::render_panel(
area,
buf,
self.top_offset,
PANEL_WIDTH,
&["fps debug (/debug fps)", self.body.as_str()],
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::{Color, Modifier, Style};
#[test]
fn disabled_by_default_and_toggle_round_trips() {
let mut hud = FpsHud::with_env(None);
assert!(!hud.enabled());
assert_eq!(hud.overlay_height(), 0);
assert!(hud.overlay(0).is_none());
hud.toggle();
assert!(hud.enabled());
assert_eq!(hud.overlay_height(), 2);
assert!(hud.overlay(0).is_some());
hud.toggle();
assert!(!hud.enabled());
assert!(hud.overlay(0).is_none());
}
/// Default test builds compile without dev instrumentation — release-shaped
/// for this gate — so a truthy env must construct enabled. A
/// debug/dev test build hands the env to `FrameMetrics` instead;
/// asserting against [`HONORS_KIGI_FPS_ENV`] keeps the test true under
/// both cfgs (the dev half is pinned by the constant's shape, the same
/// limitation as the `/debug` visibility test).
#[test]
fn grok_fps_env_enables_hud_where_dev_overlay_absent() {
for truthy in ["1", "full", " "] {
assert_eq!(
FpsHud::with_env(Some(truthy.into())).enabled(),
HONORS_KIGI_FPS_ENV,
"KIGI_FPS={truthy:?} must track the env-gate owner"
);
}
for falsy in [None, Some(String::new()), Some("0".into())] {
assert!(!FpsHud::with_env(falsy).enabled());
}
}
#[test]
fn record_caps_ring_buffer_and_toggle_clears_stale_samples() {
let mut hud = FpsHud::with_env(None);
hud.toggle();
for _ in 0..SAMPLE_CAP + 30 {
hud.record(Duration::from_millis(10));
}
assert_eq!(hud.samples.len(), SAMPLE_CAP);
hud.toggle();
hud.toggle();
assert!(hud.samples.is_empty());
assert!(
hud.overlay(0).unwrap().body.contains("fps:-"),
"fresh enablement must show placeholders"
);
}
#[test]
fn stats_line_reports_mean_fps_and_percentiles() {
let mut hud = FpsHud::with_env(None);
hud.toggle();
for _ in 0..100 {
hud.record(Duration::from_millis(10));
}
let overlay = hud.overlay(0).expect("enabled");
assert_eq!(overlay.body, "fps:100 p50:10.0ms p95:10.0ms");
}
#[test]
fn record_is_a_noop_while_disabled() {
let mut hud = FpsHud::with_env(None);
hud.record(Duration::from_millis(10));
assert!(hud.samples.is_empty());
}
/// Every cell of the panel rect — trailing padding included — must carry
/// the explicit debug chrome, not the theme style underneath.
#[test]
fn render_paints_theme_agnostic_style_over_every_panel_cell() {
let area = Rect::new(0, 0, 60, 6);
let mut buf = Buffer::empty(area);
let theme = Style::default()
.fg(Color::Rgb(228, 228, 228))
.bg(Color::Rgb(3, 3, 4))
.add_modifier(Modifier::ITALIC);
buf.set_style(area, theme);
let overlay = FpsOverlay {
body: "fps:100 p50:10.0ms p95:10.0ms".to_string(),
top_offset: 1,
};
overlay.render(area, &mut buf);
let x0 = area.width - PANEL_WIDTH;
for y in 1..3u16 {
for x in x0..area.width {
let cell = &buf[(x, y)];
assert_eq!(cell.bg, Color::Black, "cell ({x},{y}) bg");
assert!(
cell.fg == Color::White || cell.fg == Color::Yellow,
"cell ({x},{y}) fg must be debug chrome, got {:?}",
cell.fg
);
assert_eq!(
cell.modifier,
Modifier::empty(),
"cell ({x},{y}) must shed themed modifiers"
);
}
}
assert_eq!(buf[(0, 1)].bg, Color::Rgb(3, 3, 4));
assert_eq!(buf[(area.width - 1, 0)].bg, Color::Rgb(3, 3, 4));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,713 @@
//! Prompt history search with background-thread nucleo matching.
//!
//! Architecture mirrors the file search `FuzzyFileMatcherDaemon`:
//! - A background `std::thread` owns the nucleo `Matcher` + `MultiPattern`.
//! - The UI thread sends queries via a channel (`set_query`) — never blocks.
//! - The background thread scores items, computes indices, writes results
//! to `Arc<Mutex<…>>`.
//! - The UI thread polls results on each tick via `poll()`.
use std::sync::{
Arc, Mutex,
mpsc::{SyncSender, sync_channel},
};
use std::thread::{self, JoinHandle};
use nucleo::{
Config, Matcher, Utf32String,
pattern::{CaseMatching, MultiPattern, Normalization},
};
// ---------------------------------------------------------------------------
// Public data types
// ---------------------------------------------------------------------------
/// A single entry in the prompt history.
#[derive(Debug, Clone)]
pub struct HistoryEntry {
pub text: String,
}
/// A matched result with highlight positions (produced by the daemon).
#[derive(Debug, Clone)]
pub struct HistoryMatchResult {
pub text: String,
pub indices: Vec<u32>,
}
// ---------------------------------------------------------------------------
// Shared state (daemon → UI)
// ---------------------------------------------------------------------------
#[derive(Clone, Default)]
struct Snapshot {
items: Arc<[HistoryMatchResult]>,
generation: usize,
}
// ---------------------------------------------------------------------------
// Daemon messages (UI → daemon)
// ---------------------------------------------------------------------------
enum Msg {
SetItems(Vec<String>),
SetItemsAndQuery(Vec<String>, String),
SetQuery(String),
Stop,
}
// ---------------------------------------------------------------------------
// Background daemon
// ---------------------------------------------------------------------------
struct Daemon {
shared: Arc<Mutex<Snapshot>>,
tx: SyncSender<Msg>,
_handle: JoinHandle<()>,
}
const MAX_RESULTS: usize = 100;
impl Daemon {
fn new() -> Self {
let shared = Arc::new(Mutex::new(Snapshot::default()));
let (tx, rx) = sync_channel::<Msg>(256);
let out = shared.clone();
let handle = thread::spawn(move || {
let mut pattern = MultiPattern::new(1);
let mut matcher = Matcher::new(Config::DEFAULT);
let mut items: Vec<(String, Utf32String)> = Vec::new();
let mut generation: usize = 0;
let mut prev_q = String::new();
while let Ok(msg) = rx.recv() {
// Drain to latest — skip intermediate queries.
let msg = drain_to_latest(msg, &rx);
match msg {
Msg::SetItems(new) => {
items = build_items(new);
prev_q.clear();
generation += 1;
publish_matches(&items, "", &mut pattern, &mut matcher, &out, generation);
}
Msg::SetItemsAndQuery(new, query) => {
items = build_items(new);
prev_q.clear();
generation += 1;
let trimmed = query.trim().to_string();
publish_matches(
&items,
&trimmed,
&mut pattern,
&mut matcher,
&out,
generation,
);
prev_q = trimmed;
}
Msg::SetQuery(query) => {
generation += 1;
let trimmed = query.trim().to_string();
if trimmed.is_empty() {
publish_matches(
&items,
"",
&mut pattern,
&mut matcher,
&out,
generation,
);
prev_q.clear();
} else {
let append = !prev_q.is_empty()
&& trimmed.as_bytes().starts_with(prev_q.as_bytes())
&& !trimmed.ends_with('\\')
&& !trimmed
.as_bytes()
.last()
.is_some_and(|b| b.is_ascii_whitespace());
publish_query_matches(
&items,
&trimmed,
append,
&mut pattern,
&mut matcher,
&out,
generation,
);
prev_q = trimmed;
}
}
Msg::Stop => break,
}
}
});
Self {
shared,
tx,
_handle: handle,
}
}
}
fn build_items(items: Vec<String>) -> Vec<(String, Utf32String)> {
items
.into_iter()
.filter(|s| !s.is_empty())
.map(|s| {
let u = Utf32String::from(s.as_str());
(s, u)
})
.collect()
}
fn publish_matches(
items: &[(String, Utf32String)],
query: &str,
pattern: &mut MultiPattern,
matcher: &mut Matcher,
out: &Arc<Mutex<Snapshot>>,
generation: usize,
) {
if query.is_empty() {
// Items arrive most-recent-first; reverse so the most recent prompt is
// last (rendered at the bottom of the overlay, nearest the prompt).
let mut all: Vec<HistoryMatchResult> = items
.iter()
.take(MAX_RESULTS)
.map(|(s, _)| HistoryMatchResult {
text: s.clone(),
indices: Vec::new(),
})
.collect();
all.reverse();
*out.lock().unwrap() = Snapshot {
items: all.into(),
generation,
};
} else {
publish_query_matches(items, query, false, pattern, matcher, out, generation);
}
}
fn publish_query_matches(
items: &[(String, Utf32String)],
query: &str,
append: bool,
pattern: &mut MultiPattern,
matcher: &mut Matcher,
out: &Arc<Mutex<Snapshot>>,
generation: usize,
) {
pattern.reparse(0, query, CaseMatching::Smart, Normalization::Smart, append);
let mut hits: Vec<(usize, u32)> = Vec::new();
for (i, (_, u)) in items.iter().enumerate() {
if let Some(sc) = pattern.score(std::slice::from_ref(u), matcher) {
hits.push((i, sc));
}
}
hits.sort_unstable_by_key(|hit| std::cmp::Reverse(hit.1));
if hits.len() > MAX_RESULTS {
hits.truncate(MAX_RESULTS);
}
let col = pattern.column_pattern(0);
let mut matched: Vec<HistoryMatchResult> = hits
.into_iter()
.map(|(i, _)| {
let (text, u) = &items[i];
let mut idx = Vec::new();
col.indices(u.slice(..), matcher, &mut idx);
HistoryMatchResult {
text: text.clone(),
indices: idx,
}
})
.collect();
// `hits` is sorted best-first; reverse so the best match is last (rendered
// at the bottom of the overlay, selected by default).
matched.reverse();
*out.lock().unwrap() = Snapshot {
items: matched.into(),
generation,
};
}
/// Drain the channel to the most recent message, coalescing queries.
fn drain_to_latest(first: Msg, rx: &std::sync::mpsc::Receiver<Msg>) -> Msg {
let mut current = first;
while let Ok(next) = rx.try_recv() {
current = match (current, next) {
// Coalesce consecutive SetQuery — keep latest.
(Msg::SetQuery(_), next @ Msg::SetQuery(_)) => next,
// Preserve the item refresh and latest query as one atomic update.
(Msg::SetItems(items), Msg::SetQuery(query)) => Msg::SetItemsAndQuery(items, query),
(Msg::SetItemsAndQuery(items, _), Msg::SetQuery(query)) => {
Msg::SetItemsAndQuery(items, query)
}
// Stop always wins.
(_, stop @ Msg::Stop) => return stop,
// SetItems after SetQuery — keep SetItems (reset).
(_, next) => next,
};
}
current
}
impl Drop for Daemon {
fn drop(&mut self) {
let _ = self.tx.send(Msg::Stop);
}
}
// ---------------------------------------------------------------------------
// HistorySearchState (UI-thread side)
// ---------------------------------------------------------------------------
/// UI-side state for the history search overlay.
///
/// The UI thread never runs nucleo. All matching happens on the daemon
/// thread. The UI sends queries via `update_query()` and polls results
/// via `poll()`, exactly like `FuzzyFileMatcherDaemon`.
pub struct HistorySearchState {
active: bool,
saved_text: String,
snapshot: Snapshot,
last_gen: usize,
pub selected: usize,
/// While `true`, selection tracks the bottom-most (most-recent / best-match)
/// entry as results stream in. Set on `activate`, cleared once the user
/// navigates (Up/Down/PageUp/PageDown/click). This makes the overlay open
/// with the most recent prompt selected at the bottom of the list.
stick_to_bottom: bool,
/// The last query sent to the daemon. Used to distinguish a genuine query
/// change (user typing → re-anchor selection to the best match) from a
/// re-application of the same query (e.g. a late background
/// `PromptHistoryLoaded` refresh → must not clobber the user's selection).
last_query: String,
/// Mouse-hovered result index (visual highlight only).
hovered: Option<usize>,
/// Browse mode (Up-arrow entry point): the selection lives in the
/// composer (live-populated on every move), typing detaches to edit,
/// and Down at the newest closes. Search mode (`/history`)
/// keeps the composer as the filter query instead.
browse: bool,
daemon: Daemon,
}
impl Default for HistorySearchState {
fn default() -> Self {
Self::new()
}
}
impl HistorySearchState {
pub fn new() -> Self {
Self {
active: false,
saved_text: String::new(),
snapshot: Snapshot::default(),
last_gen: 0,
selected: 0,
stick_to_bottom: true,
last_query: String::new(),
hovered: None,
browse: false,
daemon: Daemon::new(),
}
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn saved_text(&self) -> &str {
&self.saved_text
}
pub fn result_count(&self) -> usize {
self.snapshot.items.len()
}
pub fn refresh_items(&mut self, history: &[HistoryEntry]) {
let items: Vec<String> = history.iter().map(|e| e.text.clone()).collect();
let _ = self.daemon.tx.send(Msg::SetItems(items));
}
/// Activate in SEARCH mode (`/history`): send items to the
/// daemon, show overlay. The composer is the filter query; navigation
/// highlights only, Enter/Tab accepts.
pub fn activate(&mut self, history: &[HistoryEntry], current_text: &str) {
self.activate_inner(history, current_text, false);
}
/// Activate in BROWSE mode (Up on an empty prompt): same panel, but the
/// caller fills the newest entry straight into the composer and every
/// selection move live-populates it; typing detaches to edit, and Down
/// at the newest entry closes the panel.
pub fn activate_browse(&mut self, history: &[HistoryEntry], current_text: &str) {
self.activate_inner(history, current_text, true);
}
fn activate_inner(&mut self, history: &[HistoryEntry], current_text: &str, browse: bool) {
self.active = true;
self.browse = browse;
self.saved_text = current_text.to_string();
// Open with the most-recent prompt (rendered at the bottom) selected;
// `poll` keeps it pinned to the bottom until the user navigates.
self.stick_to_bottom = true;
self.last_query.clear();
self.refresh_items(history);
// Eagerly grab the initial snapshot.
self.snapshot = self.daemon.shared.lock().unwrap().clone();
self.last_gen = self.snapshot.generation;
self.selected = self.snapshot.items.len().saturating_sub(1);
}
/// True while the overlay is in browse mode (see [`Self::activate_browse`]).
pub fn is_browse(&self) -> bool {
self.active && self.browse
}
/// Deactivate: clear overlay (daemon thread stays alive for reuse).
pub fn deactivate(&mut self) {
self.active = false;
self.browse = false;
self.snapshot = Snapshot::default();
self.selected = 0;
}
/// Send a query update to the daemon (non-blocking, never stalls UI).
pub fn update_query(&mut self, query: &str) {
// A genuinely new query (the user typed) re-anchors selection to the
// best match at the bottom. Re-applying the *same* query (e.g. a late
// background `PromptHistoryLoaded` refresh that re-sends the current
// query) must not move a selection the user has already navigated to.
if query != self.last_query {
self.last_query = query.to_string();
self.stick_to_bottom = true;
}
let _ = self.daemon.tx.send(Msg::SetQuery(query.to_string()));
}
/// Poll for new results from the daemon. Returns `true` if changed.
/// Call on every tick while the overlay is active.
pub fn poll(&mut self) -> bool {
if !self.active {
return false;
}
let snap = self.daemon.shared.lock().unwrap().clone();
if snap.generation == self.last_gen {
return false;
}
self.last_gen = snap.generation;
self.snapshot = snap;
let len = self.snapshot.items.len();
if len == 0 {
self.selected = 0;
} else if self.stick_to_bottom {
// Keep the most-recent / best-match (bottom) entry selected as
// results stream in or the query narrows.
self.selected = len - 1;
} else {
self.selected = self.selected.min(len - 1);
}
true
}
/// Currently hovered index (mouse-driven), if any.
pub fn hovered(&self) -> Option<usize> {
self.hovered
}
/// Set hovered index. Returns `true` if changed.
pub fn set_hovered(&mut self, index: Option<usize>) -> bool {
let clamped = index.filter(|&i| i < self.snapshot.items.len());
let changed = clamped != self.hovered;
self.hovered = clamped;
changed
}
/// Select the hovered item (for click-to-accept). Returns `true` if valid.
pub fn select_hovered(&mut self) -> bool {
if let Some(idx) = self.hovered
&& idx < self.snapshot.items.len()
{
self.stick_to_bottom = false;
self.selected = idx;
true
} else {
false
}
}
/// Move the selection one row up (older). No wrap: at the top (oldest)
/// the selection stays put. Returns `true` when it moved.
pub fn move_up(&mut self) -> bool {
let len = self.snapshot.items.len();
if len == 0 || self.selected == 0 {
return false;
}
self.stick_to_bottom = false;
self.selected -= 1;
true
}
/// Move the selection one row down (newer). No wrap: returns `false` at
/// the bottom (newest) — the caller closes the overlay there, so a Down
/// right after opening (newest is selected) backs out of history.
pub fn move_down(&mut self) -> bool {
let len = self.snapshot.items.len();
if len == 0 || self.selected >= len - 1 {
return false;
}
self.stick_to_bottom = false;
self.selected += 1;
true
}
/// Move selection by a page (half of visible height).
pub fn page_move(&mut self, delta: isize, visible_rows: usize) {
let half = (visible_rows / 2).max(1) as isize;
let len = self.snapshot.items.len();
if len == 0 {
return;
}
self.stick_to_bottom = false;
let max_idx = len - 1;
let current = self.selected.min(max_idx);
self.selected = (current as isize + delta * half).clamp(0, max_idx as isize) as usize;
}
/// Selected entry (returns `None` — use `selected_text()` instead).
pub fn selected(&self) -> Option<&HistoryEntry> {
// We can't return &HistoryEntry from Arc<[HistoryMatchResult]>.
// Callers should use selected_text(). This returns None to satisfy
// the type signature used by accept logic — the accept path uses
// selected_text() via a separate check.
None
}
/// Text of the currently selected entry.
pub fn selected_text(&self) -> Option<&str> {
self.snapshot
.items
.get(self.selected)
.map(|r| r.text.as_str())
}
/// Get a result at a given index (for rendering).
pub fn result_at(&self, idx: usize) -> Option<&HistoryMatchResult> {
self.snapshot.items.get(idx)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn entries(texts: &[&str]) -> Vec<HistoryEntry> {
texts
.iter()
.map(|t| HistoryEntry {
text: t.to_string(),
})
.collect()
}
/// Helper: activate + poll until results arrive.
fn activate_and_poll(state: &mut HistorySearchState, history: &[HistoryEntry], saved: &str) {
state.activate(history, saved);
// The daemon runs on another thread; spin-poll briefly.
for _ in 0..100 {
if state.poll() && state.result_count() > 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
/// Helper: send query + poll until results update.
fn query_and_poll(state: &mut HistorySearchState, query: &str) {
state.update_query(query);
for _ in 0..100 {
if state.poll() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
#[test]
fn refresh_items_and_query_are_applied_together() {
let mut state = HistorySearchState::new();
state.activate(&[], "");
state.refresh_items(&entries(&["alpha", "beta"]));
state.update_query("beta");
let mut delivered = false;
for _ in 0..100 {
if state.poll() && state.result_count() == 1 && state.selected_text() == Some("beta") {
delivered = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(delivered);
}
#[test]
fn empty_query_returns_all_reversed_most_recent_last() {
let mut state = HistorySearchState::new();
// Input is most-recent-first (as `combined_prompt_history` produces).
let history = entries(&["alpha", "beta", "gamma"]);
activate_and_poll(&mut state, &history, "");
assert_eq!(state.result_count(), 3);
// Reversed: oldest at top, most recent ("alpha") at the bottom.
assert_eq!(state.result_at(0).unwrap().text, "gamma");
assert_eq!(state.result_at(1).unwrap().text, "beta");
assert_eq!(state.result_at(2).unwrap().text, "alpha");
// Opens with the most-recent prompt (bottom) selected.
assert_eq!(state.selected, 2);
assert_eq!(state.selected_text(), Some("alpha"));
}
#[test]
fn non_empty_query_filters() {
let mut state = HistorySearchState::new();
let history = entries(&["fix bug", "add feature", "fix typo", "refactor code"]);
activate_and_poll(&mut state, &history, "");
query_and_poll(&mut state, "fix");
assert!(state.result_count() >= 2);
let texts: Vec<&str> = (0..state.result_count())
.filter_map(|i| state.result_at(i).map(|r| r.text.as_str()))
.collect();
assert!(texts.contains(&"fix bug"));
assert!(texts.contains(&"fix typo"));
assert!(!state.result_at(0).unwrap().indices.is_empty());
// Results are reversed (best match last) and, with no navigation,
// selection sticks to the bottom-most (best) match.
assert_eq!(state.selected, state.result_count() - 1);
}
#[test]
fn typing_after_navigation_reanchors_to_best_match() {
let mut state = HistorySearchState::new();
activate_and_poll(
&mut state,
&entries(&["match1", "match2", "match3", "zzz", "www"]),
"",
);
assert_eq!(state.result_count(), 5);
assert_eq!(state.selected, 4); // bottom (most recent) selected on open
// Navigate up off the bottom — selection is no longer sticky.
state.move_up();
state.move_up();
state.move_up();
assert_eq!(state.selected, 1);
// Typing a new query re-anchors selection to the best match (bottom).
query_and_poll(&mut state, "match");
assert_eq!(state.result_count(), 3);
assert_eq!(state.selected, state.result_count() - 1);
}
#[test]
fn activate_stores_saved_text() {
let mut state = HistorySearchState::new();
state.activate(&entries(&["hello"]), "my draft");
assert!(state.is_active());
assert_eq!(state.saved_text(), "my draft");
}
#[test]
fn deactivate_clears_state() {
let mut state = HistorySearchState::new();
activate_and_poll(&mut state, &entries(&["a", "b"]), "text");
assert!(state.is_active());
state.deactivate();
assert!(!state.is_active());
assert_eq!(state.result_count(), 0);
}
#[test]
fn opens_with_most_recent_selected_at_bottom() {
let mut state = HistorySearchState::new();
// Input most-recent-first: "a" is most recent, "b" is older.
activate_and_poll(&mut state, &entries(&["a", "b"]), "");
// Most recent ("a") is reversed to the bottom (last index) and selected.
assert_eq!(state.selected, 1);
assert_eq!(state.selected_text(), Some("a"));
}
#[test]
fn move_up_selects_earlier_then_stops_at_top() {
let mut state = HistorySearchState::new();
activate_and_poll(&mut state, &entries(&["a", "b"]), "");
assert_eq!(state.selected, 1);
// Up moves toward earlier prompts (up the list).
assert!(state.move_up());
assert_eq!(state.selected, 0);
assert_eq!(state.selected_text(), Some("b"));
// No wrap: at the oldest entry Up stays put.
assert!(!state.move_up());
assert_eq!(state.selected, 0);
}
#[test]
fn move_down_at_bottom_reports_end_instead_of_wrapping() {
let mut state = HistorySearchState::new();
activate_and_poll(&mut state, &entries(&["a", "b"]), "");
assert_eq!(state.selected, 1);
// At the newest (bottom) entry Down reports the end — the caller
// closes the panel there ("Down right after opening backs out").
assert!(!state.move_down());
assert_eq!(state.selected, 1);
// From an older entry Down moves normally.
assert!(state.move_up());
assert!(state.move_down());
assert_eq!(state.selected, 1);
}
#[test]
fn browse_mode_flag_tracks_activation_kind() {
let mut state = HistorySearchState::new();
state.activate_browse(&entries(&["a"]), "");
assert!(state.is_active());
assert!(state.is_browse());
state.deactivate();
assert!(!state.is_browse());
state.activate(&entries(&["a"]), "");
assert!(state.is_active());
assert!(!state.is_browse(), "/history search mode is not browse");
}
#[test]
fn no_panic_on_empty_history() {
let mut state = HistorySearchState::new();
state.activate(&[], "");
assert_eq!(state.result_count(), 0);
assert!(state.selected_text().is_none());
assert!(!state.move_up());
assert!(!state.move_down());
}
#[test]
fn default_is_inactive() {
let state = HistorySearchState::default();
assert!(!state.is_active());
assert_eq!(state.result_count(), 0);
}
}
File diff suppressed because it is too large Load Diff
+254
View File
@@ -0,0 +1,254 @@
//! `/jump` picker: an overlay listing every turn in the conversation.
//!
//! Pure client-side navigation over the scrollback timeline
//! ([`crate::scrollback::state::TimelineEntry`]): moving the cursor
//! live-scrolls the transcript to the hovered turn, Enter jumps there,
//! Esc restores the viewport the picker opened from. Unlike `/rewind`
//! nothing is fetched and nothing is mutated.
//!
//! Chrome, row geometry, and hit-testing come from
//! [`crate::views::overlay_list::ListOverlay`] (shared with the rewind
//! picker); this module owns only the row content and input mapping.
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::render::line_utils::truncate_str;
use crate::scrollback::entry::EntryId;
use crate::scrollback::state::{ScrollAnchor, TimelineEntry};
use crate::theme::Theme;
use crate::views::overlay_list::ListOverlay;
/// Viewport snapshot captured when the picker opens, restored on Esc / failed
/// jump. The viewport is a width-stable [`ScrollAnchor`] bookmark, not a raw
/// scroll offset (which clamps and drifts under a resize).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct JumpRestore {
pub(crate) bookmark: Option<ScrollAnchor>,
pub selected: Option<usize>,
pub follow_mode: bool,
}
#[derive(Debug)]
pub struct JumpState {
/// One row per turn, oldest first (row index == `turn_idx`).
pub entries: Vec<TimelineEntry>,
/// Cursor row.
pub selected: usize,
/// Viewport to restore on dismiss.
pub restore: JumpRestore,
}
impl JumpState {
fn list(&self) -> ListOverlay {
ListOverlay {
len: self.entries.len(),
selected: self.selected,
}
}
}
pub enum JumpInput {
/// Jump to the turn (by its prompt's stable id) and close.
Select(EntryId),
Dismissed,
MoveUp,
MoveDown,
Consumed,
}
pub fn handle_jump_key(state: &JumpState, key: &KeyEvent) -> JumpInput {
if key.kind == crossterm::event::KeyEventKind::Release {
return JumpInput::Consumed;
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => JumpInput::MoveDown,
KeyCode::Char('k') | KeyCode::Up => JumpInput::MoveUp,
KeyCode::Enter => jump_activate(state),
KeyCode::Esc => JumpInput::Dismissed,
_ => JumpInput::Consumed,
}
}
/// Move the cursor by `delta`, clamped to the entry list.
pub fn move_cursor(state: &mut JumpState, delta: i32) {
if state.entries.is_empty() {
return;
}
let max = state.entries.len() as i32 - 1;
state.selected = (state.selected as i32 + delta).clamp(0, max) as usize;
}
/// Move the cursor to `idx` (mouse hover/click). Returns `true` on change.
pub fn set_jump_cursor(state: &mut JumpState, idx: usize) -> bool {
if state.entries.is_empty() {
return false;
}
let new = idx.min(state.entries.len() - 1);
if state.selected != new {
state.selected = new;
true
} else {
false
}
}
/// The activation input for the current cursor row (Enter-equivalent).
pub fn jump_activate(state: &JumpState) -> JumpInput {
state
.entries
.get(state.selected)
.map(|e| JumpInput::Select(e.prompt_entry_id))
.unwrap_or(JumpInput::Consumed)
}
/// Hit-test a screen position against the picker's clickable rows.
pub fn jump_row_at(state: &JumpState, area: Rect, col: u16, row: u16) -> Option<usize> {
state.list().row_at(area, col, row)
}
pub fn jump_overlay_height(state: &JumpState, screen_h: u16) -> u16 {
state.list().height(screen_h)
}
pub fn render_jump_overlay(buf: &mut Buffer, area: Rect, state: &JumpState, focused: bool) {
let theme = Theme::current();
// Ordinal gutter sized to the widest turn number.
let ord_width = state.entries.len().to_string().len();
state
.list()
.render(buf, area, "Jump to which turn?", focused, |i, ctx| {
let entry = &state.entries[i];
let ordinal = format!("{:>ord_width$} ", entry.turn_idx + 1);
let ord_style = Style::default().fg(theme.gray).bg(ctx.row_bg);
let preview: String = if entry.preview.is_empty() {
"(no preview)".to_string()
} else {
truncate_str(
&entry.preview,
ctx.content_width.saturating_sub(ord_width as u16 + 3) as usize,
)
};
let text_style = Style::default()
.fg(theme.text_primary)
.bg(ctx.row_bg)
.add_modifier(if ctx.is_cursor {
Modifier::BOLD
} else {
Modifier::empty()
});
Line::from(vec![
Span::styled(ordinal, ord_style),
Span::styled(preview, text_style),
])
});
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyEventKind, KeyModifiers};
fn entry(turn_idx: usize) -> TimelineEntry {
TimelineEntry {
turn_idx,
prompt_entry_id: EntryId::new(turn_idx as u64 * 2),
preview: format!("turn {turn_idx}"),
}
}
fn state(n: usize) -> JumpState {
JumpState {
entries: (0..n).map(entry).collect(),
selected: 0,
restore: JumpRestore {
bookmark: None,
selected: None,
follow_mode: false,
},
}
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::empty(),
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::empty(),
}
}
fn area() -> Rect {
Rect {
x: 0,
y: 0,
width: 40,
height: 10,
}
}
#[test]
fn keys_map_to_inputs() {
let s = state(3);
assert!(matches!(
handle_jump_key(&s, &key(KeyCode::Char('j'))),
JumpInput::MoveDown
));
assert!(matches!(
handle_jump_key(&s, &key(KeyCode::Up)),
JumpInput::MoveUp
));
assert!(matches!(
handle_jump_key(&s, &key(KeyCode::Enter)),
JumpInput::Select(id) if id == EntryId::new(0)
));
assert!(matches!(
handle_jump_key(&s, &key(KeyCode::Esc)),
JumpInput::Dismissed
));
assert!(matches!(
handle_jump_key(&s, &key(KeyCode::Char('x'))),
JumpInput::Consumed
));
}
#[test]
fn cursor_moves_and_clamps() {
let mut s = state(3);
move_cursor(&mut s, 1);
assert_eq!(s.selected, 1);
move_cursor(&mut s, 10);
assert_eq!(s.selected, 2);
move_cursor(&mut s, -10);
assert_eq!(s.selected, 0);
assert!(set_jump_cursor(&mut s, 2));
assert!(!set_jump_cursor(&mut s, 2));
assert!(!set_jump_cursor(&mut s, 99), "clamps to last (no change)");
assert_eq!(s.selected, 2);
}
#[test]
fn activate_selects_turn_under_cursor() {
let mut s = state(3);
s.selected = 2;
assert!(matches!(jump_activate(&s), JumpInput::Select(id) if id == EntryId::new(4)));
let empty = state(0);
assert!(matches!(jump_activate(&empty), JumpInput::Consumed));
}
#[test]
fn row_hit_test_maps_to_entry_index() {
let s = state(3);
// Title at y+1; rows start at y+2 (ListOverlay geometry).
assert_eq!(jump_row_at(&s, area(), 5, 1), None);
assert_eq!(jump_row_at(&s, area(), 5, 2), Some(0));
assert_eq!(jump_row_at(&s, area(), 5, 4), Some(2));
assert_eq!(jump_row_at(&s, area(), 5, 5), None);
}
}
@@ -0,0 +1,298 @@
//! Layout cache for the list pane.
//!
//! Tracks per-item heights and prefix sums so that scroll-position ↔ item-index
//! conversions are fast. Two variants:
//!
//! - [`FixedHeight`] — all items have height 1 (NoWrap mode). Everything is O(1).
//! - [`Variable`] — items have different heights (Wrap mode). Uses a prefix-sum
//! vec for O(log n) position lookups.
/// Wrap mode for the list pane.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WrapMode {
/// Soft-wrap lines at viewport width. Variable height per item.
/// Requires full layout cache.
Wrap,
/// No wrapping — each item is exactly 1 visual line, truncated with `…`.
/// Layout is trivial O(1).
NoWrap,
}
/// Layout cache — an enum to support the fixed-height fast path.
#[derive(Debug, Clone)]
pub enum ListLayoutCache {
/// All items are height 1 (NoWrap mode). No allocation needed.
FixedHeight {
/// Number of items (= total height in visual lines).
count: usize,
},
/// Variable-height items (Wrap mode).
Variable {
/// Width at which heights were computed.
width: u16,
/// Per-item heights (indexed by *visible* index when filtered).
heights: Vec<u16>,
/// Prefix sums: `prefix_sums[i]` = sum of `heights[0..i]`.
///
/// Length = `heights.len() + 1`. `prefix_sums[0] = 0`.
/// `prefix_sums[n] = total_height`.
prefix_sums: Vec<usize>,
},
}
impl ListLayoutCache {
// -----------------------------------------------------------------------
// Constructors
// -----------------------------------------------------------------------
/// Create a fixed-height cache for `count` items (all height 1).
pub fn fixed(count: usize) -> Self {
Self::FixedHeight { count }
}
/// Build a variable-height cache from an iterator of per-item heights.
pub fn from_heights(width: u16, heights: impl IntoIterator<Item = u16>) -> Self {
let heights: Vec<u16> = heights.into_iter().collect();
let mut prefix_sums = Vec::with_capacity(heights.len() + 1);
prefix_sums.push(0);
for &h in &heights {
let prev = *prefix_sums.last().unwrap();
prefix_sums.push(prev + h as usize);
}
Self::Variable {
width,
heights,
prefix_sums,
}
}
/// Extend an existing `Variable` cache with additional item heights.
///
/// Used for **incremental append**: when new items arrive, we compute
/// heights only for the new items and extend the prefix-sum array.
///
/// Panics if `self` is `FixedHeight` — caller must ensure the mode matches.
pub fn extend_heights(&mut self, new_heights: impl IntoIterator<Item = u16>) {
match self {
Self::Variable {
heights,
prefix_sums,
..
} => {
for h in new_heights {
let prev = *prefix_sums.last().unwrap();
prefix_sums.push(prev + h as usize);
heights.push(h);
}
}
Self::FixedHeight { .. } => {
panic!("extend_heights called on FixedHeight cache");
}
}
}
// -----------------------------------------------------------------------
// Queries
// -----------------------------------------------------------------------
/// Total height in visual lines.
pub fn total_height(&self) -> usize {
match self {
Self::FixedHeight { count } => *count,
Self::Variable { prefix_sums, .. } => *prefix_sums.last().unwrap_or(&0),
}
}
/// Number of items in the cache.
pub fn item_count(&self) -> usize {
match self {
Self::FixedHeight { count } => *count,
Self::Variable { heights, .. } => heights.len(),
}
}
/// Virtual-y position (in visual lines from top) of item at `idx`.
///
/// For `FixedHeight`, this is just `idx`.
pub fn virtual_y(&self, idx: usize) -> usize {
match self {
Self::FixedHeight { .. } => idx,
Self::Variable { prefix_sums, .. } => prefix_sums.get(idx).copied().unwrap_or(0),
}
}
/// Height of item at `idx` in visual lines.
pub fn item_height(&self, idx: usize) -> u16 {
match self {
Self::FixedHeight { .. } => 1,
Self::Variable { heights, .. } => heights.get(idx).copied().unwrap_or(1),
}
}
/// Find the item index whose virtual-y range contains `y`.
///
/// For `FixedHeight`, this is just `y` (clamped to `count - 1`).
/// For `Variable`, binary search on prefix sums — O(log n).
///
/// Returns `None` if the cache is empty.
pub fn item_at_y(&self, y: usize) -> Option<usize> {
match self {
Self::FixedHeight { count } => {
if *count == 0 {
None
} else {
Some(y.min(*count - 1))
}
}
Self::Variable { prefix_sums, .. } => {
if prefix_sums.len() <= 1 {
return None; // empty
}
// Binary search: find the largest i such that prefix_sums[i] <= y.
// partition_point returns the first index where prefix_sums[i] > y,
// so we subtract 1.
let pos = prefix_sums.partition_point(|&s| s <= y);
let idx = pos.saturating_sub(1);
// Clamp to valid item range
let max_idx = prefix_sums.len() - 2; // last valid item index
Some(idx.min(max_idx))
}
}
}
/// Width at which this cache was computed (only meaningful for `Variable`).
pub fn cached_width(&self) -> Option<u16> {
match self {
Self::FixedHeight { .. } => None,
Self::Variable { width, .. } => Some(*width),
}
}
}
// ===========================================================================
// Tests
// ===========================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixed_height_basics() {
let cache = ListLayoutCache::fixed(5);
assert_eq!(cache.total_height(), 5);
assert_eq!(cache.item_count(), 5);
assert_eq!(cache.virtual_y(0), 0);
assert_eq!(cache.virtual_y(3), 3);
assert_eq!(cache.item_height(0), 1);
assert_eq!(cache.item_height(4), 1);
assert_eq!(cache.item_at_y(0), Some(0));
assert_eq!(cache.item_at_y(4), Some(4));
// Clamped
assert_eq!(cache.item_at_y(100), Some(4));
}
#[test]
fn fixed_height_empty() {
let cache = ListLayoutCache::fixed(0);
assert_eq!(cache.total_height(), 0);
assert_eq!(cache.item_count(), 0);
assert_eq!(cache.item_at_y(0), None);
}
#[test]
fn variable_height_basics() {
// Items with heights: 3, 1, 2, 4
let cache = ListLayoutCache::from_heights(80, vec![3, 1, 2, 4]);
assert_eq!(cache.total_height(), 10);
assert_eq!(cache.item_count(), 4);
// virtual_y positions: 0, 3, 4, 6
assert_eq!(cache.virtual_y(0), 0);
assert_eq!(cache.virtual_y(1), 3);
assert_eq!(cache.virtual_y(2), 4);
assert_eq!(cache.virtual_y(3), 6);
assert_eq!(cache.item_height(0), 3);
assert_eq!(cache.item_height(1), 1);
assert_eq!(cache.item_height(2), 2);
assert_eq!(cache.item_height(3), 4);
}
#[test]
fn variable_height_item_at_y() {
// Items with heights: 3, 1, 2, 4 → prefix_sums: [0, 3, 4, 6, 10]
let cache = ListLayoutCache::from_heights(80, vec![3, 1, 2, 4]);
// y=0,1,2 → item 0
assert_eq!(cache.item_at_y(0), Some(0));
assert_eq!(cache.item_at_y(1), Some(0));
assert_eq!(cache.item_at_y(2), Some(0));
// y=3 → item 1
assert_eq!(cache.item_at_y(3), Some(1));
// y=4,5 → item 2
assert_eq!(cache.item_at_y(4), Some(2));
assert_eq!(cache.item_at_y(5), Some(2));
// y=6,7,8,9 → item 3
assert_eq!(cache.item_at_y(6), Some(3));
assert_eq!(cache.item_at_y(9), Some(3));
// y=10+ → clamped to item 3
assert_eq!(cache.item_at_y(10), Some(3));
assert_eq!(cache.item_at_y(100), Some(3));
}
#[test]
fn variable_height_empty() {
let cache = ListLayoutCache::from_heights(80, Vec::<u16>::new());
assert_eq!(cache.total_height(), 0);
assert_eq!(cache.item_count(), 0);
assert_eq!(cache.item_at_y(0), None);
}
#[test]
fn variable_height_single_item() {
let cache = ListLayoutCache::from_heights(80, vec![5]);
assert_eq!(cache.total_height(), 5);
assert_eq!(cache.item_count(), 1);
assert_eq!(cache.virtual_y(0), 0);
assert_eq!(cache.item_at_y(0), Some(0));
assert_eq!(cache.item_at_y(4), Some(0));
assert_eq!(cache.item_at_y(5), Some(0)); // clamped
}
#[test]
fn cached_width() {
let fixed = ListLayoutCache::fixed(5);
assert_eq!(fixed.cached_width(), None);
let var = ListLayoutCache::from_heights(120, vec![1, 2]);
assert_eq!(var.cached_width(), Some(120));
}
#[test]
fn extend_heights_appends() {
let mut cache = ListLayoutCache::from_heights(80, vec![3, 1]);
assert_eq!(cache.item_count(), 2);
assert_eq!(cache.total_height(), 4);
cache.extend_heights(vec![2, 4]);
assert_eq!(cache.item_count(), 4);
assert_eq!(cache.total_height(), 10);
// Prefix sums: [0, 3, 4, 6, 10]
assert_eq!(cache.virtual_y(0), 0);
assert_eq!(cache.virtual_y(1), 3);
assert_eq!(cache.virtual_y(2), 4);
assert_eq!(cache.virtual_y(3), 6);
assert_eq!(cache.item_height(2), 2);
assert_eq!(cache.item_height(3), 4);
}
#[test]
fn extend_heights_empty_iter_is_noop() {
let mut cache = ListLayoutCache::from_heights(80, vec![3, 1]);
cache.extend_heights(Vec::<u16>::new());
assert_eq!(cache.item_count(), 2);
assert_eq!(cache.total_height(), 4);
}
}
@@ -0,0 +1,316 @@
//! Generic scrollable list pane widget.
//!
//! `ListPaneState` + `ListPane<T>` provide a reusable, scrollable, selectable
//! list component. The state is non-generic and owns only scroll/selection/layout
//! data; item data lives in an external model and is borrowed via
//! [`ListPaneState::prepare_layout`].
//!
//! Designed for three concrete use cases:
//! - **Tracing pane** (100K+ entries, append-only, NoWrap, follow mode)
//! - **Todo pane** (<10 items, random mutations, Wrap)
//! - **Background task pane** (<10 items, random mutations, NoWrap)
mod layout;
mod render;
mod state;
pub use crate::search::QueryKind;
pub use layout::{ListLayoutCache, WrapMode};
pub use render::ListPane;
pub use state::{
FilterMatcher, InputBarMode, ListFilter, ListMatcher, ListPaneConfig, ListPaneState, MatchMode,
};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::text::Line;
// ---------------------------------------------------------------------------
// ListPaneStyle — configurable colors for the framework's post-pass overlays
// ---------------------------------------------------------------------------
/// Visual style configuration for a `ListPane`.
///
/// Controls colors for selection highlighting, input bar, and other
/// framework-level overlays. Match highlights use style inversion
/// (REVERSED modifier) and don't need configurable colors.
///
/// Items do **not** need to know about these — the framework applies them
/// as post-passes after each item renders.
#[derive(Debug, Clone, Copy)]
pub struct ListPaneStyle {
/// Background color for the selected item row (cursor line).
pub selection_bg: Color,
/// Background color for the visual selection range (not the cursor line).
/// Slightly distinct from cursor bg, distinguishing range from cursor.
pub visual_select_bg: Color,
/// Background color for the input bar (search/filter).
pub input_bar_bg: Color,
/// Foreground color for the prompt prefix (`/`, `f>`).
pub input_bar_prompt_fg: Color,
/// Foreground color for the typed query text.
pub input_bar_text_fg: Color,
/// Scrollbar track background color.
pub scrollbar_bg: Color,
/// Scrollbar thumb foreground color.
pub scrollbar_fg: Color,
/// Corner indicator color (▲ ▼ for scroll position hints).
pub indicator_fg: Color,
/// Follow mode indicator color (▶ in bottom-right when following).
/// Distinct from `indicator_fg` so it's visible against content.
pub follow_indicator_fg: Color,
/// "Copied!" toast foreground color.
pub toast_fg: Color,
/// When true, the cursor line uses `visual_select_bg` when inside a
/// visual selection (uniform range appearance). The cursor is then
/// distinguished only by the `prefix_cursor` style, not by background.
///
/// When false (default), the cursor line always uses `selection_bg`,
/// even within a visual selection.
pub uniform_visual_bg: bool,
/// When false, the right-corner scroll indicators (▲/▼) are suppressed.
/// Used by panes that draw their own scroll affordance (e.g. the tasks
/// pane draws the same ▲/▼ centered on dedicated rows). Defaults to `true`.
pub show_corner_indicators: bool,
}
impl Default for ListPaneStyle {
fn default() -> Self {
let theme = crate::theme::Theme::current();
Self {
// Palette defaults — sourced from theme to ensure quantization.
selection_bg: theme.bg_highlight,
visual_select_bg: theme.bg_visual,
input_bar_bg: theme.bg_base,
input_bar_prompt_fg: theme.command,
input_bar_text_fg: theme.text_secondary,
scrollbar_bg: theme.bg_base,
scrollbar_fg: theme.scrollbar_fg,
indicator_fg: theme.gray,
follow_indicator_fg: theme.command,
toast_fg: theme.accent_user,
uniform_visual_bg: false,
show_corner_indicators: true,
}
}
}
// ---------------------------------------------------------------------------
// ListItem trait
// ---------------------------------------------------------------------------
/// Trait that items in a `ListPane` must implement.
///
/// Items are owned by the **model** (not the view). The view borrows them
/// through `&[T]` in [`ListPaneState::prepare_layout`] and
/// [`ListPane::new`].
///
/// ## Rendering: two modes
///
/// **Content-based (preferred):** implement [`content()`] and optionally
/// [`prefix()`]. The framework handles wrapping, truncation, highlighting,
/// and selection overlays automatically. This is the right choice for most
/// items.
///
/// **Custom rendering (escape hatch):** override [`render()`] to paint
/// directly into a buffer. Use this only when the content/prefix model
/// doesn't fit (e.g. diff hunks with side-by-side layout). You must also
/// override [`desired_height()`] when using custom rendering.
///
/// Items that implement [`content()`] (non-empty Line) get framework
/// rendering; the default [`render()`] and [`desired_height()`] are derived
/// automatically. Items that override [`render()`] bypass the framework.
pub trait ListItem {
// =======================================================================
// Content-based API (preferred)
// =======================================================================
/// The styled content to display — one logical line of text.
///
/// The framework handles wrapping (Wrap mode) and truncation (NoWrap mode)
/// based on this content. Return a reference to a stored `Line`.
///
/// Default returns an empty `Line` (signals "use custom `render()`").
fn content(&self) -> &Line<'_> {
static EMPTY: std::sync::LazyLock<Line<'static>> = std::sync::LazyLock::new(Line::default);
&EMPTY
}
/// Optional prefix column (checkbox, spinner, timestamp, etc.).
///
/// Rendered in a fixed-width column at the left edge of the item.
/// In Wrap mode, continuation lines are indented by the prefix width.
///
/// Returned by value since prefixes are small and often constructed
/// dynamically (spinner frame, elapsed timer, checkbox toggle).
fn prefix(&self) -> Option<Line<'_>> {
None
}
/// Optional prefix for items in the visual selection range (not the cursor line).
///
/// Called instead of `prefix()` when the item is in the visual selection
/// range but NOT the cursor line. Default falls back to `prefix()`.
fn prefix_in_selection(&self) -> Option<Line<'_>> {
self.prefix()
}
/// Optional prefix for the cursor line (the focused/active item).
///
/// Called instead of `prefix()` when the item is the cursor line.
/// Default falls back to `prefix()`.
fn prefix_cursor(&self) -> Option<Line<'_>> {
self.prefix()
}
/// Optional full-width background color for this item.
///
/// When `Some(color)`, the framework fills the entire item row(s) with
/// this background color before rendering content. Used for code blocks
/// in markdown viewers.
fn background(&self) -> Option<Color> {
None
}
// =======================================================================
// Custom rendering API (escape hatch)
// =======================================================================
/// Render this item into the given area.
///
/// Override this **only** when the content/prefix model doesn't fit.
/// When using the content-based API, leave this as the default (no-op).
///
/// The framework calls this only when `content()` returns an empty Line.
fn render(&self, _area: Rect, _buf: &mut Buffer, _selected: bool, _focused: bool) {}
/// Height in visual lines at the given `width` when soft-wrapping.
///
/// In `NoWrap` mode the pane ignores this and uses height = 1.
/// Must be ≥ 1.
///
/// Default implementation computes from [`content()`] and [`prefix()`].
/// Override only when using custom [`render()`].
fn desired_height(&self, width: u16) -> u16 {
if width == 0 {
return 1;
}
let prefix_w = self.prefix().map(|p| line_display_width(&p)).unwrap_or(0);
let content_w = line_display_width(self.content());
if content_w == 0 {
return 1;
}
let text_area = (width as usize).saturating_sub(prefix_w);
if text_area == 0 {
return 1;
}
// Use actual word-wrap line count via textwrap (not character-count
// division). The cheap ceil(chars/width) estimate underestimates
// because word-aware wrapping produces more lines when words can't
// fit at line boundaries.
//
// We use textwrap::wrap directly (cheap — just computes break
// positions) rather than word_wrap_line (expensive — builds styled
// Lines). Uses the same FirstFit options as the rendering pipeline.
let flat: String = self
.content()
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
let opts = textwrap::Options::new(text_area)
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit)
.break_words(true);
(textwrap::wrap(&flat, opts).len() as u16).max(1)
}
// =======================================================================
// Identity & behavior
// =======================================================================
/// Stable identity that survives insertions, removals, and reordering.
///
/// Must be unique within the list. Used so that selection state persists
/// across mutations without index arithmetic.
fn stable_id(&self) -> u64;
/// Whether this item can be selected. Return `false` for separator rows.
fn is_selectable(&self) -> bool {
true
}
/// Source line number for goto-line (`:N`) navigation.
///
/// When items have a meaningful source line number (e.g., file viewer
/// lines), return `Some(n)` so goto-line targets the correct item even
/// when the visual index differs (e.g., interleaved comment lines).
/// Return `None` (default) to use the visual index.
fn goto_line_number(&self) -> Option<usize> {
None
}
/// Whether this item needs periodic tick updates (e.g. elapsed timer).
fn needs_tick(&self) -> bool {
false
}
// =======================================================================
// Search / filter
// =======================================================================
/// Plain text for search/filter matching.
///
/// The framework calls `regex.is_match(item.search_text())` during
/// filtering and `regex.find_iter(item.search_text())` for highlight
/// rendering. Byte offsets in this string correspond to the text
/// content rendered starting at column [`search_text_col_offset`].
///
/// Default returns `""` (item not searchable/filterable).
fn search_text(&self) -> &str {
""
}
/// Column offset where `search_text()` content begins in the rendered output.
///
/// The framework uses this to position match highlights correctly.
///
/// Default derives from [`prefix()`] display width. Override only
/// when using custom [`render()`] with a non-standard layout.
fn search_text_col_offset(&self) -> u16 {
self.prefix()
.map(|p| line_display_width(&p) as u16)
.unwrap_or(0)
}
/// Text to copy when `y` is pressed.
///
/// Default extracts plain text from `content()`. Override for items
/// that use custom `render()` with empty `content()`.
fn copy_text(&self) -> String {
self.content()
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
}
}
/// Compute the display width of a ratatui `Line` (sum of span display widths).
pub(crate) fn line_display_width(line: &Line<'_>) -> usize {
line.spans
.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
.sum()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,634 @@
//! MCP server data types, status enum, response conversion, and section
//! presentation helpers (labels, description lines, connectors URLs).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpWireSource {
Managed,
Local,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpSectionId {
Managed,
Plugin(String),
Local,
}
impl PartialOrd for McpSectionId {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for McpSectionId {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
use std::cmp::Ordering;
match (self, other) {
(Self::Managed, Self::Managed) => Ordering::Equal,
(Self::Managed, _) => Ordering::Less,
(_, Self::Managed) => Ordering::Greater,
(Self::Plugin(a), Self::Plugin(b)) => a.cmp(b),
(Self::Plugin(_), Self::Local) => Ordering::Less,
(Self::Local, Self::Plugin(_)) => Ordering::Greater,
(Self::Local, Self::Local) => Ordering::Equal,
}
}
}
/// Collapse/expand key for a section header row in the MCP servers tab.
pub fn section_key(section: &McpSectionId) -> String {
match section {
McpSectionId::Managed => "mcp-section:managed".into(),
McpSectionId::Plugin(name) => format!("mcp-section:plugin:{name}"),
McpSectionId::Local => "mcp-section:local".into(),
}
}
/// Display label for a section header, e.g. `"Managed by grok.com (3)"`.
pub fn section_label(section: &McpSectionId, count: usize) -> String {
match section {
McpSectionId::Managed => format!("Managed by grok.com ({count})"),
McpSectionId::Plugin(name) => format!("Plugin: {name} ({count})"),
McpSectionId::Local => format!("Local ({count})"),
}
}
/// Base grok.com connectors URL (no team). Prefer [`managed_connectors_url`] when opening.
pub const MANAGED_SECTION_CONNECTORS_URL: &str = "https://grok.com/connectors";
/// Connectors deep link, appending percent-encoded `teamId` when the session is a team principal.
pub fn managed_connectors_url(team_id: Option<&str>) -> String {
match team_id.filter(|id| !id.is_empty()) {
Some(id) => format!(
"{MANAGED_SECTION_CONNECTORS_URL}?teamId={}",
urlencoding::encode(id)
),
None => MANAGED_SECTION_CONNECTORS_URL.to_string(),
}
}
/// Display form of [`managed_connectors_url`] with the `https://` scheme dropped.
///
/// Used for the Managed section subtitle so the URL is shorter and more likely
/// to fit on one row; the Ctrl+O action still opens the full-scheme URL.
pub fn managed_connectors_url_display(team_id: Option<&str>) -> String {
let url = managed_connectors_url(team_id);
url.strip_prefix("https://").unwrap_or(&url).to_string()
}
/// Description lines shown under the Managed section header (when expanded).
/// `team_id` matches the Ctrl+O / open-connectors deep link for the session.
pub fn section_description_lines(section: &McpSectionId, team_id: Option<&str>) -> Vec<String> {
match section {
McpSectionId::Managed => {
let url = managed_connectors_url_display(team_id);
vec![
"Add, remove, or manage connectors. Ctrl+O to open or go to:".into(),
format!("[{url}]"),
]
}
McpSectionId::Plugin(_) | McpSectionId::Local => vec![],
}
}
/// Classify a server into a UI section.
///
/// Priority: `grok_com_` prefix or managed wire source → Managed; else plugin
/// label → Plugin; else Local. A managed server with a plugin display label
/// still lands in Managed.
pub fn section_for(server: &McpServerInfo) -> McpSectionId {
if server.name.starts_with("grok_com_") || server.wire_source == McpWireSource::Managed {
McpSectionId::Managed
} else if let Some(ref name) = server.plugin_name {
McpSectionId::Plugin(name.clone())
} else {
McpSectionId::Local
}
}
/// Whether the user may delete this server from local config.
pub fn is_removable(server: &McpServerInfo) -> bool {
server.wire_source == McpWireSource::Local && !server.name.starts_with("grok_com_")
}
fn parse_wire_source(raw: Option<&str>) -> McpWireSource {
match raw {
Some("managed") => McpWireSource::Managed,
_ => McpWireSource::Local,
}
}
fn parse_plugin_name(source_label: &str) -> Option<String> {
let rest = source_label.strip_prefix("plugin:")?.trim();
if rest.is_empty() {
None
} else {
Some(rest.to_string())
}
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpsListResponse {
pub servers: Vec<McpsServerEntry>,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpsServerEntry {
pub name: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub source_label: Option<String>,
#[serde(default, rename = "type")]
pub config_type: Option<String>,
#[serde(default)]
pub session: Option<McpsServerSession>,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpsServerSession {
pub enabled: bool,
pub status: Option<String>,
#[serde(default)]
pub tools: Vec<serde_json::Value>,
#[serde(default)]
pub auth_required: bool,
}
#[derive(Debug, Clone)]
pub struct McpToolDetail {
pub name: String,
pub display_name: Option<String>,
pub description: Option<String>,
pub enabled: bool,
}
#[derive(Debug, Clone)]
pub struct McpServerInfo {
pub name: String,
pub display_name: Option<String>,
pub status: McpServerDisplayStatus,
pub tool_count: usize,
pub auth_required: bool,
/// Detailed tool list for expanded view.
pub tools: Vec<McpToolDetail>,
/// Whether the server is enabled in config.
pub enabled: bool,
/// Display label from `source_label` or wire `source` (e.g. `"plugin: foo"`).
pub source: String,
/// Wire `source` enum before display overlay.
pub wire_source: McpWireSource,
/// Plugin name parsed from `source_label` (`"plugin: …"`).
pub plugin_name: Option<String>,
pub is_managed_gateway: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum McpServerDisplayStatus {
Ready,
NeedsAuth,
Unavailable,
Initializing,
}
impl McpServerDisplayStatus {
/// Theme-aware status color for badge rendering.
pub(crate) fn theme_color(&self, theme: &crate::theme::Theme) -> ratatui::style::Color {
match self {
Self::Ready => theme.accent_success,
Self::NeedsAuth => theme.warning,
Self::Unavailable => theme.accent_error,
Self::Initializing => theme.running,
}
}
/// Short human label for the status.
pub(crate) fn label(&self) -> &'static str {
match self {
Self::Ready => "ready",
Self::NeedsAuth => "needs auth",
Self::Unavailable => "unavailable",
Self::Initializing => "initializing",
}
}
}
pub fn convert_list_response(resp: McpsListResponse) -> Vec<McpServerInfo> {
let mut servers: Vec<McpServerInfo> = resp
.servers
.into_iter()
.map(|entry| {
let (status, tool_count, tools, auth_required, enabled) =
if let Some(session) = &entry.session {
let enabled = session.enabled;
if session.auth_required {
(McpServerDisplayStatus::NeedsAuth, 0, vec![], true, enabled)
} else if !enabled {
(McpServerDisplayStatus::Unavailable, 0, vec![], false, false)
} else {
let st = match session.status.as_deref() {
Some("ready") => McpServerDisplayStatus::Ready,
Some("initializing") => McpServerDisplayStatus::Initializing,
_ => McpServerDisplayStatus::Unavailable,
};
let tools: Vec<McpToolDetail> = session
.tools
.iter()
.map(|t| McpToolDetail {
name: t
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
display_name: t
.get("displayName")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
description: t
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
enabled: t.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
})
.collect();
let tc = tools.len();
(st, tc, tools, false, enabled)
}
} else {
(McpServerDisplayStatus::Unavailable, 0, vec![], false, false)
};
let wire_source = parse_wire_source(entry.source.as_deref());
let plugin_name = entry.source_label.as_deref().and_then(parse_plugin_name);
let is_managed_gateway = entry.name.starts_with("managed_gateway:")
|| entry.config_type.as_deref() == Some("managedGateway");
let source = entry
.source_label
.or(entry.source)
.unwrap_or_else(|| "local".to_string());
McpServerInfo {
name: entry.name,
display_name: entry.display_name,
status,
tool_count,
auth_required,
tools,
enabled,
source,
wire_source,
plugin_name,
is_managed_gateway,
}
})
.collect::<Vec<_>>();
// Stable sort: managed before plugin/local, then alphabetical by name.
servers.sort_by(|a, b| {
let source_rank = |s: &McpServerInfo| match section_for(s) {
McpSectionId::Managed => 0,
McpSectionId::Plugin(_) => 1,
McpSectionId::Local => 2,
};
source_rank(a)
.cmp(&source_rank(b))
.then_with(|| {
a.display_name
.as_deref()
.unwrap_or(&a.name)
.cmp(b.display_name.as_deref().unwrap_or(&b.name))
})
.then_with(|| a.name.cmp(&b.name))
});
servers
}
/// Patch a single server row in-place from an `x.ai/mcp/server_status`
/// push.
///
/// Finds the row by `name` and updates its `status` (and optionally its
/// `tools` list + `tool_count`). When the named server is not present
/// the call is a silent no-op — the pager may receive a status push
/// for a server it has not yet fetched (e.g. the modal was just opened
/// and the cached `mcp/list` response has not landed yet). The cheap
/// no-op keeps the push subscription side-effect-free in that case.
///
/// When duplicate names exist, only the first occurrence is mutated.
/// In practice `build_mcp_catalog` deduplicates by name before the
/// list reaches the pager, so this is dead-code in production.
///
/// Returns `true` when a row was actually mutated; the caller can use
/// this signal to decide whether a redraw is warranted.
pub fn patch_server_row(
servers: &mut [McpServerInfo],
name: &str,
new_status: McpServerDisplayStatus,
new_tools: Option<Vec<McpToolDetail>>,
) -> bool {
let Some(row) = servers.iter_mut().find(|s| s.name == name) else {
return false;
};
row.status = new_status;
if let Some(tools) = new_tools {
row.tool_count = tools.len();
row.tools = tools;
}
true
}
#[cfg(test)]
mod tests {
use super::*;
fn make_row(name: &str, status: McpServerDisplayStatus) -> McpServerInfo {
McpServerInfo {
name: name.to_string(),
display_name: None,
status,
tool_count: 0,
auth_required: false,
tools: Vec::new(),
enabled: true,
source: "local".to_string(),
wire_source: McpWireSource::Local,
plugin_name: None,
is_managed_gateway: false,
}
}
fn server_from_wire(
name: &str,
source: Option<&str>,
source_label: Option<&str>,
) -> McpServerInfo {
server_from_wire_with_type(name, source, source_label, None)
}
fn server_from_wire_with_type(
name: &str,
source: Option<&str>,
source_label: Option<&str>,
config_type: Option<&str>,
) -> McpServerInfo {
convert_list_response(McpsListResponse {
servers: vec![McpsServerEntry {
name: name.to_string(),
display_name: None,
source: source.map(str::to_string),
source_label: source_label.map(str::to_string),
config_type: config_type.map(str::to_string),
session: Some(McpsServerSession {
enabled: true,
status: Some("ready".into()),
tools: vec![],
auth_required: false,
}),
}],
})
.into_iter()
.next()
.unwrap()
}
#[test]
fn section_description_lines_managed_includes_connectors_url() {
let lines = section_description_lines(&McpSectionId::Managed, None);
assert_eq!(lines.len(), 2);
// Instruction leads; Ctrl+O hint lives on the first line.
assert!(
lines[0].contains("Ctrl+O"),
"should mention Ctrl+O shortcut: {}",
lines[0]
);
// URL sits alone on the second line, scheme-stripped and bracket-highlighted.
assert_eq!(lines[1], "[grok.com/connectors]");
assert!(
!lines[1].contains("https://"),
"displayed URL should drop the scheme: {}",
lines[1]
);
let with_team = section_description_lines(&McpSectionId::Managed, Some("team-1"));
assert_eq!(with_team[1], "[grok.com/connectors?teamId=team-1]");
}
#[test]
fn managed_connectors_url_display_strips_scheme() {
assert_eq!(managed_connectors_url_display(None), "grok.com/connectors");
assert_eq!(
managed_connectors_url_display(Some("team-uuid-1")),
"grok.com/connectors?teamId=team-uuid-1"
);
}
#[test]
fn managed_connectors_url_appends_team_id_when_present() {
assert_eq!(managed_connectors_url(None), MANAGED_SECTION_CONNECTORS_URL);
assert_eq!(
managed_connectors_url(Some("")),
MANAGED_SECTION_CONNECTORS_URL
);
assert_eq!(
managed_connectors_url(Some("team-uuid-1")),
format!("{MANAGED_SECTION_CONNECTORS_URL}?teamId=team-uuid-1")
);
assert_eq!(
managed_connectors_url(Some("a b/c")),
format!(
"{MANAGED_SECTION_CONNECTORS_URL}?teamId={}",
urlencoding::encode("a b/c")
)
);
}
#[test]
fn section_description_lines_local_is_empty() {
assert!(section_description_lines(&McpSectionId::Local, None).is_empty());
}
#[test]
fn section_for_grok_com_with_plugin_label_is_managed() {
let server = server_from_wire(
"grok_com_linear",
Some("managed"),
Some("plugin: my-plugin"),
);
assert_eq!(section_for(&server), McpSectionId::Managed);
}
#[test]
fn section_for_plugin_labeled_local_is_plugin_section() {
let server = server_from_wire("my-mcp", Some("local"), Some("plugin: linter"));
assert_eq!(
section_for(&server),
McpSectionId::Plugin("linter".to_string())
);
}
#[test]
fn is_removable_plugin_labeled_local_server() {
let server = server_from_wire("my-mcp", Some("local"), Some("plugin: linter"));
assert!(is_removable(&server));
}
#[test]
fn is_removable_rejects_managed_wire_source() {
let server = server_from_wire("custom", Some("managed"), None);
assert!(!is_removable(&server));
}
#[test]
fn is_removable_rejects_grok_com_prefix() {
let server = server_from_wire("grok_com_slack", Some("local"), None);
assert!(!is_removable(&server));
}
#[test]
fn convert_list_response_parses_plugin_name() {
let server = server_from_wire("srv", Some("local"), Some("plugin: example"));
assert_eq!(server.wire_source, McpWireSource::Local);
assert_eq!(server.plugin_name.as_deref(), Some("example"));
assert_eq!(server.source, "plugin: example");
}
#[test]
fn convert_list_response_classifies_managed_gateway_only_for_gateway_rows() {
let gateway = server_from_wire_with_type(
"managed_gateway:linear",
Some("managed"),
None,
Some("managedGateway"),
);
assert!(gateway.is_managed_gateway);
let legacy_managed = server_from_wire("grok_com_slack", Some("managed"), None);
assert!(!legacy_managed.is_managed_gateway);
}
#[test]
fn gateway_row_uses_managed_section_not_local_uninstall() {
let gateway = server_from_wire_with_type(
"managed_gateway:linear",
Some("managed"),
None,
Some("managedGateway"),
);
assert_eq!(section_for(&gateway), McpSectionId::Managed);
assert!(!is_removable(&gateway));
}
#[test]
fn convert_list_response_orders_gateway_rows_by_display_name() {
fn gateway_entry(name: &str, display_name: &str) -> McpsServerEntry {
McpsServerEntry {
name: name.to_string(),
display_name: Some(display_name.to_string()),
source: Some("managed".to_string()),
source_label: None,
config_type: Some("managedGateway".to_string()),
session: Some(McpsServerSession {
enabled: true,
status: Some("ready".to_string()),
tools: vec![],
auth_required: false,
}),
}
}
let servers = convert_list_response(McpsListResponse {
servers: vec![
gateway_entry("managed_gateway:zeta", "Alpha"),
gateway_entry("managed_gateway:alpha", "Zeta"),
],
});
assert_eq!(servers[0].display_name.as_deref(), Some("Alpha"));
assert_eq!(servers[0].name, "managed_gateway:zeta");
assert_eq!(servers[1].display_name.as_deref(), Some("Zeta"));
}
#[test]
fn patch_server_row_updates_existing() {
let mut servers = vec![
make_row("alpha", McpServerDisplayStatus::Initializing),
make_row("beta", McpServerDisplayStatus::Initializing),
];
let new_tools = vec![
McpToolDetail {
name: "t1".into(),
display_name: None,
description: None,
enabled: true,
},
McpToolDetail {
name: "t2".into(),
display_name: None,
description: Some("two".into()),
enabled: true,
},
];
let mutated = patch_server_row(
&mut servers,
"beta",
McpServerDisplayStatus::Ready,
Some(new_tools),
);
assert!(mutated, "named row must be reported as mutated");
assert_eq!(servers[0].status, McpServerDisplayStatus::Initializing);
assert_eq!(servers[1].status, McpServerDisplayStatus::Ready);
assert_eq!(servers[1].tool_count, 2);
assert_eq!(servers[1].tools.len(), 2);
assert_eq!(servers[1].tools[0].name, "t1");
}
#[test]
fn patch_server_row_noop_when_absent() {
let mut servers = vec![make_row("alpha", McpServerDisplayStatus::Ready)];
let mutated = patch_server_row(
&mut servers,
"ghost",
McpServerDisplayStatus::Unavailable,
None,
);
assert!(!mutated, "missing-name push must be a silent no-op");
// Existing row must be untouched.
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "alpha");
assert_eq!(servers[0].status, McpServerDisplayStatus::Ready);
}
#[test]
fn patch_server_row_status_only_keeps_tools() {
let mut servers = vec![McpServerInfo {
name: "alpha".into(),
display_name: None,
status: McpServerDisplayStatus::Ready,
tool_count: 3,
auth_required: false,
tools: vec![McpToolDetail {
name: "existing".into(),
display_name: None,
description: None,
enabled: true,
}],
enabled: true,
source: "local".into(),
wire_source: McpWireSource::Local,
plugin_name: None,
is_managed_gateway: false,
}];
let mutated = patch_server_row(
&mut servers,
"alpha",
McpServerDisplayStatus::Unavailable,
None,
);
assert!(mutated);
assert_eq!(servers[0].status, McpServerDisplayStatus::Unavailable);
// Tools left untouched when caller passes None.
assert_eq!(servers[0].tool_count, 3);
assert_eq!(servers[0].tools.len(), 1);
assert_eq!(servers[0].tools[0].name, "existing");
}
}
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
//! Screen rendering — each screen type has its own rendering module.
pub mod agent;
pub mod agent_status;
pub mod agents_modal;
pub mod block_viewer;
pub mod btw_overlay;
pub mod completion_dropdown;
pub mod context_bar;
pub mod credit_bar;
pub mod dashboard;
pub mod debug_style;
pub mod extensions_modal;
pub mod file_search;
pub mod fps_hud;
pub mod goal_detail;
pub mod history_search;
pub mod import_claude_modal;
pub mod jump;
pub mod list_pane;
pub mod mcps_modal;
pub mod memory_modal;
pub mod modal;
pub mod modal_window;
pub mod new_worktree_dialog;
pub mod overlay;
pub mod overlay_list;
pub mod permission_view;
pub mod persona_detail;
pub mod picker;
pub mod plan_approval_view;
pub mod progress_bar;
pub mod prompt_suggestion;
pub mod prompt_widget;
pub mod question_view;
pub mod queue_pane;
pub mod rewind;
pub mod scroll_debug_hud;
pub mod session_picker;
pub mod session_title;
pub mod settings_modal;
pub mod shortcuts_bar;
pub mod shortcuts_help;
pub mod slash_dropdown;
pub mod status_bar;
pub mod subagent_catalog_pane;
pub mod suggestion_controller;
pub mod tasks_pane;
pub mod timeline;
pub mod todo_pane;
pub mod turn_status;
pub mod welcome;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,332 @@
//! Popup dialog for creating a new worktree with an optional label.
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::app::app_view::NewWorktreeDialogState;
use crate::theme::Theme;
/// Minimum dialog width (fits title + empty input + hints comfortably).
const MIN_DIALOG_WIDTH: u16 = 50;
const DIALOG_HEIGHT: u16 = 5;
/// Left/right padding inside the border (`inner_x = dialog.x + 2`).
const INNER_PAD: u16 = 4;
const LABEL_PREFIX: &str = "Name (optional): ";
/// Render the new-worktree popup dialog centered on screen.
///
/// The dialog grows with the typed label (up to the available terminal
/// width) so long names stay fully visible. When the terminal itself is
/// too narrow for the full name, the input scrolls to keep the cursor
/// (end of the label) in view, with a leading `…` when scrolled.
pub fn render_new_worktree_dialog(area: Rect, buf: &mut Buffer, state: &NewWorktreeDialogState) {
let theme = Theme::current();
let dialog_width = dialog_width_for(area.width, &state.label_input);
if area.height < DIALOG_HEIGHT || area.width < 20 {
// Too small to render — draw a minimal "resize" hint so the user
// knows the dialog is still active and can press Esc to dismiss.
if area.height >= 1 && area.width >= 16 {
let hint = Line::from(Span::styled(
"[Esc] to close",
Style::default().fg(theme.gray_dim),
));
hint.render(Rect::new(area.x, area.y, area.width.min(16), 1), buf);
}
return;
}
let [_, dialog_h, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(dialog_width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(area);
let [_, dialog, _] = Layout::vertical([
Constraint::Min(0),
Constraint::Length(DIALOG_HEIGHT),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(dialog_h);
// Draw background
let bg_style = Style::default().bg(theme.bg_dark);
for y in dialog.y..dialog.y + dialog.height {
for x in dialog.x..dialog.x + dialog.width {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_char(' ');
cell.set_style(bg_style);
}
}
}
// Draw border
let border_style = Style::default().fg(theme.gray_dim).bg(theme.bg_dark);
// Top border
if let Some(cell) = buf.cell_mut((dialog.x, dialog.y)) {
cell.set_char('\u{256D}');
cell.set_style(border_style);
}
for x in dialog.x + 1..dialog.x + dialog.width - 1 {
if let Some(cell) = buf.cell_mut((x, dialog.y)) {
cell.set_char('\u{2500}');
cell.set_style(border_style);
}
}
if let Some(cell) = buf.cell_mut((dialog.x + dialog.width - 1, dialog.y)) {
cell.set_char('\u{256E}');
cell.set_style(border_style);
}
// Bottom border
let bottom = dialog.y + dialog.height - 1;
if let Some(cell) = buf.cell_mut((dialog.x, bottom)) {
cell.set_char('\u{2570}');
cell.set_style(border_style);
}
for x in dialog.x + 1..dialog.x + dialog.width - 1 {
if let Some(cell) = buf.cell_mut((x, bottom)) {
cell.set_char('\u{2500}');
cell.set_style(border_style);
}
}
if let Some(cell) = buf.cell_mut((dialog.x + dialog.width - 1, bottom)) {
cell.set_char('\u{256F}');
cell.set_style(border_style);
}
// Side borders
for y in dialog.y + 1..dialog.y + dialog.height - 1 {
if let Some(cell) = buf.cell_mut((dialog.x, y)) {
cell.set_char('\u{2502}');
cell.set_style(border_style);
}
if let Some(cell) = buf.cell_mut((dialog.x + dialog.width - 1, y)) {
cell.set_char('\u{2502}');
cell.set_style(border_style);
}
}
let inner_x = dialog.x + 2;
let inner_width = dialog.width.saturating_sub(INNER_PAD);
// Row 1: Title
let title = Line::from(Span::styled(
"New Worktree",
Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD),
));
title.render(Rect::new(inner_x, dialog.y + 1, inner_width, 1), buf);
// Row 2: Label input — grow with content; scroll when still too wide.
let prefix_w = LABEL_PREFIX.width() as u16;
let cursor_w = 1u16;
let input_budget = inner_width
.saturating_sub(prefix_w)
.saturating_sub(cursor_w) as usize;
let visible_input = visible_input_suffix(&state.label_input, input_budget);
let prefix_span = Span::styled(LABEL_PREFIX, Style::default().fg(theme.gray_bright));
let input_span = Span::styled(visible_input, Style::default().fg(theme.text_primary));
let cursor_span = Span::styled("\u{2588}", Style::default().fg(theme.accent_user));
let input_line = Line::from(vec![prefix_span, input_span, cursor_span]);
input_line.render(Rect::new(inner_x, dialog.y + 2, inner_width, 1), buf);
// Row 3: Hints
let hints = Line::from(vec![
Span::styled(
"enter",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" = create ", Style::default().fg(theme.gray)),
Span::styled(
"esc",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" = cancel", Style::default().fg(theme.gray)),
]);
hints.render(Rect::new(inner_x, dialog.y + 3, inner_width, 1), buf);
}
/// Dialog width that fits the typed label, clamped to the available area.
fn dialog_width_for(area_width: u16, label: &str) -> u16 {
let max_width = area_width.saturating_sub(4);
// prefix + label + block cursor + inner pad
let needed = (LABEL_PREFIX.width() + label.width() + 1 + INNER_PAD as usize) as u16;
needed.max(MIN_DIALOG_WIDTH).min(max_width)
}
/// Return the visible portion of `label` for an end-anchored input field.
///
/// When `label` fits in `budget` columns, returns it unchanged. Otherwise
/// returns a leading `…` plus the suffix that fits, so the cursor at the
/// end of the label stays visible while typing a long name.
///
/// Walks Unicode grapheme clusters (not scalar values) so combining marks
/// and ZWJ sequences are never split across the scroll boundary.
fn visible_input_suffix(label: &str, budget: usize) -> String {
if budget == 0 {
return String::new();
}
if label.width() <= budget {
return label.to_string();
}
if budget == 1 {
return "".to_string();
}
let suffix_budget = budget - 1; // reserve one column for leading …
let mut width = 0usize;
let mut start = label.len();
let graphemes: Vec<(usize, &str)> = label.grapheme_indices(true).collect();
for &(i, g) in graphemes.iter().rev() {
let cw = UnicodeWidthStr::width(g);
if width + cw > suffix_budget {
break;
}
width += cw;
start = i;
}
format!("{}", &label[start..])
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
fn render_to_text(area: Rect, label: &str) -> String {
let mut buf = Buffer::empty(area);
let state = NewWorktreeDialogState {
label_input: label.to_string(),
};
render_new_worktree_dialog(area, &mut buf, &state);
let mut lines = Vec::new();
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
row.push_str(buf[(x, y)].symbol());
}
lines.push(row);
}
lines.join("\n")
}
#[test]
fn empty_dialog_uses_minimum_width() {
assert_eq!(dialog_width_for(120, ""), MIN_DIALOG_WIDTH);
assert_eq!(dialog_width_for(40, ""), 36); // area.width - 4
}
#[test]
fn dialog_grows_with_long_label() {
let label = "a-very-long-worktree-name-that-exceeds-fifty";
let width = dialog_width_for(120, label);
assert!(
width > MIN_DIALOG_WIDTH,
"expected dialog wider than min for long label, got {width}"
);
// Full label + chrome must fit inside the grown dialog.
let inner = width.saturating_sub(INNER_PAD) as usize;
let needed = LABEL_PREFIX.width() + label.width() + 1;
assert!(
needed <= inner,
"grown dialog inner={inner} should fit needed={needed}"
);
}
#[test]
fn dialog_clamps_to_terminal_width() {
let label = "x".repeat(100);
let width = dialog_width_for(60, &label);
assert_eq!(width, 56); // 60 - 4
}
#[test]
fn visible_suffix_keeps_end_when_scrolled() {
let label = "abcdefghijklmnopqrstuvwxyz0123456789";
let visible = visible_input_suffix(label, 10);
assert!(
visible.starts_with('…'),
"expected leading ellipsis: {visible}"
);
assert!(
visible.ends_with("0123456789") || visible.ends_with("123456789"),
"expected end of label visible: {visible}"
);
assert_eq!(visible.width(), 10);
}
#[test]
fn visible_suffix_unchanged_when_fits() {
assert_eq!(visible_input_suffix("short", 20), "short");
}
#[test]
fn visible_suffix_does_not_split_grapheme_clusters() {
// "e" + combining acute (U+0301) is one grapheme; pad so we must scroll.
let cluster = "e\u{0301}";
let label = format!("{}{}", "x".repeat(20), cluster);
let visible = visible_input_suffix(&label, 8);
assert!(
visible.starts_with('…'),
"expected leading ellipsis: {visible}"
);
// Either the full cluster is present, or it was dropped as a unit —
// never a lone combining mark after the ellipsis.
let after_ellipsis = &visible[visible.char_indices().nth(1).map(|(i, _)| i).unwrap_or(0)..];
assert!(
!after_ellipsis.starts_with('\u{0301}'),
"must not start scrolled suffix on a combining mark: {visible:?}"
);
if after_ellipsis.contains('e') {
assert!(
after_ellipsis.contains(cluster),
"base 'e' must keep its combining mark: {visible:?}"
);
}
assert!(visible.width() <= 8, "width overflow: {visible:?}");
}
#[test]
fn long_name_fully_visible_on_wide_terminal() {
let area = Rect::new(0, 0, 100, 20);
let label = "biscuit-worktree-popup-long-name-fix";
let text = render_to_text(area, label);
assert!(
text.contains(label),
"full long name must be visible on a wide terminal:\n{text}"
);
assert!(text.contains("New Worktree"), "title missing:\n{text}");
}
#[test]
fn long_name_end_visible_on_narrow_terminal() {
// Terminal narrower than the full label — end (cursor side) must show.
let area = Rect::new(0, 0, 40, 12);
let label = "super-long-worktree-name-that-will-not-fit";
let text = render_to_text(area, label);
let tail = &label[label.len().saturating_sub(8)..];
assert!(
text.contains(tail),
"end of long name must remain visible when scrolled:\n{text}"
);
assert!(
text.contains('…') || text.contains(tail),
"expected scrolled indicator or tail:\n{text}"
);
}
}
@@ -0,0 +1,171 @@
//! Shared overlay pane state machine.
//!
//! [`OverlayState`] encapsulates the three-state visibility/focus/fullscreen
//! logic shared by all toggleable panes (tracing, todo, bg tasks).
//!
//! [`handle_overlay_key`] processes structural keys (Tab, Esc, q, Space,
//! Ctrl-F) consistently across all overlay panes, so each pane only needs
//! to implement its content-specific `handle_key()`.
//!
//! ## State model
//!
//! ```text
//! ┌────────┐ shortcut ┌──────────────────┐ shortcut ┌────────┐
//! │ Hidden │ ─────────► │ Visible + Focused │ ─────────► │ Hidden │
//! └────────┘ └──────────────────┘ └────────┘
//! │ Tab/Space ▲ ▲
//! ▼ │ shortcut │ Esc/q
//! ┌──────────────────┐ │
//! │ Visible+Unfocused│ ───────────────────┘
//! └──────────────────┘ (Esc/q from unfocused
//! shouldn't happen —
//! keys go to agent view)
//! ```
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
/// What the caller should do after an overlay state change.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OverlayAction {
/// No state change — key not consumed.
Ignored,
/// State changed, redraw needed.
Changed,
/// Unfocused → move focus to scrollback.
FocusScrollback,
/// Unfocused → move focus to prompt.
FocusPrompt,
}
impl OverlayAction {
/// Whether this action represents a consumed key event.
pub fn consumed(self) -> bool {
!matches!(self, Self::Ignored)
}
}
/// Shared visibility / focus / fullscreen state for overlay panes.
///
/// Embedded in each toggleable pane (TracingPane, TodoPane, etc.).
/// The pane's shortcut handler calls [`toggle()`], and the shared
/// [`handle_overlay_key()`] handles Tab/Esc/q/Space/Ctrl-F.
#[derive(Debug, Clone, Copy, Default)]
pub struct OverlayState {
pub visible: bool,
pub focused: bool,
pub fullscreen: bool,
}
impl OverlayState {
/// Start visible but not focused (e.g. todo pane that shows when items arrive).
pub fn visible() -> Self {
Self {
visible: true,
focused: false,
fullscreen: false,
}
}
/// Start hidden (e.g. tracing pane).
pub fn hidden() -> Self {
Self::default()
}
/// Pane shortcut: three-state toggle.
///
/// Hidden → show + focus.
/// Visible + unfocused → focus.
/// Visible + focused → hide.
pub fn toggle(&mut self) -> OverlayAction {
if !self.visible {
self.visible = true;
self.focused = true;
} else if !self.focused {
self.focused = true;
} else {
self.visible = false;
self.fullscreen = false;
self.focused = false;
}
OverlayAction::Changed
}
/// Tab: exit fullscreen if active, unfocus, keep visible → scrollback.
pub fn tab_out(&mut self) -> OverlayAction {
self.fullscreen = false;
self.focused = false;
OverlayAction::FocusScrollback
}
/// Esc / q: exit one nesting level.
///
/// Fullscreen → exit fullscreen (stay visible + focused).
/// Non-fullscreen → hide entirely.
pub fn escape(&mut self) -> OverlayAction {
if self.fullscreen {
self.fullscreen = false;
} else {
self.visible = false;
self.focused = false;
}
OverlayAction::Changed
}
/// Space: exit fullscreen if active, unfocus, keep visible → prompt.
pub fn space(&mut self) -> OverlayAction {
self.fullscreen = false;
self.focused = false;
OverlayAction::FocusPrompt
}
/// Ctrl-F: toggle fullscreen.
pub fn toggle_fullscreen(&mut self) -> OverlayAction {
self.fullscreen = !self.fullscreen;
OverlayAction::Changed
}
/// Hide entirely. Used by external callers (e.g. clear on session end).
pub fn hide(&mut self) -> OverlayAction {
self.visible = false;
self.fullscreen = false;
self.focused = false;
OverlayAction::Changed
}
/// Show and focus (e.g. auto-show when items arrive).
pub fn show(&mut self) {
self.visible = true;
}
}
/// Handle structural keys for any focused overlay pane.
///
/// Processes Tab, Esc, q, Space, and Ctrl-F consistently. Returns
/// `Some(action)` if a structural key was consumed, `None` to let the
/// pane's content handler process the key.
///
/// When `has_input_bar` is true, only Ctrl-F is processed (the input
/// bar handles Esc/Tab/etc. itself).
pub fn handle_overlay_key(state: &mut OverlayState, key: &KeyEvent) -> Option<OverlayAction> {
// Ctrl-F: toggle fullscreen (works even with input bar open).
if key.code == KeyCode::Char('f') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Some(state.toggle_fullscreen());
}
None
}
/// Handle structural keys that should only fire when no input bar is open.
///
/// Split from [`handle_overlay_key`] so callers can check `has_input_bar`
/// before calling this.
pub fn handle_overlay_nav_key(state: &mut OverlayState, key: &KeyEvent) -> Option<OverlayAction> {
match key.code {
KeyCode::Tab => Some(state.tab_out()),
KeyCode::Esc => Some(state.escape()),
// Plain 'q' only (Ctrl-Q is the app-level quit shortcut).
KeyCode::Char('q') if key.modifiers == KeyModifiers::NONE => Some(state.escape()),
KeyCode::Char(' ') => Some(state.space()),
_ => None,
}
}
@@ -0,0 +1,221 @@
//! Shared prompt-area list overlay: accent bar, bold title, and a
//! scrollable single-line row list with a cursor.
//!
//! One source of truth for the row geometry that `/rewind`'s picker phase
//! and `/jump` previously each kept in sync by hand across their render,
//! hit-test, and height functions. Row *content* stays with the caller
//! (a closure); this owns chrome, cursor styling, and the scroll window.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use crate::theme::Theme;
/// Rows shown before the list scrolls (matches the historical picker cap).
const MAX_ROWS: usize = 15;
/// List geometry: row count + cursor position. Construct per call; all
/// methods derive the same scroll window from these two fields, so the
/// render, hit-test, and height paths cannot drift.
pub struct ListOverlay {
pub len: usize,
pub selected: usize,
}
/// Per-row style context handed to the row-content closure.
pub struct RowCtx {
pub is_cursor: bool,
/// Resolved row background (cursor rows get the visual-selection bg).
pub row_bg: Color,
/// Width available for the row's content.
pub content_width: u16,
}
impl ListOverlay {
/// Overlay height: title + rows (≤ [`MAX_ROWS`]), capped at 60% of the
/// screen, plus one padding row.
pub fn height(&self, screen_h: u16) -> u16 {
let rows = self.len.min(MAX_ROWS) as u16;
let h = 2 + rows;
let cap = (screen_h as u32 * 60 / 100).max(6) as u16;
h.min(cap) + 1
}
/// Rows that fit in `area` (title + padding excluded).
fn visible_rows(area: Rect) -> usize {
area.height.saturating_sub(3) as usize
}
/// First visible row index (keeps the cursor inside the window).
fn scroll_offset(&self, visible_rows: usize) -> usize {
if visible_rows > 0 && self.selected >= visible_rows {
self.selected - visible_rows + 1
} else {
0
}
}
/// Row index under a screen position, or `None` off the rows.
pub fn row_at(&self, area: Rect, col: u16, row: u16) -> Option<usize> {
if area.height == 0 || area.width < 10 {
return None;
}
if col < area.x || col >= area.x + area.width {
return None;
}
if row < area.y || row >= area.y + area.height {
return None;
}
let first = area.y + 2;
if row < first {
return None;
}
let visible_rows = Self::visible_rows(area);
let rel = (row - first) as usize;
if rel >= visible_rows {
return None;
}
let idx = self.scroll_offset(visible_rows) + rel;
(idx < self.len).then_some(idx)
}
/// Render the overlay: bg fill, accent bar, title, then the visible
/// window of rows. `row_line(idx, ctx)` produces each row's content;
/// cursor/row backgrounds are painted here. Applies the standard
/// unfocus dim, so callers must not blend again.
pub fn render(
&self,
buf: &mut Buffer,
area: Rect,
title: &str,
focused: bool,
mut row_line: impl FnMut(usize, &RowCtx) -> Line<'static>,
) {
if area.height == 0 || area.width < 10 {
return;
}
let theme = Theme::current();
let bg = theme.bg_light;
buf.set_style(area, Style::default().bg(bg));
let accent_style = Style::default().fg(theme.accent_user);
for row in area.y..area.y + area.height {
if let Some(cell) = buf.cell_mut((area.x, row)) {
cell.set_symbol(crate::glyphs::accent_bar());
cell.set_style(accent_style);
}
}
let content_x = area.x + 3;
let content_w = area.width.saturating_sub(5);
let title_style = Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD);
let mut y = area.y + 1;
buf.set_line(
content_x,
y,
&Line::from(Span::styled(title.to_string(), title_style)),
content_w,
);
y += 1;
let visible_rows = Self::visible_rows(area);
let scroll_offset = self.scroll_offset(visible_rows);
for i in (scroll_offset..self.len).take(visible_rows) {
if y >= area.y + area.height {
break;
}
let is_cursor = i == self.selected;
let row_bg = if is_cursor && focused {
theme.bg_visual
} else {
bg
};
let row_rect = Rect {
x: content_x.saturating_sub(1),
y,
width: content_w + 2,
height: 1,
};
buf.set_style(row_rect, Style::default().bg(row_bg));
let ctx = RowCtx {
is_cursor,
row_bg,
content_width: content_w,
};
let line = row_line(i, &ctx);
buf.set_line(content_x, y, &line, content_w);
y += 1;
}
// Unfocus dim: blend foregrounds toward the panel bg so the overlay
// recedes when the prompt area is unfocused (prompt_widget pattern).
if !focused {
crate::render::color::blend_area(buf, area, Some((bg, 0.66)), None);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn area() -> Rect {
Rect {
x: 0,
y: 0,
width: 40,
height: 10,
}
}
#[test]
fn row_at_maps_rows_and_rejects_chrome() {
let list = ListOverlay {
len: 3,
selected: 0,
};
// Title at y+1; rows start at y+2.
assert_eq!(list.row_at(area(), 5, 1), None);
assert_eq!(list.row_at(area(), 5, 2), Some(0));
assert_eq!(list.row_at(area(), 5, 4), Some(2));
// Past the last row.
assert_eq!(list.row_at(area(), 5, 5), None);
// Outside horizontally.
assert_eq!(list.row_at(area(), 99, 2), None);
}
#[test]
fn row_at_respects_scroll_window() {
// 20 rows, 7 visible (height 10 - 3), cursor at the end: the window
// starts at 13 so the cursor stays visible.
let list = ListOverlay {
len: 20,
selected: 19,
};
assert_eq!(list.row_at(area(), 5, 2), Some(13));
assert_eq!(list.row_at(area(), 5, 8), Some(19));
}
#[test]
fn height_caps_at_max_rows_and_screen_fraction() {
let two = ListOverlay {
len: 2,
selected: 0,
};
assert_eq!(two.height(40), 5); // title + 2 rows + padding
let many = ListOverlay {
len: 30,
selected: 0,
};
assert_eq!(many.height(40), 18); // 15-row cap
assert_eq!(many.height(12), 8); // 60% screen cap
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,898 @@
//! Persona detail/edit modal — structured view of a persona with inline editing.
//!
//! Opened by pressing Enter on a persona in the `/config-agents` Personas tab.
//! Renders all persona TOML fields in labeled sections. Editable personas
//! (user/project scope) support inline field editing; bundled personas are
//! read-only.
use std::path::{Path, PathBuf};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use unicode_width::UnicodeWidthStr;
use crate::theme::Theme;
use crate::views::modal_window::{
self, ModalContentArea, ModalSizing, ModalWindowConfig, ModalWindowState, Shortcut,
};
// ---------------------------------------------------------------------------
// Field enum
// ---------------------------------------------------------------------------
/// Navigable fields in the persona detail view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PersonaField {
Name,
Description,
Model,
ReasoningEffort,
Isolation,
Instructions,
InstructionsFile,
}
impl PersonaField {
const ALL: &[PersonaField] = &[
PersonaField::Name,
PersonaField::Description,
PersonaField::Model,
PersonaField::ReasoningEffort,
PersonaField::Isolation,
PersonaField::Instructions,
PersonaField::InstructionsFile,
];
fn label(self) -> &'static str {
match self {
Self::Name => "Name",
Self::Description => "Description",
Self::Model => "Model",
Self::ReasoningEffort => "Effort",
Self::Isolation => "Isolation",
Self::Instructions => "Instructions",
Self::InstructionsFile => "Instr. file",
}
}
fn next(self) -> Self {
let idx = Self::ALL.iter().position(|&f| f == self).unwrap_or(0);
Self::ALL[(idx + 1) % Self::ALL.len()]
}
fn prev(self) -> Self {
let idx = Self::ALL.iter().position(|&f| f == self).unwrap_or(0);
Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
}
/// True for fields that support inline text editing.
fn is_editable(self) -> bool {
matches!(
self,
Self::Name | Self::Description | Self::Model | Self::ReasoningEffort | Self::Isolation
)
}
}
// ---------------------------------------------------------------------------
// Mode state machine
// ---------------------------------------------------------------------------
#[derive(Debug)]
pub enum PersonaDetailMode {
Browse,
Editing {
field: PersonaField,
buffer: String,
cursor: usize,
original: String,
},
}
// ---------------------------------------------------------------------------
// Outcome
// ---------------------------------------------------------------------------
#[derive(Debug)]
pub enum PersonaDetailOutcome {
/// Normal handled event.
Changed,
/// Nothing to do.
Unchanged,
/// Close the detail modal, return to the list.
Close,
/// Open the file in $EDITOR.
EditInEditor { path: PathBuf },
}
// ---------------------------------------------------------------------------
// I/O entry
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct PersonaIOEntry {
pub name: String,
pub io_type: String,
pub required: bool,
pub description: String,
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
pub struct PersonaDetailState {
pub window: ModalWindowState,
pub name: String,
pub description: String,
pub model: String,
pub reasoning_effort: String,
pub default_isolation: String,
pub instructions: String,
pub instructions_file: String,
pub inputs: Vec<PersonaIOEntry>,
pub outputs: Vec<PersonaIOEntry>,
pub source_path: Option<PathBuf>,
pub editable: bool,
pub scope_label: String,
pub selected_field: PersonaField,
pub scroll_offset: usize,
pub mode: PersonaDetailMode,
pub dirty: bool,
pub instructions_expanded: bool,
/// Scroll offset within expanded instructions (line index of first visible line).
pub instructions_scroll: usize,
pub message: Option<String>,
}
impl PersonaDetailState {
/// Load persona state from a TOML file on disk.
pub fn from_toml_file(path: &Path, editable: bool, scope_label: &str) -> Option<Self> {
let content = std::fs::read_to_string(path).ok()?;
let table: toml::Value = toml::from_str(&content).ok()?;
let get_str = |key: &str| -> String {
table
.get(key)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned()
};
let parse_io = |key: &str| -> Vec<PersonaIOEntry> {
table
.get(key)
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.map(|item| PersonaIOEntry {
name: item
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_owned(),
io_type: item
.get("io_type")
.and_then(|v| v.as_str())
.unwrap_or("file")
.to_owned(),
required: item
.get("required")
.and_then(|v| v.as_bool())
.unwrap_or(false),
description: item
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned(),
})
.collect()
})
.unwrap_or_default()
};
let name_from_file = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_owned();
Some(Self {
window: ModalWindowState::new(),
name: {
let n = get_str("name");
if n.is_empty() { name_from_file } else { n }
},
description: get_str("description"),
model: get_str("model"),
reasoning_effort: get_str("reasoning_effort"),
default_isolation: get_str("default_isolation"),
instructions: get_str("instructions"),
instructions_file: get_str("instructions_file"),
inputs: parse_io("inputs"),
outputs: parse_io("outputs"),
source_path: Some(path.to_path_buf()),
editable,
scope_label: scope_label.to_owned(),
selected_field: PersonaField::Name,
scroll_offset: 0,
mode: PersonaDetailMode::Browse,
dirty: false,
instructions_expanded: false,
instructions_scroll: 0,
message: None,
})
}
/// Create a minimal detail state for personas with no file on disk.
pub fn from_name_only(name: &str) -> Self {
Self {
window: ModalWindowState::new(),
name: name.to_owned(),
description: String::new(),
model: String::new(),
reasoning_effort: String::new(),
default_isolation: String::new(),
instructions: String::new(),
instructions_file: String::new(),
inputs: Vec::new(),
outputs: Vec::new(),
source_path: None,
editable: false,
scope_label: "bundled".to_owned(),
selected_field: PersonaField::Name,
scroll_offset: 0,
mode: PersonaDetailMode::Browse,
dirty: false,
instructions_expanded: false,
instructions_scroll: 0,
message: None,
}
}
fn field_value(&self, field: PersonaField) -> &str {
match field {
PersonaField::Name => &self.name,
PersonaField::Description => &self.description,
PersonaField::Model => &self.model,
PersonaField::ReasoningEffort => &self.reasoning_effort,
PersonaField::Isolation => &self.default_isolation,
PersonaField::Instructions => &self.instructions,
PersonaField::InstructionsFile => &self.instructions_file,
}
}
fn set_field_value(&mut self, field: PersonaField, value: String) {
match field {
PersonaField::Name => self.name = value,
PersonaField::Description => self.description = value,
PersonaField::Model => self.model = value,
PersonaField::ReasoningEffort => self.reasoning_effort = value,
PersonaField::Isolation => self.default_isolation = value,
PersonaField::Instructions => self.instructions = value,
PersonaField::InstructionsFile => self.instructions_file = value,
}
}
/// Save current state back to the TOML file using toml_edit to preserve formatting.
fn save_to_file(&self) -> Result<(), String> {
let Some(ref path) = self.source_path else {
return Err("No source file to save to".to_string());
};
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {e}"))?;
let mut doc: toml_edit::DocumentMut = content
.parse()
.map_err(|e| format!("Failed to parse TOML: {e}"))?;
// Update simple string fields.
let fields: &[(&str, &str)] = &[
("name", &self.name),
("description", &self.description),
("instructions", &self.instructions),
("instructions_file", &self.instructions_file),
("model", &self.model),
("reasoning_effort", &self.reasoning_effort),
("default_isolation", &self.default_isolation),
];
for &(key, value) in fields {
if value.is_empty() {
doc.remove(key);
} else {
doc[key] = toml_edit::value(value);
}
}
std::fs::write(path, doc.to_string()).map_err(|e| format!("Failed to write file: {e}"))?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Render the persona detail modal.
pub fn render_persona_detail(
buf: &mut Buffer,
area: Rect,
state: &mut PersonaDetailState,
theme: &Theme,
compact: bool,
) {
let title = format!("persona: {}", state.name);
let shortcuts = build_shortcuts(state);
let config = ModalWindowConfig {
title: &title,
tabs: None,
shortcuts: &shortcuts,
sizing: persona_detail_sizing(compact),
fold_info: None,
};
let Some(ModalContentArea {
content: content_area,
..
}) = modal_window::render_modal_window(buf, area, &mut state.window, &config, theme)
else {
return;
};
let w = content_area.width as usize;
let mut y = content_area.y;
let max_y = content_area.y + content_area.height;
let label_w = 14u16; // column width for field labels
// Message line
if let Some(ref msg) = state.message
&& y < max_y
{
buf.set_string(
content_area.x,
y,
msg,
Style::default().fg(theme.accent_error),
);
y += 2;
}
// Render each field row.
for &field in PersonaField::ALL {
if y >= max_y {
break;
}
let is_selected = state.selected_field == field;
let label = field.label();
let value = state.field_value(field);
// Background highlight for selected row.
let row_bg = if is_selected {
Some(theme.bg_highlight)
} else {
None
};
// Label
let label_style = if is_selected {
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.gray)
};
if let Some(bg) = row_bg {
// Fill the row background.
let blank: String = " ".repeat(w);
buf.set_string(content_area.x, y, &blank, Style::default().bg(bg));
}
buf.set_string(content_area.x, y, label, label_style);
let value_x = content_area.x + label_w;
let value_w = w.saturating_sub(label_w as usize);
// Check if we're in editing mode for this field.
if is_selected
&& let PersonaDetailMode::Editing {
ref buffer, cursor, ..
} = state.mode
{
// Render inline editor.
let display: String = buffer.chars().take(value_w).collect();
let field_style = if let Some(bg) = row_bg {
Style::default().fg(theme.text_primary).bg(bg)
} else {
Style::default().fg(theme.text_primary)
};
buf.set_string(value_x, y, &display, field_style);
// Cursor
let cursor_x = value_x + buffer[..cursor.min(buffer.len())].width() as u16;
if cursor_x < content_area.x + content_area.width
&& let Some(cell) = buf.cell_mut((cursor_x, y))
{
cell.set_style(Style::default().fg(theme.bg_base).bg(theme.text_primary));
}
} else if field == PersonaField::Instructions {
// Multi-line instructions with expand/collapse and scroll.
if value.is_empty() {
let empty_style = if let Some(bg) = row_bg {
Style::default().fg(theme.gray_dim).bg(bg)
} else {
Style::default().fg(theme.gray_dim)
};
buf.set_string(value_x, y, "(empty)", empty_style);
} else {
let lines = word_wrap_lines(value, value_w);
let total = lines.len();
let max_collapsed = 8usize;
let is_long = total > max_collapsed;
// Reserve 1 line for the hint at the bottom.
let avail_lines = (max_y.saturating_sub(y)) as usize;
let viewport_h = if is_long {
avail_lines.saturating_sub(1) // room for hint
} else {
avail_lines
};
let val_style = if let Some(bg) = row_bg {
Style::default().fg(theme.text_secondary).bg(bg)
} else {
Style::default().fg(theme.text_secondary)
};
if !state.instructions_expanded {
// Collapsed: show first max_collapsed lines (no scroll).
let show = total.min(max_collapsed).min(viewport_h);
for (i, line) in lines.iter().enumerate().take(show) {
let x_pos = if i == 0 { value_x } else { content_area.x + 2 };
buf.set_string(x_pos, y + i as u16, line, val_style);
}
y += show.saturating_sub(1) as u16;
if is_long {
y += 1;
if y < max_y {
let hint = format!(
" ... ({} more lines \u{2014} e to expand, j/k to scroll)",
total - max_collapsed
);
buf.set_string(
content_area.x + 2,
y,
hint,
Style::default().fg(theme.gray_dim),
);
}
}
} else {
// Expanded: viewport with scroll offset.
let scroll = state
.instructions_scroll
.min(total.saturating_sub(viewport_h));
state.instructions_scroll = scroll;
let visible = &lines[scroll..total.min(scroll + viewport_h)];
for (i, line) in visible.iter().enumerate() {
let x_pos = if i == 0 && scroll == 0 {
value_x
} else {
content_area.x + 2
};
buf.set_string(x_pos, y + i as u16, line, val_style);
}
y += visible.len().saturating_sub(1) as u16;
// Hint line.
y += 1;
if y < max_y {
let pos_hint = if total > viewport_h {
format!(
" [{}\u{2013}{}/ {}]",
scroll + 1,
(scroll + viewport_h).min(total),
total
)
} else {
String::new()
};
let hint = format!(" (e to collapse, j/k to scroll{})", pos_hint);
buf.set_string(
content_area.x + 2,
y,
hint,
Style::default().fg(theme.gray_dim),
);
}
}
}
} else if value.is_empty() {
let empty_style = if let Some(bg) = row_bg {
Style::default().fg(theme.gray_dim).bg(bg)
} else {
Style::default().fg(theme.gray_dim)
};
buf.set_string(value_x, y, "\u{2014}", empty_style);
} else if value.width() <= value_w {
// Fits on one line.
let val_style = if let Some(bg) = row_bg {
Style::default().fg(theme.text_primary).bg(bg)
} else {
Style::default().fg(theme.text_primary)
};
buf.set_string(value_x, y, value, val_style);
} else {
// Word-wrap long values.
let val_style = if let Some(bg) = row_bg {
Style::default().fg(theme.text_primary).bg(bg)
} else {
Style::default().fg(theme.text_primary)
};
let lines = word_wrap_lines(value, value_w);
for (i, line) in lines.iter().enumerate() {
if y + i as u16 >= max_y {
break;
}
let x_pos = if i == 0 {
value_x
} else {
content_area.x + label_w
};
buf.set_string(x_pos, y + i as u16, line, val_style);
}
y += lines.len().saturating_sub(1) as u16;
}
y += 2; // spacing between fields
}
// I/O sections
for (section, items) in [("Inputs", &state.inputs), ("Outputs", &state.outputs)] {
if items.is_empty() || y >= max_y {
continue;
}
buf.set_string(
content_area.x,
y,
section,
Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD),
);
y += 1;
for entry in items {
if y >= max_y {
break;
}
let req = if entry.required { ", required" } else { "" };
let header = format!(" \u{2022} {} ({}{})", entry.name, entry.io_type, req);
buf.set_string(
content_area.x,
y,
&header,
Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD),
);
if !entry.description.is_empty() {
// Wrap the description across multiple lines below the header.
let indent = 4usize;
let desc_w = w.saturating_sub(indent);
if desc_w > 0 {
y += 1;
for desc_line in word_wrap_lines(&entry.description, desc_w) {
if y >= max_y {
break;
}
let padded = format!("{:indent$}{desc_line}", "", indent = indent);
buf.set_string(
content_area.x,
y,
&padded,
Style::default().fg(theme.text_secondary),
);
y += 1;
}
} else {
y += 1;
}
} else {
y += 1;
}
}
y += 1;
}
// Source path
if y < max_y
&& let Some(ref path) = state.source_path
{
let src = format!("Source: {}", path.display());
let truncated: String = src.chars().take(w).collect();
buf.set_string(
content_area.x,
y,
&truncated,
Style::default().fg(theme.gray_dim),
);
}
}
fn persona_detail_sizing(compact: bool) -> ModalSizing {
ModalSizing {
width_pct: 0.70,
max_width: 100,
min_width: 44,
v_margin: 4,
h_pad: 2,
v_pad: 1,
footer_lines: 2,
}
.with_compact(compact)
}
fn build_shortcuts(state: &PersonaDetailState) -> Vec<Shortcut<'static>> {
if matches!(state.mode, PersonaDetailMode::Editing { .. }) {
vec![
Shortcut {
label: "Enter save",
clickable: false,
id: 0,
},
Shortcut {
label: "Esc cancel",
clickable: false,
id: 0,
},
]
} else {
let mut shortcuts = vec![Shortcut {
label: "j/k nav",
clickable: false,
id: 0,
}];
if state.editable {
shortcuts.push(Shortcut {
label: "e edit field",
clickable: false,
id: 0,
});
}
if state.source_path.is_some() && state.editable {
shortcuts.push(Shortcut {
label: "i $EDITOR",
clickable: false,
id: 0,
});
}
shortcuts.push(Shortcut {
label: "Esc back",
clickable: false,
id: 0,
});
shortcuts
}
}
// ---------------------------------------------------------------------------
// Input handling
// ---------------------------------------------------------------------------
pub fn handle_persona_detail_key(
state: &mut PersonaDetailState,
key: &KeyEvent,
) -> PersonaDetailOutcome {
state.message = None;
match &state.mode {
PersonaDetailMode::Editing { .. } => handle_editing_key(state, key),
PersonaDetailMode::Browse => handle_browse_key(state, key),
}
}
fn handle_browse_key(state: &mut PersonaDetailState, key: &KeyEvent) -> PersonaDetailOutcome {
// When instructions are expanded and selected, j/k scrolls within them.
let instr_scrolling =
state.selected_field == PersonaField::Instructions && state.instructions_expanded;
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
// If instructions are expanded, collapse first instead of closing.
if instr_scrolling {
state.instructions_expanded = false;
state.instructions_scroll = 0;
return PersonaDetailOutcome::Changed;
}
PersonaDetailOutcome::Close
}
KeyCode::Char('j') | KeyCode::Down if instr_scrolling => {
state.instructions_scroll = state.instructions_scroll.saturating_add(1);
PersonaDetailOutcome::Changed
}
KeyCode::Char('k') | KeyCode::Up if instr_scrolling => {
state.instructions_scroll = state.instructions_scroll.saturating_sub(1);
PersonaDetailOutcome::Changed
}
KeyCode::Char('j') | KeyCode::Down => {
state.selected_field = state.selected_field.next();
PersonaDetailOutcome::Changed
}
// Instructions: e/Enter toggles expand/collapse.
KeyCode::Char('e') | KeyCode::Enter
if state.selected_field == PersonaField::Instructions =>
{
state.instructions_expanded = !state.instructions_expanded;
state.instructions_scroll = 0;
PersonaDetailOutcome::Changed
}
KeyCode::Char('k') | KeyCode::Up => {
state.selected_field = state.selected_field.prev();
PersonaDetailOutcome::Changed
}
// Other fields: e/Enter opens inline editor.
KeyCode::Char('e') | KeyCode::Enter => {
if !state.editable {
state.message = Some("Bundled personas are read-only".to_string());
return PersonaDetailOutcome::Changed;
}
let field = state.selected_field;
if !field.is_editable() {
state.message = Some("This field cannot be edited inline".to_string());
return PersonaDetailOutcome::Changed;
}
let current = state.field_value(field).to_owned();
state.mode = PersonaDetailMode::Editing {
field,
cursor: current.len(),
original: current.clone(),
buffer: current,
};
PersonaDetailOutcome::Changed
}
KeyCode::Char('i') => {
if let Some(ref path) = state.source_path {
if state.editable {
return PersonaDetailOutcome::EditInEditor { path: path.clone() };
}
state.message = Some("Bundled personas are read-only".to_string());
} else {
state.message = Some("No source file".to_string());
}
PersonaDetailOutcome::Changed
}
_ => PersonaDetailOutcome::Unchanged,
}
}
fn handle_editing_key(state: &mut PersonaDetailState, key: &KeyEvent) -> PersonaDetailOutcome {
let PersonaDetailMode::Editing {
field,
ref mut buffer,
ref mut cursor,
ref original,
} = state.mode
else {
return PersonaDetailOutcome::Unchanged;
};
match key.code {
KeyCode::Esc => {
// Cancel — restore original.
state.mode = PersonaDetailMode::Browse;
PersonaDetailOutcome::Changed
}
KeyCode::Enter => {
// Save the edit.
let new_value = buffer.clone();
let changed = new_value != *original;
state.set_field_value(field, new_value);
state.mode = PersonaDetailMode::Browse;
if changed {
state.dirty = true;
if let Err(e) = state.save_to_file() {
state.message = Some(format!("Save failed: {e}"));
} else {
state.message = Some("Saved".to_string());
}
}
PersonaDetailOutcome::Changed
}
KeyCode::Backspace => {
if *cursor > 0 {
let prev = buffer[..*cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
buffer.remove(prev);
*cursor = prev;
}
PersonaDetailOutcome::Changed
}
KeyCode::Left => {
if *cursor > 0 {
let prev = buffer[..*cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
*cursor = prev;
}
PersonaDetailOutcome::Changed
}
KeyCode::Right => {
if *cursor < buffer.len() {
let next = buffer[*cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| *cursor + i)
.unwrap_or(buffer.len());
*cursor = next;
}
PersonaDetailOutcome::Changed
}
KeyCode::Home => {
*cursor = 0;
PersonaDetailOutcome::Changed
}
KeyCode::End => {
*cursor = buffer.len();
PersonaDetailOutcome::Changed
}
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
|| crate::input::key::is_altgr(key.modifiers) =>
{
buffer.insert(*cursor, c);
*cursor += c.len_utf8();
PersonaDetailOutcome::Changed
}
_ => PersonaDetailOutcome::Unchanged,
}
}
pub fn handle_persona_detail_mouse(
state: &mut PersonaDetailState,
mouse: &MouseEvent,
) -> PersonaDetailOutcome {
let chrome =
modal_window::handle_modal_mouse(&mut state.window, mouse.kind, mouse.column, mouse.row);
match chrome {
modal_window::ModalWindowOutcome::CloseRequested => PersonaDetailOutcome::Close,
modal_window::ModalWindowOutcome::Handled => PersonaDetailOutcome::Changed,
_ => PersonaDetailOutcome::Unchanged,
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn word_wrap_lines(text: &str, max_width: usize) -> Vec<String> {
let mut lines = Vec::new();
for raw_line in text.lines() {
if raw_line.width() <= max_width {
lines.push(raw_line.to_string());
} else {
let mut current = String::new();
for word in raw_line.split_whitespace() {
if current.is_empty() {
current = word.to_string();
} else if current.width() + 1 + word.width() <= max_width {
current.push(' ');
current.push_str(word);
} else {
lines.push(current);
current = word.to_string();
}
}
if !current.is_empty() {
lines.push(current);
}
}
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,490 @@
use agent_client_protocol as acp;
use kigi_acp_lib::AcpResult;
pub use kigi_tools::implementations::grok_build::exit_plan_mode::{
ExitPlanModeExtRequest, ExitPlanModeExtResponse,
};
use crate::views::prompt_widget::StashedPrompt;
/// Placeholder body for the plan-approval preview when `exit_plan_mode` parks
/// with no plan content (missing/empty `plan.md`, or a whitespace-only body).
///
/// Must be non-empty after trim so `LineViewerState::open_markdown_content`
/// accepts it — empty bodies are rejected there.
pub const EMPTY_PLAN_PLACEHOLDER: &str = "\
# No plan written yet
The agent exited plan mode without writing a plan.
- **Approve** leave plan mode and start implementing
- **Request changes** send the agent back to planning
- **Quit** abandon and turn plan mode off
";
/// Status-line label while plan approval is parked.
///
/// Empty plans use an active decision prompt instead of "Waiting…", so the
/// UI doesn't look stuck when there is no preview body to open.
pub fn plan_approval_status_label(has_plan: bool) -> &'static str {
if has_plan {
"Waiting on plan approval"
} else {
"No plan written — approve or request changes"
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanApprovalFocus {
Preview,
Prompt,
Commenting,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanReviewSource {
Inline,
FileBacked,
}
#[derive(Debug, Clone)]
pub struct PlanComment {
pub id: u64,
pub line_range: std::ops::Range<usize>,
pub text: String,
}
pub struct PlanApprovalViewState {
pub tool_call_id: String,
pub has_plan: bool,
pub plan_content: Option<String>,
pub source: PlanReviewSource,
pub stashed_prompt: StashedPrompt,
pub response_tx: Option<tokio::sync::oneshot::Sender<AcpResult<acp::ExtResponse>>>,
pub focus: PlanApprovalFocus,
pub comments: Vec<PlanComment>,
pub next_comment_id: u64,
pub editing_comment_id: Option<u64>,
pub commenting_range: Option<std::ops::Range<usize>>,
pub stashed_feedback_prompt: Option<StashedPrompt>,
}
impl PlanApprovalViewState {
pub fn new(
request: ExitPlanModeExtRequest,
stashed_prompt: StashedPrompt,
response_tx: tokio::sync::oneshot::Sender<AcpResult<acp::ExtResponse>>,
) -> Self {
Self::with_source(
request,
PlanReviewSource::Inline,
stashed_prompt,
response_tx,
)
}
pub fn with_source(
request: ExitPlanModeExtRequest,
source: PlanReviewSource,
stashed_prompt: StashedPrompt,
response_tx: tokio::sync::oneshot::Sender<AcpResult<acp::ExtResponse>>,
) -> Self {
let plan_content = request.plan_content.filter(|s| !s.trim().is_empty());
let has_plan = plan_content.is_some();
Self {
tool_call_id: request.tool_call_id,
has_plan,
plan_content,
source,
stashed_prompt,
response_tx: Some(response_tx),
focus: PlanApprovalFocus::Preview,
comments: Vec::new(),
next_comment_id: 0,
editing_comment_id: None,
commenting_range: None,
stashed_feedback_prompt: None,
}
}
pub fn format_feedback(&self, freeform: Option<&str>) -> String {
let mut parts: Vec<String> = self
.comments
.iter()
.map(|comment| match self.source {
PlanReviewSource::Inline => {
let label = if comment.line_range.len() == 1 {
format!("Proposed plan line {}:", comment.line_range.start)
} else {
format!(
"Proposed plan lines {}-{}:",
comment.line_range.start,
comment.line_range.end - 1
)
};
let snippets =
inline_plan_snippets(self.plan_content.as_deref(), &comment.line_range);
format!("{label}\n{snippets}\n\nComment:\n{}", comment.text)
}
PlanReviewSource::FileBacked => format_file_backed_plan_comment(comment),
})
.collect();
if let Some(text) = freeform
&& !text.trim().is_empty()
{
let text = match (self.source, self.comments.is_empty()) {
(PlanReviewSource::Inline, false) => format!("Additional feedback:\n{text}"),
_ => text.to_owned(),
};
parts.push(text);
}
parts.join("\n\n")
}
}
pub fn send_exit_plan_response(
tx: tokio::sync::oneshot::Sender<AcpResult<acp::ExtResponse>>,
outcome: &str,
feedback: Option<String>,
) {
let feedback = feedback.filter(|f| !f.trim().is_empty());
let resp = ExitPlanModeExtResponse {
outcome: outcome.into(),
feedback,
};
let raw = serde_json::value::to_raw_value(&resp)
.expect("ExitPlanModeExtResponse serialization should not fail");
tx.send(Ok(acp::ExtResponse::new(raw.into()))).ok();
}
fn send_ext_response(
tx: &mut Option<tokio::sync::oneshot::Sender<AcpResult<acp::ExtResponse>>>,
outcome: &str,
feedback: Option<String>,
) -> bool {
let Some(tx) = tx.take() else {
return false;
};
send_exit_plan_response(tx, outcome, feedback);
true
}
impl PlanApprovalViewState {
pub fn send_approved(&mut self) -> bool {
send_ext_response(&mut self.response_tx, "approved", None)
}
pub fn send_abandoned(&mut self) -> bool {
send_ext_response(&mut self.response_tx, "abandoned", None)
}
pub fn send_cancelled(&mut self, feedback: Option<String>) -> bool {
send_ext_response(&mut self.response_tx, "cancelled", feedback)
}
pub fn send_stale_cancel(&mut self) -> bool {
self.send_cancelled(None)
}
}
fn format_file_backed_plan_comment(comment: &PlanComment) -> String {
let range = if comment.line_range.len() == 1 {
format!("@plan.md:{}", comment.line_range.start)
} else {
format!(
"@plan.md:{}-{}",
comment.line_range.start,
comment.line_range.end - 1
)
};
format!("{range}\n{}", comment.text)
}
pub(crate) fn inline_plan_snippets(
plan_content: Option<&str>,
range: &std::ops::Range<usize>,
) -> String {
let Some(plan_content) = plan_content else {
return "> [plan content unavailable]".to_owned();
};
let lines: Vec<&str> = plan_content.lines().collect();
if range.start == 0 || range.start >= range.end || range.start > lines.len() {
return "> [selected lines unavailable]".to_owned();
}
let end = range.end.saturating_sub(1).min(lines.len());
if end < range.start {
return "> [selected lines unavailable]".to_owned();
}
lines[range.start - 1..end]
.iter()
.map(|line| format!("> {line}"))
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn format_plan_comments(comments: &[PlanComment], plan_content: Option<&str>) -> String {
comments
.iter()
.map(|comment| {
let label = if comment.line_range.len() == 1 {
format!("Proposed plan line {}:", comment.line_range.start)
} else {
format!(
"Proposed plan lines {}-{}:",
comment.line_range.start,
comment.line_range.end - 1
)
};
let snippets = inline_plan_snippets(plan_content, &comment.line_range);
format!("{label}\n{snippets}\n\nComment:\n{}", comment.text)
})
.collect::<Vec<_>>()
.join("\n\n")
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_state() -> (
PlanApprovalViewState,
tokio::sync::oneshot::Receiver<AcpResult<acp::ExtResponse>>,
) {
let (tx, rx) = tokio::sync::oneshot::channel();
let request = ExitPlanModeExtRequest {
session_id: "test-session".into(),
tool_call_id: "call_123".into(),
plan_content: Some("# Plan\n\n## Step 1\nDo something".into()),
};
let state = PlanApprovalViewState::new(
request,
StashedPrompt {
text: "stashed text".into(),
cursor: 0,
images: Vec::new(),
chip_elements: Vec::new(),
image_counter: 0,
image_undo_stash: Vec::new(),
},
tx,
);
(state, rx)
}
#[test]
fn test_send_approved() {
let (mut state, mut rx) = make_test_state();
assert!(state.send_approved());
let resp = rx.try_recv().expect("should receive response");
let raw = resp.expect("should be Ok");
let parsed: serde_json::Value =
serde_json::from_str(raw.0.get()).expect("should be valid JSON");
assert_eq!(parsed["outcome"], "approved");
assert!(parsed.get("feedback").is_none());
}
#[test]
fn test_send_cancelled_with_feedback() {
let (mut state, mut rx) = make_test_state();
assert!(state.send_cancelled(Some("fix auth flow".into())));
let resp = rx.try_recv().expect("should receive response");
let raw = resp.expect("should be Ok");
let parsed: serde_json::Value =
serde_json::from_str(raw.0.get()).expect("should be valid JSON");
assert_eq!(parsed["outcome"], "cancelled");
assert_eq!(parsed["feedback"], "fix auth flow");
}
#[test]
fn test_send_cancelled_without_feedback() {
let (mut state, mut rx) = make_test_state();
assert!(state.send_cancelled(None));
let resp = rx.try_recv().expect("should receive response");
let raw = resp.expect("should be Ok");
let parsed: serde_json::Value =
serde_json::from_str(raw.0.get()).expect("should be valid JSON");
assert_eq!(parsed["outcome"], "cancelled");
assert!(parsed.get("feedback").is_none());
}
#[test]
fn test_send_cancelled_empty_feedback_is_none() {
let (mut state, mut rx) = make_test_state();
assert!(state.send_cancelled(Some(" ".into())));
let resp = rx.try_recv().expect("should receive response");
let raw = resp.expect("should be Ok");
let parsed: serde_json::Value =
serde_json::from_str(raw.0.get()).expect("should be valid JSON");
assert_eq!(parsed["outcome"], "cancelled");
assert!(parsed.get("feedback").is_none());
}
#[test]
fn test_send_stale_cancel() {
let (mut state, mut rx) = make_test_state();
assert!(state.send_stale_cancel());
let resp = rx.try_recv().expect("should receive response");
let raw = resp.expect("should be Ok");
let parsed: serde_json::Value =
serde_json::from_str(raw.0.get()).expect("should be valid JSON");
assert_eq!(parsed["outcome"], "cancelled");
assert!(parsed.get("feedback").is_none());
}
#[test]
fn test_double_send_returns_false() {
let (mut state, _rx) = make_test_state();
assert!(state.send_approved());
assert!(!state.send_approved());
assert!(!state.send_cancelled(None));
}
#[test]
fn test_constructor_defaults() {
let (state, _rx) = make_test_state();
assert_eq!(state.tool_call_id, "call_123");
assert!(state.has_plan);
assert_eq!(
state.plan_content.as_deref(),
Some("# Plan\n\n## Step 1\nDo something")
);
assert_eq!(state.source, PlanReviewSource::Inline);
assert_eq!(state.stashed_prompt.text, "stashed text");
assert!(state.response_tx.is_some());
assert_eq!(state.focus, PlanApprovalFocus::Preview);
assert!(state.comments.is_empty());
assert_eq!(state.next_comment_id, 0);
assert!(state.editing_comment_id.is_none());
assert!(state.commenting_range.is_none());
assert!(state.stashed_feedback_prompt.is_none());
}
fn make_empty_plan_state() -> (
PlanApprovalViewState,
tokio::sync::oneshot::Receiver<AcpResult<acp::ExtResponse>>,
) {
let (tx, rx) = tokio::sync::oneshot::channel();
let request = ExitPlanModeExtRequest {
session_id: "test-session".into(),
tool_call_id: "call_456".into(),
plan_content: None,
};
let state = PlanApprovalViewState::new(
request,
StashedPrompt {
text: "stashed".into(),
cursor: 0,
images: Vec::new(),
chip_elements: Vec::new(),
image_counter: 0,
image_undo_stash: Vec::new(),
},
tx,
);
(state, rx)
}
#[test]
fn test_empty_plan_has_plan_false() {
let (state, _rx) = make_empty_plan_state();
assert!(!state.has_plan);
assert!(state.plan_content.is_none());
}
#[test]
fn plan_approval_status_label_distinguishes_empty() {
assert_eq!(plan_approval_status_label(true), "Waiting on plan approval");
assert_eq!(
plan_approval_status_label(false),
"No plan written — approve or request changes"
);
// Placeholder must be non-empty so the line viewer accepts it.
assert!(!EMPTY_PLAN_PLACEHOLDER.trim().is_empty());
}
#[test]
fn test_empty_plan_whitespace_only() {
let (tx, _rx) = tokio::sync::oneshot::channel();
let request = ExitPlanModeExtRequest {
session_id: "test-session".into(),
tool_call_id: "call_789".into(),
plan_content: Some(" \n\n ".into()),
};
let state = PlanApprovalViewState::new(
request,
StashedPrompt {
text: "stashed".into(),
cursor: 0,
images: Vec::new(),
chip_elements: Vec::new(),
image_counter: 0,
image_undo_stash: Vec::new(),
},
tx,
);
assert!(!state.has_plan);
assert!(state.plan_content.is_none());
}
#[test]
fn inline_plan_feedback_quotes_selected_line_snippets() {
let (mut state, _rx) = make_test_state();
state.plan_content = Some("alpha\nbravo\ncharlie\ndelta".into());
state.comments.push(PlanComment {
id: 0,
line_range: 2..3,
text: "rewrite this".into(),
});
state.comments.push(PlanComment {
id: 1,
line_range: 3..5,
text: "combine these".into(),
});
let feedback = state.format_feedback(Some("overall note"));
assert_eq!(
feedback,
"Proposed plan line 2:\n> bravo\n\nComment:\nrewrite this\n\nProposed plan lines 3-4:\n> charlie\n> delta\n\nComment:\ncombine these\n\nAdditional feedback:\noverall note"
);
}
#[test]
fn inline_plan_feedback_handles_out_of_range_lines() {
let (mut state, _rx) = make_test_state();
state.plan_content = Some("alpha".into());
state.comments.push(PlanComment {
id: 0,
line_range: 9..10,
text: "where is this".into(),
});
assert_eq!(
state.format_feedback(None),
"Proposed plan line 9:\n> [selected lines unavailable]\n\nComment:\nwhere is this"
);
}
#[test]
fn file_backed_plan_feedback_keeps_plan_md_references() {
let (mut state, _rx) = make_test_state();
state.source = PlanReviewSource::FileBacked;
state.plan_content = Some("alpha\nbravo".into());
state.comments.push(PlanComment {
id: 0,
line_range: 1..3,
text: "keep file ref".into(),
});
assert_eq!(
state.format_feedback(Some("freeform")),
"@plan.md:1-2\nkeep file ref\n\nfreeform"
);
}
}
@@ -0,0 +1,170 @@
//! Unicode block progress bar at 1/8th-cell resolution via the LEFT
//! fractional blocks `▏▎▍▌▋▊▉█`.
//!
//! Consolas (the default ConHost font) is missing the narrow ones
//! (U+258F..=U+2589) — see microsoft/terminal#387 — so on legacy
//! ConHost we substitute the shade glyphs `░▒▓` from CP437 instead.
//! Same eighth-resolution input; the cell just reads as a density
//! pattern rather than a true left-justified bar.
//!
//! ```ignore
//! render_progress_bar(buf, x, y, 5, 0.42, fg_color, bg_color);
//! ```
use ratatui::buffer::Buffer;
use ratatui::style::{Color, Style};
use ratatui::text::Span;
/// LEFT-fractional block glyphs, indexed 08 (0 = empty, 8 = full).
const BLOCKS: [&str; 9] = ["", "", "", "", "", "", "", "", ""];
/// Shade substitutes used on hosts that can't render the LEFT-fractional
/// blocks. Same index domain as [`BLOCKS`] so call sites stay uniform.
const SHADES: [&str; 9] = ["", "", "", "", "", "", "", "", ""];
/// Per-cell partial-fill glyph table — `BLOCKS` everywhere except legacy
/// ConHost, where we substitute `SHADES`.
fn partial_blocks() -> &'static [&'static str; 9] {
if crate::glyphs::is_legacy_windows_console() {
&SHADES
} else {
&BLOCKS
}
}
/// Split a fill fraction into (whole cells, remainder eighths).
fn cell_breakdown(width: u16, value: f32) -> (u16, usize) {
let value = value.clamp(0.0, 1.0);
let total_eighths = (value * width as f32 * 8.0).round() as u16;
let full = (total_eighths / 8).min(width);
let remainder = (total_eighths % 8) as usize;
(full, remainder)
}
/// Per-cell `(symbol, is_filled)` for a `width`-cell bar at `value`
/// fill. Single source of truth for both renderers below.
fn bar_cells(width: u16, value: f32) -> impl Iterator<Item = (&'static str, /* filled */ bool)> {
let (full, remainder) = cell_breakdown(width, value);
let glyphs = partial_blocks();
(0..width).map(move |i| {
if i < full {
(glyphs[8], true)
} else if i == full && remainder > 0 {
(glyphs[remainder], true)
} else {
(" ", false)
}
})
}
/// Build a progress bar as styled spans (one per cell).
///
/// Each span has `fg` on `bg`, suitable for composing into a `Line`.
pub fn progress_bar_spans(width: u16, value: f32, fg: Color, bg: Color) -> Vec<Span<'static>> {
let fg_style = Style::default().fg(fg).bg(bg);
let bg_style = Style::default().bg(bg);
bar_cells(width, value)
.map(|(symbol, filled)| {
let style = if filled { fg_style } else { bg_style };
Span::styled(symbol.to_string(), style)
})
.collect()
}
/// Render a progress bar into the buffer at the given position.
///
/// - `width`: number of character cells for the bar
/// - `value`: fill fraction in `0.0..=1.0` (clamped)
/// - `fg`: color for the filled portion
/// - `bg`: background color for the track (filled + empty cells)
pub fn render_progress_bar(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
value: f32,
fg: Color,
bg: Color,
) {
let fg_style = Style::default().fg(fg).bg(bg);
let bg_style = Style::default().bg(bg);
for (i, (symbol, filled)) in bar_cells(width, value).enumerate() {
let Some(cell) = buf.cell_mut((x + i as u16, y)) else {
continue;
};
cell.set_symbol(symbol);
cell.set_style(if filled { fg_style } else { bg_style });
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::layout::Rect;
#[test]
fn test_empty_bar() {
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
render_progress_bar(&mut buf, 0, 0, 5, 0.0, Color::White, Color::Black);
for i in 0..5u16 {
assert_eq!(buf[(i, 0)].symbol(), " ");
}
}
#[test]
fn test_full_bar() {
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
render_progress_bar(&mut buf, 0, 0, 5, 1.0, Color::White, Color::Black);
for i in 0..5u16 {
assert_eq!(buf[(i, 0)].symbol(), "");
}
}
#[test]
fn test_half_bar() {
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
render_progress_bar(&mut buf, 0, 0, 4, 0.5, Color::White, Color::Black);
// 50% of 4 cells = 2 full blocks
assert_eq!(buf[(0, 0)].symbol(), "");
assert_eq!(buf[(1, 0)].symbol(), "");
assert_eq!(buf[(2, 0)].symbol(), " ");
assert_eq!(buf[(3, 0)].symbol(), " ");
}
#[test]
fn test_partial_block() {
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
// 25% of 4 cells = 1 full block (8 eighths). Actually 0.25*4*8 = 8 = 1 full.
// Let's use 12.5% of 4 cells = 0.125*4*8 = 4 eighths = half block on cell 0
render_progress_bar(&mut buf, 0, 0, 4, 0.125, Color::White, Color::Black);
assert_eq!(buf[(0, 0)].symbol(), ""); // 4/8 = half
assert_eq!(buf[(1, 0)].symbol(), " ");
}
#[test]
fn cell_breakdown_keeps_eighths_resolution() {
// 0.5 * 4 * 8 = 16 eighths → 2 full + 0 remainder.
assert_eq!(cell_breakdown(4, 0.5), (2, 0));
// 0.125 * 4 * 8 = 4 eighths → 0 full + 4 remainder.
assert_eq!(cell_breakdown(4, 0.125), (0, 4));
// 0.03 * 5 * 8 = 1.2 → rounds to 1 eighth → 0 full + 1 remainder.
// On legacy that picks SHADES[1] = "░"; on truecolor it picks
// BLOCKS[1] = "▏". Either way ~3% does NOT light a full cell.
assert_eq!(cell_breakdown(5, 0.03), (0, 1));
// Out-of-range clamped.
assert_eq!(cell_breakdown(4, 2.0), (4, 0));
}
#[test]
fn shades_and_blocks_tables_match_in_length() {
// The two glyph tables must share the same index domain so call
// sites can swap them without branching on the host.
assert_eq!(BLOCKS.len(), SHADES.len());
assert_eq!(BLOCKS[0], SHADES[0]); // both empty
assert_eq!(BLOCKS[8], SHADES[8]); // both full block
}
}
@@ -0,0 +1,290 @@
//! Next-prompt suggestion controller (tab autocomplete ghost text).
//!
//! After a turn completes, the pager asks the shell (`suggestPrompt`)
//! to predict the user's likely next prompt. The prediction renders as dim
//! ghost text in the (empty) prompt input:
//!
//! - **Tab** or **Right arrow** accepts it (the ghost only shows with the
//! cursor at end-of-text, where Right is otherwise a no-op — the fish/zsh
//! autosuggestion convention).
//! - Typing a matching prefix *shrinks* the ghost; typing it out fully
//! consumes it; any divergent text hides it (it comes back if the user
//! clears the input, matching common agent-CLI autosuggest behavior).
//! - **Esc** on an empty prompt dismisses it for the rest of the turn.
//!
//! Visibility is *derived* from the current prompt text each frame
//! ([`PromptSuggestionController::ghost_for`]) rather than mutated on each
//! keystroke — there is no per-keystroke state machine to drift. Stale
//! responses are discarded via a generation counter, mirroring
//! `SuggestionController` (shell command suggestions).
/// Env override for the whole feature: `KIGI_PROMPT_SUGGESTIONS=0/1`.
/// When unset, the persisted `prompt_suggestions` setting applies.
pub const PROMPT_SUGGESTIONS_ENV: &str = "KIGI_PROMPT_SUGGESTIONS";
/// Env override for the model used by the suggestion call:
/// `KIGI_PROMPT_SUGGESTIONS_MODEL=<model-id>`.
pub const PROMPT_SUGGESTIONS_MODEL_ENV: &str = "KIGI_PROMPT_SUGGESTIONS_MODEL";
/// Preferred model for suggestion calls when the server catalog offers it
/// (cheap + fast). The session model is never used: when this is absent
/// from the catalog the request carries no model hint and the shell
/// resolves (or skips) it — see [`resolve_model`].
pub const PREFERRED_SUGGESTION_MODEL: &str = "grok-build-0.1";
/// Controller for the predicted-next-prompt ghost text.
#[derive(Debug, Default)]
pub struct PromptSuggestionController {
/// Full suggestion text from the model. Empty = no suggestion.
full_text: String,
/// Request generation counter; responses carrying a stale generation are
/// discarded (a newer turn ended, or the suggestion was invalidated).
generation: u64,
/// Set when the user dismissed the current suggestion (Esc). Cleared by
/// the next loaded suggestion.
dismissed: bool,
/// Whether the feature is enabled. Resolved via
/// `KIGI_PROMPT_SUGGESTIONS` env var, falling back to the persisted
/// `prompt_suggestions` setting.
pub enabled: bool,
}
impl PromptSuggestionController {
pub fn new() -> Self {
Self {
full_text: String::new(),
generation: 0,
dismissed: false,
enabled: resolve_enabled(),
}
}
/// Begin a new fetch: invalidates any in-flight request and returns the
/// generation to thread through the effect pipeline.
pub fn begin_fetch(&mut self) -> u64 {
self.generation = self.generation.wrapping_add(1);
self.generation
}
/// A suggestion arrived from the shell. Discards stale generations and
/// empty payloads. Returns `true` when the suggestion was installed.
pub fn on_loaded(&mut self, suggestion: Option<String>, generation: u64) -> bool {
if generation != self.generation {
return false;
}
match suggestion {
Some(text) if !text.trim().is_empty() && !text.contains('\n') => {
self.full_text = text;
self.dismissed = false;
true
}
_ => {
self.full_text.clear();
false
}
}
}
/// The ghost text to render for the current prompt text, if any.
///
/// Derived: the suggestion is visible iff the current text is a proper
/// prefix of it (including the empty prompt). Typing matching characters
/// shrinks the ghost; typing it out fully (or diverging) hides it;
/// clearing the input brings the full suggestion back.
pub fn ghost_for(&self, text: &str) -> Option<&str> {
if !self.enabled || self.dismissed || self.full_text.is_empty() {
return None;
}
let rest = self.full_text.strip_prefix(text)?;
if rest.is_empty() { None } else { Some(rest) }
}
/// Accept the suggestion against the current prompt text. Returns the
/// remainder to insert and clears the suggestion.
pub fn accept(&mut self, text: &str) -> Option<String> {
let rest = self.ghost_for(text)?.to_owned();
self.clear();
Some(rest)
}
/// Dismiss the current suggestion (Esc) until a new one loads.
pub fn dismiss(&mut self) {
self.dismissed = true;
}
/// Drop the suggestion and invalidate any in-flight fetch (turn started,
/// prompt sent, session switched...).
pub fn clear(&mut self) {
self.full_text.clear();
self.generation = self.generation.wrapping_add(1);
}
/// Whether a (non-dismissed) suggestion is loaded, regardless of the
/// current prompt text.
pub fn has_suggestion(&self) -> bool {
self.enabled && !self.dismissed && !self.full_text.is_empty()
}
#[cfg(test)]
pub(crate) fn set_suggestion_for_test(&mut self, text: &str) {
self.enabled = true;
self.dismissed = false;
self.full_text = text.to_owned();
}
}
/// Resolve the enabled state: env override wins, then the persisted
/// `prompt_suggestions` setting (default on). The env var is read once per
/// process; the setting is a thread-local cache, so this is cheap enough for
/// per-frame calls.
pub fn resolve_enabled() -> bool {
static ENV_OVERRIDE: std::sync::OnceLock<Option<bool>> = std::sync::OnceLock::new();
ENV_OVERRIDE
.get_or_init(|| kigi_config::env_bool(PROMPT_SUGGESTIONS_ENV))
.unwrap_or_else(crate::appearance::cache::load_prompt_suggestions)
}
/// Content-free size metadata for acceptance-rate telemetry: `(chars, words)`
/// of the full suggestion text. Never log the text itself.
pub fn suggestion_size(text: &str) -> (usize, usize) {
(text.chars().count(), text.split_whitespace().count())
}
/// Resolve the client-side model hint sent with the suggestion request:
/// env override > `grok-build-0.1` when the catalog offers it > `None`.
///
/// The hint is one tier of the shell-side resolution (env > config.toml >
/// remote settings > this hint > `grok-build-0.1` default): the shell
/// catalog-guards the effective model and skips the request entirely when
/// it is not sampleable — the session model is never used for suggestion
/// calls.
pub fn resolve_model(models: &crate::acp::model_state::ModelState) -> Option<String> {
if let Ok(model) = std::env::var(PROMPT_SUGGESTIONS_MODEL_ENV)
&& !model.trim().is_empty()
{
return Some(model);
}
let preferred =
agent_client_protocol::ModelId::new(std::sync::Arc::from(PREFERRED_SUGGESTION_MODEL));
models
.available
.contains_key(&preferred)
.then(|| PREFERRED_SUGGESTION_MODEL.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
fn loaded_controller(text: &str) -> PromptSuggestionController {
let mut c = PromptSuggestionController {
enabled: true,
..Default::default()
};
let generation = c.begin_fetch();
assert!(c.on_loaded(Some(text.to_owned()), generation));
c
}
#[test]
fn ghost_shows_full_suggestion_on_empty_prompt() {
let c = loaded_controller("run the tests");
assert_eq!(c.ghost_for(""), Some("run the tests"));
}
#[test]
fn ghost_shrinks_as_matching_prefix_is_typed() {
let c = loaded_controller("run the tests");
assert_eq!(c.ghost_for("r"), Some("un the tests"));
assert_eq!(c.ghost_for("run the"), Some(" tests"));
}
#[test]
fn ghost_disappears_when_typed_out_fully() {
let c = loaded_controller("run the tests");
assert_eq!(c.ghost_for("run the tests"), None);
}
#[test]
fn ghost_hides_on_divergent_text_and_returns_on_clear() {
let c = loaded_controller("run the tests");
assert_eq!(c.ghost_for("x"), None);
assert_eq!(c.ghost_for("rux"), None);
// Clearing the input brings the suggestion back.
assert_eq!(c.ghost_for(""), Some("run the tests"));
}
#[test]
fn accept_returns_remainder_and_clears() {
let mut c = loaded_controller("run the tests");
assert_eq!(c.accept("run ").as_deref(), Some("the tests"));
assert!(!c.has_suggestion());
assert_eq!(c.ghost_for(""), None);
}
#[test]
fn accept_on_divergent_text_returns_none() {
let mut c = loaded_controller("run the tests");
assert_eq!(c.accept("xyz"), None);
// Suggestion intact for when the input is cleared.
assert!(c.has_suggestion());
}
#[test]
fn dismiss_hides_until_next_load() {
let mut c = loaded_controller("run the tests");
c.dismiss();
assert_eq!(c.ghost_for(""), None);
assert!(!c.has_suggestion());
let generation = c.begin_fetch();
assert!(c.on_loaded(Some("commit this".to_owned()), generation));
assert_eq!(c.ghost_for(""), Some("commit this"));
}
#[test]
fn stale_generation_is_discarded() {
let mut c = PromptSuggestionController {
enabled: true,
..Default::default()
};
let stale = c.begin_fetch();
let _newer = c.begin_fetch();
assert!(!c.on_loaded(Some("old".to_owned()), stale));
assert_eq!(c.ghost_for(""), None);
}
#[test]
fn clear_invalidates_in_flight_fetch() {
let mut c = PromptSuggestionController {
enabled: true,
..Default::default()
};
let generation = c.begin_fetch();
c.clear();
assert!(!c.on_loaded(Some("late".to_owned()), generation));
assert!(!c.has_suggestion());
}
#[test]
fn empty_or_multiline_suggestions_are_rejected() {
let mut c = PromptSuggestionController {
enabled: true,
..Default::default()
};
let generation = c.begin_fetch();
assert!(!c.on_loaded(Some(" ".to_owned()), generation));
let generation = c.begin_fetch();
assert!(!c.on_loaded(Some("a\nb".to_owned()), generation));
let generation = c.begin_fetch();
assert!(!c.on_loaded(None, generation));
}
#[test]
fn disabled_controller_shows_nothing() {
let mut c = loaded_controller("run the tests");
c.enabled = false;
assert_eq!(c.ghost_for(""), None);
assert!(!c.has_suggestion());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,244 @@
//! Scroll-diagnostics HUD — the in-pager "scroll playground".
//!
//! A compact top-right overlay painting a per-frame snapshot of the scroll
//! state machine ([`MouseScrollState::debug_snapshot`]) plus the active
//! scrollback's viewport facts, inside a REAL session with the REAL event
//! loop. Recipe: `KIGI_FPS=1 KIGI_SCROLL_DEBUG=1 grok --resume <session>`,
//! then flip `scroll_mode` / `scroll_lines` / `invert_scroll` /
//! `scroll_speed` in `/settings` to compare variants live. For event-exact
//! capture beyond this per-frame sampling, add `KIGI_SCROLL_LOG=1` — the
//! JSONL flight recorder ([`crate::input::scroll_log`]).
//!
//! Invariant: the HUD must never affect scroll behavior. The snapshot is
//! read-only (`&self`, caller-supplied `now`), taken in the draw path after
//! all input/tick state updates for the frame, and rendering only paints
//! buffer cells. Disabled cost is a single bool check per frame.
//!
//! Unlike the FPS overlay (`render::frame_metrics`, debug/dev builds only), this
//! compiles into release builds behind its runtime gate (the hidden-command
//! precedent, e.g. `/gboom`): dev instrumentation alters the frame pipeline
//! (phase timings through `draw_frame`), so a dev-only HUD could not probe
//! the production render path — defeating the zero-fidelity-gap goal — and
//! the pty e2e suite runs against production-featured binaries.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use crate::input::mouse::ScrollDebugSnapshot;
/// Panel width in cells; each line is padded/truncated to this.
const PANEL_WIDTH: u16 = 46;
/// Runtime enablement for the HUD. Mirrors `FrameMetrics`' env machinery:
/// `KIGI_SCROLL_DEBUG` (nonempty and not `"0"`) enables at startup, and the
/// hidden `/scroll-debug` command toggles it live. Deliberately NOT a
/// settings-registry entry: it is a diagnostic, not a preference to persist.
pub struct ScrollDebugHud {
enabled: bool,
}
impl Default for ScrollDebugHud {
fn default() -> Self {
Self::new()
}
}
impl ScrollDebugHud {
pub fn new() -> Self {
let env_on = std::env::var("KIGI_SCROLL_DEBUG").is_ok_and(|v| !v.is_empty() && v != "0");
Self { enabled: env_on }
}
/// Whether the HUD is currently enabled.
pub fn enabled(&self) -> bool {
self.enabled
}
/// `/scroll-debug` runtime toggle.
pub fn toggle(&mut self) {
self.enabled = !self.enabled;
}
}
/// Scrollback-side facts for the `view:` row (`None` off agent views).
#[derive(Clone, Copy, Debug)]
pub struct ViewportDebug {
pub scroll_offset: usize,
pub max_offset: usize,
pub total_height: usize,
pub follow_mode: bool,
pub at_bottom: bool,
}
/// Owned per-frame render params, assembled by `AppView::draw` BEFORE the
/// frame closure (after all scroll-state updates; borrow-splitting keeps the
/// closure free of `self.scroll_state`).
pub struct ScrollDebugPanel {
pub snapshot: ScrollDebugSnapshot,
pub view: Option<ViewportDebug>,
/// Rows left free for FPS overlays stacked above (the dev `KIGI_FPS`
/// line and/or the release-safe `/debug fps` HUD).
pub top_offset: u16,
}
impl ScrollDebugPanel {
/// Paint the panel in the top-right corner of `area`. Per-frame
/// formatting is fine for a debug tool; nothing here outlives the frame.
pub fn render(&self, area: Rect, buf: &mut Buffer) {
let s = &self.snapshot;
let yn = |b: bool| if b { "y" } else { "n" };
let ctx = crate::terminal::terminal_context();
let mut lines: Vec<String> = Vec::with_capacity(10);
lines.push("scroll debug (/scroll-debug)".to_string());
lines.push(format!("term:{} mux:{}", ctx.brand, ctx.multiplexer));
lines.push(format!(
"mode:{} inv:{} speed:x{:.2}",
s.mode.label(),
yn(s.invert),
s.speed_multiplier
));
lines.push(format!(
"ept:{} lpt:{}/{} vp:{} cap:{} cad:{}ms",
s.events_per_tick,
s.wheel_lines_per_tick,
s.trackpad_lines_per_tick,
s.viewport_height,
s.flush_cap,
s.cadence_ms
));
match &s.stream {
Some(st) => {
lines.push(format!(
"stream:live kind:{}{} ev:{}",
st.kind,
if st.promoted { "*" } else { "" },
st.events
));
lines.push(format!(
"avg:{} accel:x{:.2} gap:{}ms",
st.avg_interval_ms
.map_or_else(|| "-".to_string(), |ms| format!("{ms:.1}ms")),
st.accel,
st.gap_remaining_ms
));
lines.push(format!(
"desired:{:+.1} applied:{:+} backlog:{:+}",
st.desired_lines, st.applied_lines, st.backlog
));
}
None => {
lines.push("stream:- kind:- ev:-".to_string());
lines.push("avg:- accel:- gap:-".to_string());
lines.push("desired:- applied:- backlog:-".to_string());
}
}
lines.push(format!(
"carry:{:+.2} flush:{}ms clock:{}",
s.carry_lines,
s.ms_since_flush,
s.next_deadline_ms
.map_or_else(|| "-".to_string(), |ms| format!("{ms}ms")),
));
lines.push(s.last_stream.as_ref().map_or_else(
|| "last:-".to_string(),
|l| format!("last:{} ev:{} ln:{:+}", l.kind, l.events, l.applied_lines),
));
if let Some(v) = &self.view {
lines.push(format!(
"view:{}/{} h:{} follow:{} bot:{}",
v.scroll_offset,
v.max_offset,
v.total_height,
yn(v.follow_mode),
yn(v.at_bottom)
));
}
let line_refs: Vec<&str> = lines.iter().map(String::as_str).collect();
super::debug_style::render_panel(area, buf, self.top_offset, PANEL_WIDTH, &line_refs);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::{Color, Modifier, Style};
fn snapshot() -> ScrollDebugSnapshot {
ScrollDebugSnapshot {
stream: None,
last_stream: None,
carry_lines: 0.0,
ms_since_flush: 0,
next_deadline_ms: None,
mode: crate::input::mouse::ScrollInputMode::Auto,
events_per_tick: 3,
wheel_lines_per_tick: 3,
trackpad_lines_per_tick: 1,
invert: false,
speed_multiplier: 1.0,
viewport_height: 40,
flush_cap: 120,
cadence_ms: 16,
}
}
/// The Oscura Midnight regression: the panel must paint the explicit
/// debug chrome — bg black, white/yellow fg, no inherited modifiers —
/// on EVERY cell of its rect, trailing padding included, regardless of
/// the themed cells underneath.
#[test]
fn panel_paints_theme_agnostic_style_over_every_cell() {
let area = Rect::new(0, 0, 60, 14);
let mut buf = Buffer::empty(area);
// Mimic a themed frame: near-black RGB bg (Oscura Midnight base is
// #030304), tinted fg, and a modifier on every cell — everything
// the overlay must override.
let theme = Style::default()
.fg(Color::Rgb(228, 228, 228))
.bg(Color::Rgb(3, 3, 4))
.add_modifier(Modifier::ITALIC);
buf.set_style(area, theme);
let panel = ScrollDebugPanel {
snapshot: snapshot(),
view: Some(ViewportDebug {
scroll_offset: 5,
max_offset: 10,
total_height: 50,
follow_mode: true,
at_bottom: false,
}),
top_offset: 0,
};
panel.render(area, &mut buf);
// 10 lines with a `view:` row; the panel hugs the right edge.
let x0 = area.width - PANEL_WIDTH;
for y in 0..10u16 {
for x in x0..area.width {
let cell = &buf[(x, y)];
assert_eq!(
cell.bg,
Color::Black,
"cell ({x},{y}) bg must be explicit black, got {:?}",
cell.bg
);
assert!(
cell.fg == Color::White || cell.fg == Color::Yellow,
"cell ({x},{y}) fg must be debug chrome, got {:?}",
cell.fg
);
assert_eq!(
cell.modifier,
Modifier::empty(),
"cell ({x},{y}) must shed themed modifiers"
);
}
}
// The overlay is rect-scoped: cells outside it keep the theme.
assert_eq!(buf[(0, 0)].bg, Color::Rgb(3, 3, 4));
assert_eq!(buf[(x0 - 1, 3)].modifier, Modifier::ITALIC);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,274 @@
//! Session display-title helpers shared by the dashboard and other surfaces.
//!
//! Title derivation order ([`entry_title`]):
//! 1. `AgentView::display_name` if set (post-rename),
//! 2. else `AgentView::generated_session_title` (LLM title or from disk on resume),
//! 3. else the trimmed first ~60 chars of the first user-prompt block in scrollback,
//! 4. else `"session abc12345"` (or `"loading..."` when no session id
//! is established yet).
use std::borrow::Cow;
use std::time::Duration;
use crate::app::agent_view::AgentView;
use crate::scrollback::block::RenderBlock;
/// Maximum characters of a derived first-prompt title.
const MAX_TITLE_CHARS: usize = 60;
/// Derive the display title for an agent (rename > generated title > first-prompt > id).
///
/// Centralised so every surface that shows a session name agrees on the same
/// precedence. Trimming and truncation happen in this single place to avoid drift.
pub fn entry_title(agent: &AgentView) -> String {
if let Some(name) = agent.display_name.as_deref() {
let trimmed = name.trim();
if !trimmed.is_empty() {
return truncate_title(&sanitize_display_text(trimmed));
}
}
if let Some(title) = agent.generated_session_title.as_deref() {
let trimmed = title.trim();
if !trimmed.is_empty() {
let clean =
kigi_tools::implementations::skills::skill::extract_skill_display_text(trimmed);
let text = clean.as_deref().unwrap_or(trimmed);
return truncate_title(&sanitize_display_text(text));
}
}
if let Some(text) = first_user_prompt_text(agent) {
let trimmed = text.trim();
if !trimmed.is_empty() {
let clean =
kigi_tools::implementations::skills::skill::extract_skill_display_text(trimmed);
let display = clean.as_deref().unwrap_or(trimmed);
return truncate_title(&sanitize_display_text(display));
}
}
match agent.session.session_id.as_ref() {
Some(sid) => {
let short: String = sid.0.chars().take(8).collect();
format!("session {short}")
}
None => "loading...".to_string(),
}
}
/// Take the first scrollback `UserPrompt` block's text, if any.
///
/// Skips indices whose `entry()` returns `None` (defensive: the indexed
/// range matches `scrollback.len()` so this should not happen in
/// practice) instead of bailing out with `?`, which would conflate
/// "no UserPrompt anywhere" with "hit an unexpected gap mid-scan".
fn first_user_prompt_text(agent: &AgentView) -> Option<String> {
for i in 0..agent.scrollback.len() {
if let Some(entry) = agent.scrollback.entry(i)
&& let RenderBlock::UserPrompt(block) = &entry.block
{
return Some(block.text.clone());
}
}
None
}
/// Take the first `MAX_TITLE_CHARS` chars and append an ellipsis when
/// truncated. Char-based (not byte-based) so multi-byte codepoints
/// don't get split.
fn truncate_title(text: &str) -> String {
if text.chars().count() <= MAX_TITLE_CHARS {
return text.to_string();
}
let head: String = text.chars().take(MAX_TITLE_CHARS).collect();
format!("{head}...")
}
/// Strip ASCII control characters (`0x00-0x1f` and `0x7f`) that could
/// inject terminal escape sequences (CSI, OSC, BEL, etc.) into the
/// rendered output. Replaces stripped chars with `U+FFFD` so the caller
/// can still see something was there.
///
/// Returns `Cow::Borrowed(s)` when no sanitization is needed, so the
/// common per-render call on a clean cached display_name does not
/// allocate.
pub(crate) fn sanitize_display_text(s: &str) -> Cow<'_, str> {
if s.chars().any(|c| c.is_ascii_control()) {
Cow::Owned(
s.chars()
.map(|c| if c.is_ascii_control() { '\u{FFFD}' } else { c })
.collect(),
)
} else {
Cow::Borrowed(s)
}
}
/// Format an elapsed duration as a compact relative label (`now`, `30s ago`,
/// `5m ago`, `2h ago`, `3d ago`). Shared by the dashboard and project picker.
pub(crate) fn format_relative_time(elapsed: Duration) -> String {
let secs = elapsed.as_secs();
if secs < 1 {
return "now".to_string();
}
if secs < 60 {
return format!("{secs}s ago");
}
let mins = secs / 60;
if mins < 60 {
return format!("{mins}m ago");
}
let hours = mins / 60;
if hours < 24 {
return format!("{hours}h ago");
}
let days = hours / 24;
format!("{days}d ago")
}
#[cfg(test)]
mod tests {
use super::*;
// ── sanitize_display_text ───────────────────────────────────────
#[test]
fn sanitize_passes_through_clean_ascii_unchanged_no_alloc() {
let s = "session foo bar";
let out = sanitize_display_text(s);
assert_eq!(out.as_ref(), s);
assert!(matches!(out, Cow::Borrowed(_)));
}
#[test]
fn sanitize_passes_through_unicode_widechars() {
for s in ["セッション one", "session 🦀 two", "naïve"] {
let out = sanitize_display_text(s);
assert_eq!(out.as_ref(), s);
assert!(matches!(out, Cow::Borrowed(_)), "input={s:?}");
}
}
#[test]
fn sanitize_strips_osc_escape_sequence() {
// Attack: OSC title-set + clear screen.
let attack = "\x1b]0;PWNED\x07\x1b[2J safe text";
let out = sanitize_display_text(attack);
assert!(matches!(out, Cow::Owned(_)));
for c in out.chars() {
assert!(!c.is_ascii_control(), "leaked control char: {:?}", c);
}
// Replacement chars should be present where escapes were.
assert!(out.contains('\u{FFFD}'));
assert!(out.ends_with(" safe text"));
}
#[test]
fn sanitize_strips_csi_sequence() {
let csi = "\x1b[31mred\x1b[0m";
let out = sanitize_display_text(csi);
for c in out.chars() {
assert!(!c.is_ascii_control());
}
}
#[test]
fn sanitize_strips_bel_and_del() {
let s = "ring\x07the\x7fbell";
let out = sanitize_display_text(s);
assert_eq!(out.as_ref(), "ring\u{FFFD}the\u{FFFD}bell");
}
#[test]
fn sanitize_strips_tab_newline_carriage_return() {
// Tabs and newlines are also ASCII controls -- a single-line
// rename input should never contain them, so strip all.
let s = "a\tb\nc\rd";
let out = sanitize_display_text(s);
assert_eq!(out.as_ref(), "a\u{FFFD}b\u{FFFD}c\u{FFFD}d");
}
#[test]
fn sanitize_empty_returns_empty_borrowed() {
let out = sanitize_display_text("");
assert_eq!(out.as_ref(), "");
assert!(matches!(out, Cow::Borrowed(_)));
}
// ── truncate_title ──────────────────────────────────────────────
#[test]
fn truncate_title_keeps_short_strings() {
assert_eq!(truncate_title("hello"), "hello");
}
#[test]
fn truncate_title_appends_ellipsis_when_too_long() {
let long = "x".repeat(MAX_TITLE_CHARS + 5);
let out = truncate_title(&long);
assert!(out.ends_with("..."));
assert_eq!(out.chars().count(), MAX_TITLE_CHARS + 3);
}
#[test]
fn truncate_title_handles_multibyte_codepoints_safely() {
// Each "é" (U+00E9) is one char (two bytes); ensure char-based
// truncation does not split a multibyte codepoint mid-byte.
// This does NOT exercise grapheme-cluster handling -- a
// decomposed sequence (e + U+0301) would split at the
// codepoint boundary today; that's a separate concern.
let s: String = std::iter::repeat_n('é', MAX_TITLE_CHARS + 2).collect();
let out = truncate_title(&s);
assert!(out.ends_with("..."));
assert_eq!(out.chars().count(), MAX_TITLE_CHARS + 3);
}
// ── format_relative_time ────────────────────────────────────────
#[test]
fn format_relative_time_sub_second_is_now() {
assert_eq!(format_relative_time(Duration::from_millis(0)), "now");
assert_eq!(format_relative_time(Duration::from_millis(500)), "now");
assert_eq!(format_relative_time(Duration::from_millis(999)), "now");
}
#[test]
fn format_relative_time_seconds() {
assert_eq!(format_relative_time(Duration::from_secs(1)), "1s ago");
assert_eq!(format_relative_time(Duration::from_secs(30)), "30s ago");
assert_eq!(format_relative_time(Duration::from_secs(59)), "59s ago");
}
#[test]
fn format_relative_time_minutes() {
assert_eq!(format_relative_time(Duration::from_secs(60)), "1m ago");
assert_eq!(format_relative_time(Duration::from_secs(120)), "2m ago");
assert_eq!(
format_relative_time(Duration::from_secs(59 * 60)),
"59m ago"
);
}
#[test]
fn format_relative_time_hours() {
assert_eq!(format_relative_time(Duration::from_secs(60 * 60)), "1h ago");
assert_eq!(
format_relative_time(Duration::from_secs(2 * 60 * 60)),
"2h ago"
);
assert_eq!(
format_relative_time(Duration::from_secs(23 * 60 * 60)),
"23h ago"
);
}
#[test]
fn format_relative_time_days() {
assert_eq!(
format_relative_time(Duration::from_secs(24 * 60 * 60)),
"1d ago"
);
assert_eq!(
format_relative_time(Duration::from_secs(3 * 24 * 60 * 60)),
"3d ago"
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,428 @@
//! Shortcuts bar — renders keyboard hints.
//!
//! Accepts `&[HintItem]` from any source — action registry, prompt widget,
//! scrollback state, etc. Each view builds its own hints dynamically.
//!
//! When a `PendingAction` is active (double-press confirmation),
//! the bar replaces all hints with "press again to {label}".
use std::borrow::Cow;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthStr;
use crate::input::key::KeyShortcut;
use crate::theme::Theme;
/// A single hint for the shortcuts bar.
///
/// Carries semantic key data — the bar handles rendering.
/// Views build these dynamically from the registry, widget keymaps, or local state.
#[derive(Debug, Clone)]
pub struct HintItem {
/// Keys to display. Multiple keys are shown joined with "/" (e.g., j/k).
pub keys: Vec<KeyShortcut>,
/// Short label for the bottom bar (e.g., "send", "nav", "cancel").
pub label: Cow<'static, str>,
/// Optional custom display string for keys (overrides keys.display()).
pub custom_display: Option<&'static str>,
/// Longer description for the all-shortcuts cheatsheet (e.g.,
/// "Send prompt to agent"). When `None`, falls back to `label`.
pub description: Option<Cow<'static, str>>,
/// When true, the hint survives compact-mode truncation — it is always
/// rendered regardless of `max_visible`. Use for hints that should be
/// discoverable in every scrollback context (e.g. nav, turn, mode).
pub pinned: bool,
}
impl HintItem {
/// Single-key hint.
pub fn new(key: KeyShortcut, label: impl Into<Cow<'static, str>>) -> Self {
Self {
keys: vec![key],
label: label.into(),
custom_display: None,
description: None,
pinned: false,
}
}
/// Paired-key hint (e.g., j/k for nav, h/l for turn).
pub fn paired(a: KeyShortcut, b: KeyShortcut, label: impl Into<Cow<'static, str>>) -> Self {
Self {
keys: vec![a, b],
label: label.into(),
custom_display: None,
description: None,
pinned: false,
}
}
/// Mark this hint as pinned — it will always be shown in the compact
/// shortcuts bar, even when the hint list exceeds `max_visible`.
pub fn pinned(mut self) -> Self {
self.pinned = true;
self
}
/// Render the keys portion as a display string (e.g., "j/k", "Enter", "Ctrl+c").
fn key_display(&self) -> String {
if let Some(display) = self.custom_display {
display.to_string()
} else {
self.keys
.iter()
.map(|k| k.display())
.collect::<Vec<_>>()
.join("/")
}
}
}
/// Shortcuts bar widget. Renders a list of `HintItem`s.
pub struct ShortcutsBar<'a> {
hints: &'a [HintItem],
/// If set, replaces all hints with "press again to {label}".
pending_confirmation: Option<PendingHint>,
/// Right-aligned text (e.g. team name).
right_text: Option<&'a str>,
/// Compact mode config: render only the first `max_visible` hints from
/// `hints`, then always append `help_hint` (e.g. the "all shortcuts"
/// modal trigger). When None, all hints are rendered.
compact: Option<CompactConfig>,
}
/// Compact-mode configuration for the shortcuts bar.
pub struct CompactConfig {
/// Maximum number of items to render from the hint list before the
/// trailing help hint.
pub max_visible: usize,
/// The trailing help hint (typically the binding for the all-shortcuts
/// modal). Always rendered when set, even if the hint list is empty.
pub help_hint: Option<HintItem>,
}
/// Info needed to render the "press again" hint.
#[derive(Clone, Copy)]
pub struct PendingHint {
pub shortcut: KeyShortcut,
pub label: &'static str,
}
impl<'a> ShortcutsBar<'a> {
/// Create from a pre-built list of hints.
pub fn new(hints: &'a [HintItem]) -> Self {
Self {
hints,
pending_confirmation: None,
right_text: None,
compact: None,
}
}
/// Render only the first `max_visible` hints, then append `help_hint`
/// (typically the binding that opens the all-shortcuts modal).
pub fn compact(mut self, max_visible: usize, help_hint: Option<HintItem>) -> Self {
self.compact = Some(CompactConfig {
max_visible,
help_hint,
});
self
}
/// Set the pending confirmation hint (replaces all normal hints).
pub fn with_pending(mut self, pending: Option<PendingHint>) -> Self {
self.pending_confirmation = pending;
self
}
/// Set right-aligned text (e.g. team name).
pub fn with_right_text(mut self, text: Option<&'a str>) -> Self {
self.right_text = text;
self
}
}
impl Widget for ShortcutsBar<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let theme = Theme::current();
let bg_style = Style::default()
.bg(theme.bg_base)
.fg(theme.gray)
.remove_modifier(Modifier::all());
// Clear area content and style — set_style only patches style, leaving
// old text from previous renders. Fill with spaces to clear.
for x in area.x..area.x + area.width {
if let Some(cell) = buf.cell_mut((x, area.y)) {
cell.reset();
cell.set_style(bg_style);
}
}
let key_style = Style::default()
.fg(theme.text_secondary)
.bg(theme.bg_base)
.add_modifier(Modifier::BOLD);
let action_style = Style::default()
.fg(theme.gray)
.bg(theme.bg_base)
.remove_modifier(Modifier::BOLD | Modifier::DIM);
// If pending confirmation, show only "press again to {label}"
if let Some(pending) = &self.pending_confirmation {
let key_text = pending.shortcut.display();
let label = format!("press again to {}", pending.label);
let mut x = area.x;
let key_span = Span::styled(&key_text, key_style);
let key_width = key_text.width() as u16;
buf.set_span(x, area.y, &key_span, key_width);
x += key_width;
let colon = Span::styled(":", action_style);
buf.set_span(x, area.y, &colon, 1);
x += 1;
let action_span = Span::styled(&label, action_style);
let action_width = label.width() as u16;
buf.set_span(x, area.y, &action_span, action_width);
let _ = x + action_width; // suppress unused
return;
}
let sep_style = Style::default()
.fg(theme.gray)
.bg(theme.bg_base)
.add_modifier(Modifier::DIM)
.remove_modifier(Modifier::BOLD);
let mut x = area.x;
// Build the effective hint list (compact-aware).
let effective = compute_effective_hints(self.hints, self.compact.as_ref());
for (i, hint) in effective.iter().enumerate() {
if i > 0 {
let sep = Span::styled("", sep_style);
let sep_width = 5u16;
if x + sep_width > area.x + area.width {
break;
}
buf.set_span(x, area.y, &sep, sep_width);
x += sep_width;
}
let key_text = hint.key_display();
let key_span = Span::styled(&key_text, key_style);
let key_width = key_text.width() as u16;
if x + key_width > area.x + area.width {
break;
}
buf.set_span(x, area.y, &key_span, key_width);
x += key_width;
let colon = Span::styled(":", action_style);
if x + 1 > area.x + area.width {
break;
}
buf.set_span(x, area.y, &colon, 1);
x += 1;
let action_span = Span::styled(hint.label.as_ref(), action_style);
let action_width = hint.label.width() as u16;
if x + action_width > area.x + area.width {
break;
}
buf.set_span(x, area.y, &action_span, action_width);
x += action_width;
}
// Right-aligned text (team name etc.)
if let Some(text) = self.right_text {
let right_style = Style::default().fg(theme.gray).bg(theme.bg_base);
let display = format!("{text} ");
let rw = display.width() as u16;
if rw > 0 && rw < area.width {
let rx = area.x + area.width.saturating_sub(rw);
if rx > x + 1 {
let right_span = Span::styled(display, right_style);
buf.set_span(rx, area.y, &right_span, rw);
}
}
}
}
}
/// Compute the hint list the bar will actually render.
///
/// Without `compact`: returns every hint from the input slice.
/// With `compact`: pinned hints are always included; the remaining
/// `max_visible pinned_count` slots are filled with unpinned hints in
/// their original order. The trailing `help_hint` is unconditionally
/// appended so users always see how to discover the rest.
pub fn compute_effective_hints<'a>(
hints: &'a [HintItem],
compact: Option<&'a CompactConfig>,
) -> Vec<&'a HintItem> {
if let Some(cfg) = compact {
let pinned_count = hints.iter().filter(|h| h.pinned).count();
let unpinned_budget = cfg.max_visible.saturating_sub(pinned_count);
let mut unpinned_used = 0;
let mut v: Vec<&HintItem> = hints
.iter()
.filter(|h| {
if h.pinned {
true
} else if unpinned_used < unpinned_budget {
unpinned_used += 1;
true
} else {
false
}
})
.collect();
if let Some(ref h) = cfg.help_hint {
v.push(h);
}
v
} else {
hints.iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::key;
fn h(label: &'static str, k: crate::input::key::KeyShortcut) -> HintItem {
HintItem::new(k, label)
}
#[test]
fn full_mode_returns_all_hints() {
let hints = vec![h("a", key!('a')), h("b", key!('b')), h("c", key!('c'))];
let out = compute_effective_hints(&hints, None);
assert_eq!(out.len(), 3);
}
#[test]
fn compact_takes_first_n_then_appends_help() {
let hints = vec![h("a", key!('a')), h("b", key!('b')), h("c", key!('c'))];
let help = h("shortcuts", key!('/', CONTROL));
let cfg = CompactConfig {
max_visible: 2,
help_hint: Some(help),
};
let out = compute_effective_hints(&hints, Some(&cfg));
assert_eq!(out.len(), 3); // 2 + help
assert_eq!(out[0].label, "a");
assert_eq!(out[1].label, "b");
assert_eq!(out[2].label, "shortcuts");
}
#[test]
fn compact_help_hint_renders_even_with_empty_hint_list() {
let hints: Vec<HintItem> = vec![];
let help = h("shortcuts", key!('/', CONTROL));
let cfg = CompactConfig {
max_visible: 2,
help_hint: Some(help),
};
let out = compute_effective_hints(&hints, Some(&cfg));
assert_eq!(out.len(), 1);
assert_eq!(out[0].label, "shortcuts");
}
#[test]
fn compact_without_help_just_truncates() {
let hints = vec![h("a", key!('a')), h("b", key!('b')), h("c", key!('c'))];
let cfg = CompactConfig {
max_visible: 2,
help_hint: None,
};
let out = compute_effective_hints(&hints, Some(&cfg));
assert_eq!(out.len(), 2);
}
#[test]
fn compact_max_visible_larger_than_input_is_safe() {
let hints = vec![h("a", key!('a'))];
let cfg = CompactConfig {
max_visible: 10,
help_hint: None,
};
let out = compute_effective_hints(&hints, Some(&cfg));
assert_eq!(out.len(), 1);
}
#[test]
fn compact_pinned_hints_always_included() {
// 5 hints: a, b, c are unpinned; d, e are pinned.
// max_visible=3 → budget for unpinned = 3-2 = 1.
// Result: a (unpinned slot 1), d (pinned), e (pinned) = 3 items.
let hints = vec![
h("a", key!('a')),
h("b", key!('b')),
h("c", key!('c')),
h("d", key!('d')).pinned(),
h("e", key!('e')).pinned(),
];
let cfg = CompactConfig {
max_visible: 3,
help_hint: None,
};
let out = compute_effective_hints(&hints, Some(&cfg));
let labels: Vec<&str> = out.iter().map(|h| h.label.as_ref()).collect();
assert_eq!(labels, vec!["a", "d", "e"]);
}
#[test]
fn compact_pinned_preserves_original_order() {
// Pinned hint appears between unpinned ones — order is preserved.
let hints = vec![
h("a", key!('a')),
h("nav", key!('j')).pinned(),
h("b", key!('b')),
h("c", key!('c')),
];
let cfg = CompactConfig {
max_visible: 3,
help_hint: None,
};
let out = compute_effective_hints(&hints, Some(&cfg));
let labels: Vec<&str> = out.iter().map(|h| h.label.as_ref()).collect();
// 1 pinned + budget 2 unpinned: a, nav, b
assert_eq!(labels, vec!["a", "nav", "b"]);
}
#[test]
fn compact_all_pinned_exceeding_max_visible() {
// More pinned hints than max_visible — all pinned still shown.
let hints = vec![
h("a", key!('a')).pinned(),
h("b", key!('b')).pinned(),
h("c", key!('c')).pinned(),
h("d", key!('d')),
];
let cfg = CompactConfig {
max_visible: 2,
help_hint: None,
};
let out = compute_effective_hints(&hints, Some(&cfg));
let labels: Vec<&str> = out.iter().map(|h| h.label.as_ref()).collect();
// All 3 pinned, 0 budget for unpinned.
assert_eq!(labels, vec!["a", "b", "c"]);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,640 @@
//! Dropdown list renderer for slash command completion.
//!
//! Renders slash command/arg suggestions as a scrollable list following
//! the same polished layout as the question/answer panel:
//! - Aligned label column (truncated with `...` when too long)
//! - Description text after a fixed gap, truncated to remaining width
//! - Selection highlight (bg_visual + bold on selected row)
//! - Mouse hover highlight (25% blended bg)
//! - Scrollbar when results exceed visible height
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use crate::render::SafeBuf;
use crate::render::line_utils::truncate_str;
use crate::render::scrollbar::render_scrollbar_styled;
use crate::slash::{MAX_VISIBLE_SUGGESTIONS, SlashSnapshot, SuggestionRow};
use crate::theme::Theme;
/// Maximum number of visible rows in the dropdown (excluding separator).
pub const MAX_DROPDOWN_ROWS: u16 = MAX_VISIBLE_SUGGESTIONS as u16;
/// Hard cap on label column width (labels longer than this are truncated).
const LABEL_CAP: usize = 40;
/// Gap (in spaces) between the label column and the description.
const LABEL_DESC_GAP: usize = 2;
/// Prefix display width in columns (`" "` or `" "`).
const PREFIX_W: usize = 2;
/// Terminal rows needed to show every item at `items_width`, capped at
/// [`MAX_DROPDOWN_ROWS`].
///
/// Items render as flat lines (label + wrapped-description continuations),
/// so an item-count height starves wrapped items and can leave later
/// matches entirely off-area.
pub fn desired_item_rows(items: &[SuggestionRow], items_width: u16) -> u16 {
if items.is_empty() {
return 0;
}
flat_line_count(items, items_width as usize, MAX_DROPDOWN_ROWS as usize) as u16
}
/// Compute the aligned label column width from all visible items.
///
/// The label column gets up to 60% of the available width (capped at `LABEL_CAP`).
/// This prioritises showing the full command name over the description.
fn compute_label_column_w(items: &[SuggestionRow], content_w: usize) -> usize {
let budget = (content_w * 3 / 5).min(LABEL_CAP);
let max_display_w = items
.iter()
.map(|r| r.display.width())
.filter(|&w| w <= LABEL_CAP)
.max()
.unwrap_or(0);
max_display_w.min(budget)
}
/// Build a flat list of styled lines for all visible items.
///
/// Each item produces one or more lines: the first has the prefix + label +
/// first description line; continuation lines are indented to the description
/// column. This is the same approach as `question_view::build_flat_option_lines`.
///
/// Returns `(flat_lines, item_first_line_indices)` where each entry in the
/// second vec is the flat-line index where item `i` starts (for scroll targeting).
fn build_flat_lines(
items: &[SuggestionRow],
selected: usize,
hovered: Option<usize>,
label_col_w: usize,
row_w: usize,
theme: &Theme,
) -> (Vec<Line<'static>>, Vec<usize>) {
let hover_bg = theme.bg_hover;
let mut flat: Vec<Line<'static>> = Vec::new();
let mut starts: Vec<usize> = Vec::new();
for (idx, item) in items.iter().enumerate() {
starts.push(flat.len());
let is_selected = idx == selected;
let is_hovered = hovered == Some(idx) && !is_selected;
let row_bg = match crate::views::modal_window::embedded_row_style(theme, is_selected) {
Some(e) => e.bg,
None if is_selected => theme.bg_visual,
None if is_hovered => hover_bg,
None => theme.bg_light,
};
build_item_lines(
&mut flat,
item,
is_selected,
label_col_w,
row_w,
row_bg,
theme,
);
}
(flat, starts)
}
/// Render the slash dropdown items into the given area.
///
/// This renders ONLY the result rows (no borders or separators).
/// Panel chrome (clear, borders, count hint) is handled by the caller
/// (AgentView). The `area` covers just the item rows.
///
/// `hovered` is the absolute item index currently under the mouse
/// (`None` if no hover). Used for blended hover highlight like file-search.
///
/// Returns the visible-row → item mapping for mouse hit-testing: once a
/// description wraps, rows ≠ items, so callers must not use row arithmetic.
pub fn render_dropdown(
buf: &mut Buffer,
area: Rect,
snap: &SlashSnapshot,
hovered: Option<usize>,
theme: &Theme,
) -> RenderedDropdown {
if area.height == 0 || area.width < 4 || !snap.open {
return RenderedDropdown::default();
}
let items = &snap.matches;
let selected = snap.selected.min(items.len().saturating_sub(1));
// Reserve 2 right columns for the scrollbar when wrapped content
// overflows. Decide at full width: narrowing only adds lines, so the
// decision cannot become stale.
let content_w = area.width as usize;
let visible_rows = area.height as usize;
let needs_scrollbar = flat_line_count(items, content_w, visible_rows + 1) > visible_rows;
let row_w = if needs_scrollbar {
content_w.saturating_sub(2)
} else {
content_w
};
// Compute aligned label column width across all items.
let label_col_w = compute_label_column_w(items, row_w.saturating_sub(PREFIX_W));
// Build flat line list (multi-line descriptions produce multiple lines per item).
let (flat_lines, item_starts) =
build_flat_lines(items, selected, hovered, label_col_w, row_w, theme);
// Compute scroll offset so the selected item's first line is visible.
let selected_start = item_starts.get(selected).copied().unwrap_or(0);
let total_lines = flat_lines.len();
let scroll = if total_lines <= visible_rows || selected_start < visible_rows / 2 {
0
} else if selected_start + visible_rows / 2 >= total_lines {
total_lines.saturating_sub(visible_rows)
} else {
selected_start.saturating_sub(visible_rows / 2)
};
// Render visible slice, recording which item each visible row shows.
let mut row_items = Vec::with_capacity(visible_rows.min(flat_lines.len()));
for vis_row in 0..visible_rows {
let line_idx = scroll + vis_row;
if line_idx >= flat_lines.len() {
break;
}
row_items.push(
item_starts
.partition_point(|&s| s <= line_idx)
.saturating_sub(1),
);
let y = area.y + vis_row as u16;
let line = &flat_lines[line_idx];
// Skip rows that fall outside the buffer (resize race).
if y < buf.area.y || y >= buf.area.bottom() || area.x >= buf.area.right() {
continue;
}
let row_bg = line.style.bg.unwrap_or(theme.bg_light);
let clamped_w = row_w.min(buf.area.right().saturating_sub(area.x) as usize) as u16;
let clamped = Rect {
x: area.x,
y,
width: clamped_w,
height: 1,
};
buf.set_style(clamped, Style::default().bg(row_bg));
buf.set_line_safe(area.x, y, line, row_w as u16);
}
// ── Scrollbar ───────────────────────────────────────────────────────
if needs_scrollbar {
// Intersect with the frame buffer so a resize race cannot paint past
// `buf.area` (same failure mode as item rows).
let sb_x = area.x + area.width.saturating_sub(1);
let sb_y = area.y.max(buf.area.y);
let sb_bottom = (area.y.saturating_add(area.height)).min(buf.area.bottom());
if sb_x < buf.area.right() && sb_bottom > sb_y {
let scrollbar_area = Rect {
x: sb_x,
y: sb_y,
width: 1,
height: sb_bottom - sb_y,
};
let track_style = Style::default().bg(theme.bg_dark);
let thumb_style = Style::default().fg(theme.gray_dim).bg(theme.bg_dark);
render_scrollbar_styled(
buf,
Some(scrollbar_area),
total_lines as u16,
scrollbar_area.height,
scroll as u16,
track_style,
thumb_style,
);
}
}
RenderedDropdown {
row_items,
has_scrollbar: needs_scrollbar,
}
}
/// Hit-test geometry produced by [`render_dropdown`].
#[derive(Debug, Clone, Default)]
pub struct RenderedDropdown {
/// Item index shown on each visible row (top to bottom). Shorter than the
/// area height when the content ends early.
pub row_items: Vec<usize>,
/// Whether the right 2 columns of the area are the scrollbar gutter.
pub has_scrollbar: bool,
}
/// Flat line count of `items` at `row_w`, mirroring [`build_item_lines`]
/// (label line + wrapped-description continuation lines). Saturates at
/// `cap` so the empty-query dropdown (every command listed) doesn't wrap
/// hundreds of descriptions just to compare against a single-digit height.
fn flat_line_count(items: &[SuggestionRow], row_w: usize, cap: usize) -> usize {
let label_col_w = compute_label_column_w(items, row_w.saturating_sub(PREFIX_W));
let desc_w = row_w
.saturating_sub(PREFIX_W + label_col_w + LABEL_DESC_GAP)
.max(1);
let mut lines = 0usize;
for item in items {
lines += if item.description.is_empty() {
1
} else {
simple_word_wrap(&item.description, desc_w).len()
};
if lines >= cap {
return cap;
}
}
lines
}
/// Build lines for a single dropdown item and append them to `out`.
///
/// Layout (same as question view):
/// - First line: ` /command-name First line of description`
/// - Continuation: ` Wrapped description text`
/// ^indent aligned to description column
#[allow(clippy::too_many_arguments)]
fn build_item_lines(
out: &mut Vec<Line<'static>>,
item: &SuggestionRow,
is_selected: bool,
label_col_w: usize,
total_w: usize,
row_bg: ratatui::style::Color,
theme: &Theme,
) {
let bold = if is_selected {
Modifier::BOLD
} else {
Modifier::empty()
};
let embed = crate::views::modal_window::embedded_row_style(theme, is_selected);
let primary_fg = embed.map_or(theme.text_primary, |e| e.fg(theme.text_primary));
let match_fg = embed.map_or(theme.fuzzy_accent, |e| e.fg(theme.fuzzy_accent));
let desc_fg = embed.map_or(theme.gray, |e| e.fg(theme.gray));
let normal_style = Style::default()
.fg(primary_fg)
.bg(row_bg)
.add_modifier(bold);
let match_style = Style::default().fg(match_fg).bg(row_bg).add_modifier(bold);
let desc_style = Style::default().fg(desc_fg).bg(row_bg);
let bg_style = Style::default().bg(row_bg);
// 1. Build prefix + label spans with fuzzy match highlighting.
let prefix = if is_selected {
crate::glyphs::prompt_arrow()
} else {
" "
};
let prefix_span = Span::styled(
prefix.to_string(),
if is_selected { normal_style } else { bg_style },
);
let label = truncate_str(&item.display, label_col_w);
let label_w = label.width();
let padding = label_col_w.saturating_sub(label_w);
// Build per-character spans for the label with fuzzy highlight.
let label_spans = build_highlighted_spans(&label, &item.indices, normal_style, match_style);
// Description column indent (prefix + label + gap).
let desc_indent = PREFIX_W + label_col_w + LABEL_DESC_GAP;
let desc_w = total_w.saturating_sub(desc_indent).max(1);
// Word-wrap description into lines of `desc_w` width.
let desc_lines = if item.description.is_empty() {
Vec::new()
} else {
simple_word_wrap(&item.description, desc_w)
};
// 2. First line: prefix + label(padded) + gap + first desc line.
{
let mut spans = vec![prefix_span];
spans.extend(label_spans);
if padding > 0 {
spans.push(Span::styled(" ".repeat(padding), bg_style));
}
if let Some(first_desc) = desc_lines.first() {
spans.push(Span::styled(" ".to_string(), bg_style));
spans.push(Span::styled(first_desc.clone(), desc_style));
}
out.push(Line::from(spans).style(bg_style));
}
// 3. Continuation lines: indented to description column.
for desc_line in desc_lines.iter().skip(1) {
let spans = vec![
Span::styled(" ".repeat(desc_indent), bg_style),
Span::styled(desc_line.clone(), desc_style),
];
out.push(Line::from(spans).style(bg_style));
}
}
/// Build spans for a text string with fuzzy match character highlighting.
///
/// Characters at positions listed in `indices` get `match_style` (accent color),
/// all others get `normal_style`. Adjacent characters with the same style are
/// coalesced into a single `Span` to keep the span count low.
fn build_highlighted_spans(
text: &str,
indices: &[u32],
normal_style: Style,
match_style: Style,
) -> Vec<Span<'static>> {
if indices.is_empty() {
return vec![Span::styled(text.to_string(), normal_style)];
}
let mut spans: Vec<Span<'static>> = Vec::new();
let mut current = String::new();
let mut current_is_match = false;
let mut idx_iter = indices.iter().copied().peekable();
for (char_idx, ch) in text.chars().enumerate() {
let is_match = idx_iter.peek() == Some(&(char_idx as u32));
if is_match {
idx_iter.next();
}
if char_idx == 0 {
current_is_match = is_match;
current.push(ch);
} else if is_match == current_is_match {
current.push(ch);
} else {
// Style transition — flush current run.
let style = if current_is_match {
match_style
} else {
normal_style
};
spans.push(Span::styled(std::mem::take(&mut current), style));
current_is_match = is_match;
current.push(ch);
}
}
if !current.is_empty() {
let style = if current_is_match {
match_style
} else {
normal_style
};
spans.push(Span::styled(current, style));
}
spans
}
/// Simple word-wrap for plain text. Returns lines of at most `width` chars.
///
/// Breaks at word boundaries when possible, hard-breaks at `width` otherwise.
fn simple_word_wrap(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
// Normalize: collapse newlines into spaces.
let normalized = text.replace('\n', " ");
let mut remaining = normalized.as_str();
while !remaining.is_empty() {
if remaining.width() <= width {
lines.push(remaining.to_string());
break;
}
// Find break point: last space within width, or hard break.
let break_at = {
let mut last_space = None;
let mut w = 0;
for (i, ch) in remaining.char_indices() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if w + cw > width {
break;
}
w += cw;
if ch == ' ' {
last_space = Some(i);
}
}
// Prefer word boundary; fall back to hard break at width.
last_space.map(|i| i + 1).unwrap_or_else(|| {
remaining
.char_indices()
.scan(0usize, |w, (i, ch)| {
*w += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if *w > width {
None
} else {
Some(i + ch.len_utf8())
}
})
.last()
.unwrap_or(remaining.len())
})
};
let (chunk, rest) = remaining.split_at(break_at);
lines.push(chunk.trim_end().to_string());
remaining = rest.trim_start();
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn desired_item_rows_caps_many_short_items() {
let matches: Vec<SuggestionRow> = (0..20)
.map(|i| SuggestionRow {
display: format!("/cmd{i}"),
description: String::new(),
insert_text: format!("/cmd{i}"),
indices: vec![],
})
.collect();
assert_eq!(desired_item_rows(&matches, 80), MAX_DROPDOWN_ROWS);
assert_eq!(desired_item_rows(&[], 80), 0);
}
/// During terminal resize the computed items area can extend past
/// the frame buffer. Item paint must not panic via ratatui `set_line`.
#[test]
fn render_dropdown_past_buffer_bottom_does_not_panic() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = Theme::current();
let matches: Vec<SuggestionRow> = (0..12)
.map(|i| SuggestionRow {
display: format!("/cmd{i}"),
description: format!("description for command {i}"),
insert_text: format!("/cmd{i}"),
indices: vec![],
})
.collect();
let snap = SlashSnapshot {
open: true,
matches,
selected: 0,
..Default::default()
};
// 80×10 buffer; items area starts at y=8 with height 8 → rows y=8..15,
// which extends past the buffer bottom (y=10). Mimics a resize race
// where layout still thinks the terminal is taller than the buffer.
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
let area = Rect::new(2, 8, 76, 8);
render_dropdown(&mut buf, area, &snap, Some(1), &theme);
}
fn row(display: &str, description: &str) -> SuggestionRow {
SuggestionRow {
display: display.into(),
description: description.into(),
insert_text: display.into(),
indices: vec![],
}
}
/// Degenerate geometry sweep: tiny/zero widths and heights, over-wide
/// glyphs, and unbreakable words must neither panic (debug arithmetic,
/// non-char-boundary splits) nor loop (zero-progress wrap).
#[test]
fn tiny_geometry_never_panics_or_hangs() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = Theme::current();
let nasty = vec![
row(
"/a",
"one-unbreakable-word-ที่ยาวมาก-🦀🦀🦀-with-no-spaces-at-all",
),
row(
"/日本語コマンド",
"全角文字だけで構成された説明文です、スペースなし。",
),
row("/b", ""),
];
for width in 0..=8u16 {
assert!(desired_item_rows(&nasty, width) <= MAX_DROPDOWN_ROWS);
for height in 0..=3u16 {
let snap = SlashSnapshot {
open: true,
matches: nasty.clone(),
selected: 2,
..Default::default()
};
let mut buf = Buffer::empty(Rect::new(0, 0, width.max(1), height.max(1)));
let area = Rect::new(0, 0, width, height);
let rendered = render_dropdown(&mut buf, area, &snap, Some(0), &theme);
assert!(rendered.row_items.len() <= height as usize);
}
}
}
/// Two matches, first description wraps: sizing must count wrapped
/// lines or the sibling lands off-area.
#[test]
fn desired_item_rows_counts_wrapped_description_lines() {
let long = "Apply the Japandi visual design system - a warm, earthy, calm aesthetic \
that merges Japanese restraint with Scandinavian comfort - when building \
HTML artifacts, web pages, UI mockups, components.";
let items = vec![
row("/japandi", long),
row("/japandi2", "Japandi v2 system."),
];
let rows = desired_item_rows(&items, 60);
assert!(
rows > items.len() as u16,
"wrapped lines must exceed item count, got {rows}"
);
assert!(rows <= MAX_DROPDOWN_ROWS);
// All-short descriptions keep the one-row-per-item sizing.
let short = vec![row("/exit", "Quit"), row("/model", "Switch model")];
assert_eq!(desired_item_rows(&short, 60), 2);
}
/// Every item is on screen (present in the hit map) when the area is
/// sized via `desired_item_rows`.
#[test]
fn render_dropdown_row_map_covers_all_items_at_desired_height() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = Theme::current();
let long = "Apply the Japandi visual design system - a warm, earthy, calm aesthetic \
that merges Japanese restraint with Scandinavian comfort.";
let matches = vec![
row("/japandi", long),
row("/japandi2", "Japandi v2 system."),
];
let width: u16 = 60;
let rows = desired_item_rows(&matches, width);
let snap = SlashSnapshot {
open: true,
matches,
selected: 0,
..Default::default()
};
let mut buf = Buffer::empty(Rect::new(0, 0, width, rows + 2));
let area = Rect::new(0, 0, width, rows);
let rendered = render_dropdown(&mut buf, area, &snap, None, &theme);
assert_eq!(rendered.row_items.len(), rows as usize);
assert!(
rendered.row_items.contains(&0) && rendered.row_items.contains(&1),
"both items must be on screen at scroll 0: {:?}",
rendered.row_items
);
assert!(!rendered.has_scrollbar, "content fits; no scrollbar");
// Rows are monotone and grouped: item 1 starts after item 0's lines.
assert!(rendered.row_items.windows(2).all(|w| w[0] <= w[1]));
}
/// Scrollbar + row map when content exceeds the capped height.
#[test]
fn render_dropdown_scrollbar_on_line_overflow() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = Theme::current();
let long = "A deliberately verbose description that will wrap across several \
lines at sixty columns to overflow the capped dropdown height.";
let matches: Vec<SuggestionRow> = (0..4).map(|i| row(&format!("/cmd{i}"), long)).collect();
let width: u16 = 60;
let rows = desired_item_rows(&matches, width);
assert_eq!(rows, MAX_DROPDOWN_ROWS, "content must exceed the cap");
let snap = SlashSnapshot {
open: true,
matches,
selected: 0,
..Default::default()
};
let mut buf = Buffer::empty(Rect::new(0, 0, width, rows + 2));
let area = Rect::new(0, 0, width, rows);
let rendered = render_dropdown(&mut buf, area, &snap, None, &theme);
assert!(rendered.has_scrollbar, "overflowing lines need a scrollbar");
assert_eq!(rendered.row_items.len(), rows as usize);
assert_eq!(rendered.row_items[0], 0, "scroll starts at the top");
}
}
@@ -0,0 +1,93 @@
//! StatusBar widget - displays context info at the top.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Span;
use ratatui::widgets::Widget;
use crate::theme::Theme;
/// Status bar showing context information.
///
/// Displays: token count, current turn, view mode, etc.
/// Respects layout: first 3 cols and last 2 cols are empty.
pub struct StatusBar<'a> {
/// Left-aligned content (e.g., "Context: 5.2k tokens")
pub left: &'a str,
/// Center content (e.g., "Turn 2/3")
pub center: Option<&'a str>,
/// Right-aligned content (e.g., view mode indicator)
pub right: Option<&'a str>,
}
impl<'a> StatusBar<'a> {
/// Create a new status bar with left content.
pub fn new(left: &'a str) -> Self {
Self {
left,
center: None,
right: None,
}
}
/// Add center content.
pub fn center(mut self, text: &'a str) -> Self {
self.center = Some(text);
self
}
/// Add right content.
pub fn right(mut self, text: &'a str) -> Self {
self.right = Some(text);
self
}
}
impl Widget for StatusBar<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let theme = Theme::current();
// Layout: outer block already has 2-char horizontal padding
// No additional margins needed
let left_margin = 0u16;
let right_margin = 0u16;
let content_x = area.x + left_margin;
let content_width = area.width.saturating_sub(left_margin + right_margin);
if content_width < 10 {
return;
}
let style = Style::default().fg(theme.gray).bg(theme.bg_base);
// Fill background (the whole row)
buf.set_style(area, Style::default().bg(theme.bg_base));
// Left content
let left_span = Span::styled(self.left, style);
buf.set_span(content_x, area.y, &left_span, content_width);
// Center content (if fits)
if let Some(center) = self.center {
let center_width = center.len() as u16;
let center_x = content_x + (content_width.saturating_sub(center_width)) / 2;
if center_x > content_x + self.left.len() as u16 + 2 {
let center_span = Span::styled(center, style);
buf.set_span(center_x, area.y, &center_span, center_width);
}
}
// Right content
if let Some(right) = self.right {
let right_width = right.len() as u16;
let right_x = content_x + content_width.saturating_sub(right_width);
let right_span = Span::styled(right, style);
buf.set_span(right_x, area.y, &right_span, right_width);
}
}
}
@@ -0,0 +1,514 @@
//! Subagent catalog pane — browseable list of bundled personas/roles/agents.
//!
//! Read-only pane that renders grouped entries from [`BundleState`]. Headers
//! (Personas, Roles, Agents) are non-selectable; items below each header
//! are selectable and scrollable via the standard [`ListPane`] machinery.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use crossterm::event::{KeyEvent, MouseEventKind};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::StatefulWidget;
use crate::app::bundle::BundleState;
use crate::appearance::LayoutConfig;
use crate::scrollback::layout::HorizontalLayout;
use crate::theme::Theme;
use super::list_pane::{
ListItem, ListPane, ListPaneConfig, ListPaneState, ListPaneStyle, WrapMode,
};
use super::overlay::OverlayState;
// ---------------------------------------------------------------------------
// CatalogEntry
// ---------------------------------------------------------------------------
struct CatalogEntry {
id: u64,
label: String,
styled: Line<'static>,
is_header: bool,
kind: Option<&'static str>,
}
impl ListItem for CatalogEntry {
fn content(&self) -> &Line<'_> {
&self.styled
}
fn stable_id(&self) -> u64 {
self.id
}
fn is_selectable(&self) -> bool {
!self.is_header
}
fn search_text(&self) -> &str {
&self.label
}
}
fn lookup_description<'a>(kind: &str, name: &str, state: &'a BundleState) -> Option<&'a str> {
match kind {
"persona" => state
.persona_details
.iter()
.find(|d| d.name == name)
.and_then(|d| d.description.as_deref())
.filter(|d| !d.is_empty()),
"role" => state
.role_details
.iter()
.find(|d| d.name == name)
.map(|d| d.description.as_str())
.filter(|d| !d.is_empty()),
_ => None,
}
}
// ---------------------------------------------------------------------------
// SubagentCatalogPane
// ---------------------------------------------------------------------------
const MAX_CATALOG_HEIGHT: u16 = 8;
const MAX_CATALOG_FRACTION: f32 = 0.15;
pub struct SubagentCatalogPane {
entries: Vec<CatalogEntry>,
pub list_state: ListPaneState,
list_style: ListPaneStyle,
pub overlay: OverlayState,
}
impl Default for SubagentCatalogPane {
fn default() -> Self {
Self::new()
}
}
impl SubagentCatalogPane {
pub fn new() -> Self {
let config = ListPaneConfig {
follow_enabled: false,
wrap_toggle_enabled: false,
search_enabled: true,
copy_enabled: false,
show_selection_when_unfocused: false,
visual_select_enabled: false,
filter_enabled: true,
goto_line_enabled: false,
};
let list_state = ListPaneState::new_with_config(WrapMode::NoWrap, false, config);
Self {
entries: Vec::new(),
list_state,
list_style: ListPaneStyle::default(),
overlay: OverlayState::hidden(),
}
}
// -- Data sync -----------------------------------------------------------
pub fn sync_from_bundle(&mut self, state: &BundleState) {
self.entries.clear();
if !state.has_cache {
return;
}
let theme = Theme::current();
let header_style = Style::default()
.fg(theme.gray_bright)
.add_modifier(Modifier::BOLD);
let item_style = Style::default().fg(theme.text_primary);
let desc_style = Style::default().fg(theme.gray_bright);
let groups: [(&str, &'static str, &[String]); 3] = [
("Personas", "persona", &state.personas),
("Roles", "role", &state.roles),
("Agents", "agent", &state.agents),
];
for (name, kind, items) in &groups {
if items.is_empty() {
continue;
}
let mut hasher = DefaultHasher::new();
name.hash(&mut hasher);
let owned_name = name.to_string();
self.entries.push(CatalogEntry {
id: hasher.finish(),
styled: Line::from(Span::styled(owned_name.clone(), header_style)),
label: owned_name,
is_header: true,
kind: None,
});
for item in *items {
let mut hasher = DefaultHasher::new();
name.hash(&mut hasher);
item.hash(&mut hasher);
let desc = lookup_description(kind, item, state);
let spans = if let Some(d) = &desc {
vec![
Span::styled(format!(" {item}"), item_style),
Span::styled(format!(" \u{2014} {d}"), desc_style),
]
} else {
vec![Span::styled(format!(" {item}"), item_style)]
};
self.entries.push(CatalogEntry {
id: hasher.finish(),
label: item.clone(),
styled: Line::from(spans),
is_header: false,
kind: Some(kind),
});
}
}
}
// -- Visibility ----------------------------------------------------------
pub fn is_visible(&self) -> bool {
self.overlay.visible
}
pub fn on_state_change(&mut self) {
if !self.overlay.visible {
self.list_state.close_input_bar();
}
}
pub fn desired_height(&self, view_height: u16) -> u16 {
if !self.overlay.visible {
return 0;
}
if view_height < 12 {
return 0;
}
let count = self.entries.len();
if count == 0 {
return 1;
}
let fraction_cap = (view_height as f32 * MAX_CATALOG_FRACTION).floor() as u16;
let max = MAX_CATALOG_HEIGHT.min(fraction_cap).max(1);
(count as u16).min(max).max(1)
}
/// Returns `(kind, name)` of the currently selected non-header entry.
///
/// `kind` is the lowercase singular form (`"persona"`, `"role"`, `"agent"`).
pub fn selected_entry(&self) -> Option<(&str, &str)> {
let selected_id = self.list_state.selected_id()?;
let entry = self.entries.iter().find(|e| e.id == selected_id)?;
if entry.is_header {
return None;
}
Some((entry.kind?, &entry.label))
}
// -- Input handling ------------------------------------------------------
pub fn handle_key(&mut self, key: &KeyEvent) -> bool {
if self.entries.is_empty() {
return false;
}
self.list_state.handle_key_event(key, &self.entries)
}
pub fn handle_scroll(&mut self, lines: i32, col: u16, row: u16) {
let max = match self.list_state.viewport_height() {
0..=5 => 1,
6..=10 => 2,
_ => lines.unsigned_abs() as i32,
};
let capped = lines.signum() * lines.abs().min(max);
self.list_state
.handle_scroll_event(capped, col, row, &self.entries);
}
pub fn handle_mouse(&mut self, kind: MouseEventKind, col: u16, row: u16, area: Rect) -> bool {
if self.entries.is_empty() {
return false;
}
self.list_state
.handle_mouse_event(kind, col, row, area, &self.entries)
}
// -- Rendering -----------------------------------------------------------
fn content_area(area: Rect, layout_cfg: &LayoutConfig) -> Rect {
let pad_left = HorizontalLayout::ACCENT + layout_cfg.block_pad_left;
let pad_right = layout_cfg.block_pad_right;
Rect {
x: area.x + pad_left,
y: area.y,
width: area.width.saturating_sub(pad_left + pad_right),
height: area.height,
}
}
pub fn render(
&mut self,
area: Rect,
buf: &mut Buffer,
focused: bool,
layout_cfg: &LayoutConfig,
) {
let inner = Self::content_area(area, layout_cfg);
if self.entries.is_empty() {
if inner.height > 0 && inner.width > 0 {
let theme = Theme::current();
let span =
Span::styled("No bundled items.", Style::default().fg(theme.gray_bright));
buf.set_span(inner.x, inner.y, &span, inner.width);
}
return;
}
self.list_state
.prepare_layout(&self.entries, inner.width, inner.height);
ListPane::new(&self.entries)
.focused(focused)
.style(self.list_style)
.render(inner, buf, &mut self.list_state);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_state(personas: &[&str], roles: &[&str], agents: &[&str]) -> BundleState {
BundleState {
has_cache: true,
version: "v2".into(),
personas: personas.iter().map(|s| s.to_string()).collect(),
roles: roles.iter().map(|s| s.to_string()).collect(),
agents: agents.iter().map(|s| s.to_string()).collect(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
}
}
#[test]
fn sync_empty_state_produces_no_entries() {
let mut pane = SubagentCatalogPane::new();
pane.sync_from_bundle(&BundleState::default());
assert!(pane.entries.is_empty());
}
#[test]
fn sync_no_cache_produces_no_entries() {
let mut pane = SubagentCatalogPane::new();
let state = BundleState {
has_cache: false,
personas: vec!["researcher".into()],
..Default::default()
};
pane.sync_from_bundle(&state);
assert!(pane.entries.is_empty());
}
#[test]
fn sync_with_data_produces_grouped_entries() {
let mut pane = SubagentCatalogPane::new();
let state = make_state(&["researcher", "implementer"], &["reviewer"], &["default"]);
pane.sync_from_bundle(&state);
// 3 headers + 4 items = 7 entries
assert_eq!(pane.entries.len(), 7);
assert!(pane.entries[0].is_header);
assert_eq!(pane.entries[0].label, "Personas");
assert!(!pane.entries[1].is_header);
assert_eq!(pane.entries[1].label, "researcher");
assert!(!pane.entries[2].is_header);
assert_eq!(pane.entries[2].label, "implementer");
assert!(pane.entries[3].is_header);
assert_eq!(pane.entries[3].label, "Roles");
assert!(!pane.entries[4].is_header);
assert_eq!(pane.entries[4].label, "reviewer");
assert!(pane.entries[5].is_header);
assert_eq!(pane.entries[5].label, "Agents");
assert!(!pane.entries[6].is_header);
assert_eq!(pane.entries[6].label, "default");
}
#[test]
fn sync_partial_data_skips_empty_groups() {
let mut pane = SubagentCatalogPane::new();
let state = make_state(&["researcher", "auditor"], &[], &[]);
pane.sync_from_bundle(&state);
// 1 header + 2 items = 3 (no Roles/Agents headers)
assert_eq!(pane.entries.len(), 3);
assert!(pane.entries[0].is_header);
assert_eq!(pane.entries[0].label, "Personas");
assert!(!pane.entries[1].is_header);
assert!(!pane.entries[2].is_header);
}
#[test]
fn headers_are_not_selectable() {
let mut pane = SubagentCatalogPane::new();
let state = make_state(&["researcher"], &["reviewer"], &[]);
pane.sync_from_bundle(&state);
for entry in &pane.entries {
assert_eq!(entry.is_selectable(), !entry.is_header);
}
}
#[test]
fn desired_height_zero_when_hidden() {
let pane = SubagentCatalogPane::new();
assert!(!pane.overlay.visible);
assert_eq!(pane.desired_height(40), 0);
}
#[test]
fn desired_height_capped_by_entry_count() {
let mut pane = SubagentCatalogPane::new();
pane.overlay.visible = true;
let state = make_state(&["a", "b"], &[], &[]);
pane.sync_from_bundle(&state);
// 1 header + 2 items = 3 entries, should cap at 3
assert_eq!(pane.desired_height(80), 3);
}
#[test]
fn desired_height_zero_for_tiny_terminal() {
let mut pane = SubagentCatalogPane::new();
pane.overlay.visible = true;
let state = make_state(&["a"], &[], &[]);
pane.sync_from_bundle(&state);
assert_eq!(pane.desired_height(10), 0);
}
#[test]
fn stable_ids_are_unique() {
let mut pane = SubagentCatalogPane::new();
let state = make_state(&["a", "b"], &["a"], &["a"]);
pane.sync_from_bundle(&state);
let ids: Vec<u64> = pane.entries.iter().map(|e| e.stable_id()).collect();
let unique: std::collections::HashSet<u64> = ids.iter().copied().collect();
assert_eq!(ids.len(), unique.len(), "all stable IDs must be unique");
}
#[test]
fn sync_replaces_previous_entries() {
let mut pane = SubagentCatalogPane::new();
let state1 = make_state(&["a", "b", "c"], &[], &[]);
pane.sync_from_bundle(&state1);
assert_eq!(pane.entries.len(), 4); // 1 header + 3
let state2 = make_state(&["x"], &[], &[]);
pane.sync_from_bundle(&state2);
assert_eq!(pane.entries.len(), 2); // 1 header + 1
assert_eq!(pane.entries[1].label, "x");
}
#[test]
fn selected_entry_returns_kind_and_name() {
let mut pane = SubagentCatalogPane::new();
let state = make_state(&["researcher"], &["reviewer"], &["default"]);
pane.sync_from_bundle(&state);
// [0]=Personas, [1]=researcher, [2]=Roles, [3]=reviewer, [4]=Agents, [5]=default
pane.list_state.select_by_id(pane.entries[1].id);
assert_eq!(pane.selected_entry(), Some(("persona", "researcher")));
pane.list_state.select_by_id(pane.entries[3].id);
assert_eq!(pane.selected_entry(), Some(("role", "reviewer")));
pane.list_state.select_by_id(pane.entries[5].id);
assert_eq!(pane.selected_entry(), Some(("agent", "default")));
}
#[test]
fn selected_entry_returns_none_for_header() {
let mut pane = SubagentCatalogPane::new();
let state = make_state(&["researcher"], &[], &[]);
pane.sync_from_bundle(&state);
// Select the "Personas" header (entries[0])
pane.list_state.select_by_id(pane.entries[0].id);
assert!(pane.selected_entry().is_none());
}
#[test]
fn selected_entry_returns_none_when_empty() {
let pane = SubagentCatalogPane::new();
assert!(pane.selected_entry().is_none());
}
#[test]
fn sync_with_descriptions_appends_to_styled_line() {
use crate::app::bundle::{PersonaDetail, RoleDetail};
let mut pane = SubagentCatalogPane::new();
let mut state = make_state(&["researcher"], &["reviewer"], &[]);
state.persona_details = vec![PersonaDetail {
name: "researcher".into(),
description: Some("thorough researcher".into()),
has_inputs: false,
has_outputs: false,
source_path: None,
scope_label: None,
}];
state.role_details = vec![RoleDetail {
name: "reviewer".into(),
description: "code reviewer".into(),
}];
pane.sync_from_bundle(&state);
// researcher entry should have 2 spans (name + description)
assert_eq!(pane.entries[1].styled.spans.len(), 2);
// reviewer entry should have 2 spans
assert_eq!(pane.entries[3].styled.spans.len(), 2);
}
#[test]
fn sync_without_descriptions_has_single_span() {
let mut pane = SubagentCatalogPane::new();
let state = make_state(&["researcher"], &[], &[]);
pane.sync_from_bundle(&state);
// No detail → single span
assert_eq!(pane.entries[1].styled.spans.len(), 1);
}
#[test]
fn empty_persona_description_renders_no_em_dash() {
use crate::app::bundle::PersonaDetail;
let mut pane = SubagentCatalogPane::new();
let mut state = make_state(&["researcher"], &[], &[]);
state.persona_details = vec![PersonaDetail {
name: "researcher".into(),
description: Some(String::new()),
has_inputs: false,
has_outputs: false,
source_path: None,
scope_label: None,
}];
pane.sync_from_bundle(&state);
// Empty description should be filtered — single span, no dangling em-dash.
assert_eq!(pane.entries[1].styled.spans.len(), 1);
}
#[test]
fn entries_store_kind() {
let mut pane = SubagentCatalogPane::new();
let state = make_state(&["researcher"], &["reviewer"], &["default"]);
pane.sync_from_bundle(&state);
assert_eq!(pane.entries[0].kind, None); // header
assert_eq!(pane.entries[1].kind, Some("persona"));
assert_eq!(pane.entries[2].kind, None); // header
assert_eq!(pane.entries[3].kind, Some("role"));
assert_eq!(pane.entries[4].kind, None); // header
assert_eq!(pane.entries[5].kind, Some("agent"));
}
}
@@ -0,0 +1,787 @@
//! Shell command suggestion controller.
//!
//! Manages ghost text state, progressive matching, and ACP integration
//! for shell command suggestions. Ghost text is rendered as dimmed italic
//! text after the cursor. Progressive matching trims the ghost when the
//! user types a character that matches the ghost's prefix, avoiding
//! unnecessary network requests.
//!
//! ACP integration: on text change (after debounce), sends an
//! `x.ai/suggest` request through the Effect pipeline. Stale responses
//! are discarded via generation tracking.
/// Source of a shell command suggestion.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SuggestionSource {
#[default]
None,
History,
PathExecutable,
FilePath,
AI,
}
impl SuggestionSource {
fn parse_source(s: &str) -> Self {
match s {
"history" => Self::History,
"path" => Self::PathExecutable,
"file" => Self::FilePath,
"ai" => Self::AI,
_ => Self::None,
}
}
}
/// Ghost text state for shell command suggestions.
#[derive(Debug, Clone, Default)]
pub(crate) struct GhostTextState {
/// Current ghost text to render (may be trimmed by progressive matching).
pub(crate) text: String,
/// Where this suggestion came from.
pub(crate) source: SuggestionSource,
/// Original full suggestion text before progressive matching.
pub(crate) full_text: String,
/// Generation counter when this ghost was set.
pub(crate) generation: u64,
}
/// Parsed ghost suggestion from an ACP `x.ai/suggest` response.
#[derive(Debug, Clone)]
pub struct GhostSuggestionParsed {
pub suffix: String,
pub source: SuggestionSource,
}
/// A single completion item from an ACP `x.ai/suggest` response.
// `Default` (empty item) exists for downstream test fixtures — functional-
// update construction (`..Default::default()`) keeps out-of-crate literals
// (e.g. kigi-pager-minimal's) compiling when optional fields are added.
#[derive(Debug, Clone, Default)]
pub struct CompletionItemParsed {
pub display: String,
pub description: String,
/// Whole-line replacement — always safe to `set_text` (the shell keeps
/// this backward-shaped for range-unaware pagers).
pub insert_text: String,
pub source: SuggestionSource,
pub priority: i32,
/// Byte range in the REQUEST text the completion targets. `None` (older
/// shells, whole-line items, or malformed wire data) keeps the
/// whole-line accept behavior. Parsed atomically with `token_text`:
/// present only as a pair.
pub replace_range: Option<std::ops::Range<usize>>,
/// Replacement for `replace_range` (path/file token completions);
/// `Some` exactly when `replace_range` is.
pub token_text: Option<String>,
/// The provider capped its scan/results — the set may be incomplete, so
/// Tab must not conclude from it (dropdown-only). Absent on the wire
/// (older shells) parses as `false`.
pub truncated: bool,
}
impl CompletionItemParsed {
/// The text that replaces `replace_range` on an in-place accept.
pub fn span_replacement(&self) -> &str {
self.token_text.as_deref().unwrap_or(&self.insert_text)
}
}
/// Parsed response from an ACP `x.ai/suggest` request.
#[derive(Debug, Clone)]
pub struct SuggestResponseParsed {
pub ghost: Option<GhostSuggestionParsed>,
pub completions: Vec<CompletionItemParsed>,
pub generation: u64,
}
impl SuggestResponseParsed {
/// Parse a raw JSON value from an ACP `x.ai/suggest` response.
pub fn from_json(value: &serde_json::Value) -> Option<Self> {
let result = value.get("result").unwrap_or(value);
let generation = result.get("generation")?.as_u64()?;
let ghost = result.get("ghost").and_then(|g| {
if g.is_null() {
return None;
}
let suffix = g.get("suffix")?.as_str()?;
if suffix.is_empty() {
return None;
}
let source_str = g.get("source").and_then(|s| s.as_str()).unwrap_or("");
Some(GhostSuggestionParsed {
suffix: suffix.to_owned(),
source: SuggestionSource::parse_source(source_str),
})
});
let completions = result
.get("completions")
.and_then(|c| c.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
let display = item.get("display")?.as_str()?.to_owned();
let insert_text = item.get("insertText")?.as_str()?.to_owned();
let description = item
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("")
.to_owned();
let source_str = item.get("source").and_then(|s| s.as_str()).unwrap_or("");
let priority =
item.get("priority").and_then(|p| p.as_i64()).unwrap_or(0) as i32;
// Optional `[start, end]`; anything malformed
// degrades to the legacy whole-line accept.
let replace_range = item.get("replaceRange").and_then(|r| {
let arr = r.as_array()?;
let (start, end) = match arr.as_slice() {
[s, e] => (s.as_u64()? as usize, e.as_u64()? as usize),
_ => return None,
};
(start <= end).then_some(start..end)
});
let token_text = item
.get("tokenText")
.and_then(|t| t.as_str())
.map(str::to_owned);
// The pair is atomic: a range without its token
// would splice the whole-line `insertText` into a
// token span (`cat no` → `cat cat notes.md`), a
// token without its range has nowhere to go — half
// pairs degrade to the rangeless whole-line accept.
let (replace_range, token_text) = match (replace_range, token_text) {
(Some(r), Some(t)) => (Some(r), Some(t)),
_ => (None, None),
};
let truncated = item
.get("truncated")
.and_then(|t| t.as_bool())
.unwrap_or(false);
Some(CompletionItemParsed {
display,
description,
insert_text,
source: SuggestionSource::parse_source(source_str),
priority,
replace_range,
token_text,
truncated,
})
})
.collect()
})
.unwrap_or_default();
Some(Self {
ghost,
completions,
generation,
})
}
}
/// How much of the ghost text to accept.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcceptMode {
/// Accept the entire ghost text (Right arrow).
Full,
/// Accept one word of ghost text (Ctrl+Right).
OneWord,
}
/// Action requested by `text_changed` for the caller to dispatch.
#[derive(Debug, PartialEq, Eq)]
pub enum SuggestionAction {
/// Text progressively matched the ghost — no network request needed.
Matched,
/// Spawn a debounce timer. On expiry, call `on_debounce_expired`.
Debounce { generation: u64 },
}
/// Wire `limit` for `x.ai/suggest` fetches. Matches the shell file
/// provider's ranked-result cap (`MAX_RESULTS` in the shell crate's
/// `file_provider.rs`): the provider ranks BEFORE capping, the dropdown
/// renders 6 rows and scrolls the rest. Both fetch sites (Tab and the
/// as-you-type debounce) must send the same value or their candidate sets
/// diverge.
pub const SHELL_SUGGEST_WIRE_LIMIT: usize = 50;
/// Terminal-Tab decision over the current dropdown items — computed by
/// [`SuggestionController::tab_decision`], executed by the view. Owning the
/// whole policy here (staleness, source shape, single-candidate, LCP) keeps
/// the view from reading item internals.
#[derive(Debug, PartialEq, Eq)]
pub enum TabAction {
/// Exactly one token candidate: accept it immediately, no dropdown flash.
InstaAccept,
/// Write the shared prefix over the validated span (bash's first Tab),
/// then re-fetch for the longer token.
Fill(std::ops::Range<usize>, String),
/// Ambiguous (or whole-line/mixed sources): open the dropdown.
Open,
/// No usable candidates: none fetched, or outdated by an edit / cursor
/// move. The key path fetches on this; the landing path does nothing.
Nothing,
}
/// A resolved dropdown accept: what to write, decided against the draft
/// BEFORE the dropdown closed (so nothing depends on state surviving
/// `close()`). Produced by [`SuggestionController::accept_completion`],
/// applied by `PromptWidget::apply_completion_splice`.
#[derive(Debug, PartialEq, Eq)]
pub enum CompletionSplice {
/// Rangeless (legacy-shell) item: replace the whole line — safe because
/// wire `insert_text` is always a full line by protocol contract.
WholeLine(String),
/// Token item with a still-valid span: replace that range in place.
Token(std::ops::Range<usize>, String),
/// The item's span no longer fits the draft: accept is a
/// draft-preserving no-op — never a clobber.
Stale,
}
/// State for the shell command completion dropdown.
#[derive(Debug, Default)]
pub struct CompletionDropdownState {
pub open: bool,
pub items: Vec<CompletionItemParsed>,
pub selected: usize,
pub hovered: Option<usize>,
pub generation: u64,
/// The request text `items` were computed for — set atomically with the
/// items when a response lands, so item `replace_range` offsets always
/// validate against the text they actually index into.
pub request_text: String,
/// Cursor position the request was built at. Items target the token AT
/// this cursor; [`SuggestionController::tab_decision`] refuses items
/// when the live cursor has moved anywhere else (e.g. a mouse click) —
/// the only tolerated drift is typing at the end.
pub request_cursor: usize,
}
impl CompletionDropdownState {
/// Move the selection by `delta` (negative = up, positive = down),
/// wrapping around at the ends. Used for keyboard arrow nav.
pub fn move_selection(&mut self, delta: isize) {
if self.items.is_empty() {
return;
}
let len = self.items.len() as isize;
let new = (self.selected as isize + delta).rem_euclid(len) as usize;
self.selected = new;
}
/// Move the selection by `delta`, clamping at the first and last item
/// (no wrap-around). Used for mouse-wheel scrolling.
pub fn scroll_selection(&mut self, delta: isize) {
if self.items.is_empty() {
return;
}
let len = self.items.len() as isize;
let new = (self.selected as isize + delta).clamp(0, len - 1) as usize;
self.selected = new;
}
/// Accept the currently selected item, or `None` when there are no
/// items. Moves the item out to avoid cloning and closes the dropdown.
/// Deliberately independent of [`open`](Self::open) (a render flag):
/// the single-candidate insta-accept consumes an item that was never
/// rendered.
pub fn accept(&mut self) -> Option<CompletionItemParsed> {
if self.items.is_empty() {
return None;
}
let idx = self.selected.min(self.items.len() - 1);
let item = self.items.swap_remove(idx);
self.close();
Some(item)
}
// The `request_text`/`request_cursor` anchor is left in place (inert
// without items); a landing overwrites it atomically with the items.
pub fn close(&mut self) {
self.open = false;
self.selected = 0;
self.hovered = None;
self.items.clear();
}
}
/// Manages ghost text state, progressive matching, and ACP integration.
#[derive(Default)]
pub struct SuggestionController {
ghost: GhostTextState,
generation: u64,
last_request_text: String,
/// Generation of a Tab-triggered fetch whose landing should run the
/// terminal Tab semantics (armed by [`Self::begin_tab_completion`],
/// consumed by [`Self::take_pending_tab`]).
tab_pending: Option<u64>,
/// Whether the as-you-type suggestion pipeline (debounced fetches +
/// ghost rendering) is enabled. Resolved at construction from the
/// `KIGI_SUGGESTIONS` env var. Tab-triggered completion in bash mode
/// deliberately does NOT consult this — it is always on.
pub enabled: bool,
/// Completion dropdown state (populated from `SuggestResponse.completions`).
pub dropdown: CompletionDropdownState,
/// Whether AI-powered suggestions are enabled.
/// Resolved at construction from `KIGI_SUGGESTIONS_AI` env var.
pub ai_enabled: bool,
/// Model to use for AI suggestions. Sent in the `x.ai/suggest` request.
/// Resolved at construction from `KIGI_SUGGESTIONS_AI_MODEL` env var.
pub ai_model: Option<String>,
}
impl SuggestionController {
pub fn new() -> Self {
Self {
ghost: GhostTextState::default(),
generation: 0,
last_request_text: String::new(),
tab_pending: None,
enabled: kigi_config::env_bool("KIGI_SUGGESTIONS").unwrap_or(false),
dropdown: CompletionDropdownState::default(),
ai_enabled: kigi_config::env_bool("KIGI_SUGGESTIONS_AI").unwrap_or(false),
ai_model: std::env::var("KIGI_SUGGESTIONS_AI_MODEL")
.ok()
.filter(|s| !s.is_empty()),
}
}
/// Set ghost text with a source. Moves `text` into the controller.
pub fn set_ghost(&mut self, text: String, source: SuggestionSource) {
self.generation += 1;
self.set_ghost_fields(text, source);
}
/// Write ghost fields without touching the generation counter.
fn set_ghost_fields(&mut self, text: String, source: SuggestionSource) {
self.ghost.full_text.clear();
self.ghost.full_text.push_str(&text);
self.ghost.text = text;
self.ghost.source = source;
self.ghost.generation = self.generation;
}
pub fn clear_ghost(&mut self) {
self.ghost.text.clear();
self.ghost.full_text.clear();
self.ghost.source = SuggestionSource::None;
self.dropdown.close();
}
/// Wholesale suggestion-state discard (prompt emptied, `set_text` swap):
/// the ghost and the dropdown items belonged to the OLD draft, and any
/// in-flight fetch was for it, so clear both, disarm a pending Tab, and
/// bump the generation so late responses are discarded instead of
/// resurrecting stale state.
pub fn invalidate_draft(&mut self) {
self.clear_ghost();
self.last_request_text.clear();
self.tab_pending = None;
self.generation += 1;
}
pub fn has_ghost(&self) -> bool {
!self.ghost.text.is_empty()
}
/// Returns the current ghost text if non-empty.
pub fn ghost_text(&self) -> Option<&str> {
if self.ghost.text.is_empty() {
None
} else {
Some(&self.ghost.text)
}
}
/// Accept ghost text. Returns the accepted portion, or `None` if empty.
/// Closes the completion dropdown (accepted ghost text supersedes it)
/// and bumps the generation so in-flight responses for the pre-accept
/// text are discarded when they land.
pub fn accept_ghost(&mut self, mode: AcceptMode) -> Option<String> {
if self.ghost.text.is_empty() {
return None;
}
self.dropdown.close();
match mode {
AcceptMode::Full => {
let accepted = std::mem::take(&mut self.ghost.text);
self.ghost.full_text.clear();
self.ghost.source = SuggestionSource::None;
self.generation += 1;
Some(accepted)
}
AcceptMode::OneWord => {
let accept_end = one_word_end(&self.ghost.text);
if accept_end == 0 {
return None;
}
let accepted = self.ghost.text[..accept_end].to_owned();
self.ghost.text.drain(..accept_end);
if self.ghost.text.is_empty() {
self.ghost.full_text.clear();
self.ghost.source = SuggestionSource::None;
}
self.generation += 1;
Some(accepted)
}
}
}
/// Resolve what accepting the selected item WOULD write, without
/// consuming it or touching any state. The view probes this before an
/// insta-accept: a splice into an atomic prompt element must degrade to
/// opening the dropdown, not consume the candidate.
/// [`Self::accept_completion`] delegates here so the two can never
/// resolve differently.
pub fn peek_completion_splice(&self, current_text: &str) -> Option<CompletionSplice> {
if self.dropdown.generation != self.generation || self.dropdown.items.is_empty() {
return None;
}
let idx = self.dropdown.selected.min(self.dropdown.items.len() - 1);
let item = &self.dropdown.items[idx];
Some(match item.replace_range.clone() {
None => CompletionSplice::WholeLine(item.insert_text.clone()),
Some(range) => {
match self.validated_replace_range(range, item.span_replacement(), current_text) {
Some(range) => {
CompletionSplice::Token(range, item.span_replacement().to_owned())
}
None => CompletionSplice::Stale,
}
}
})
}
/// Accept the selected completion-dropdown item, refusing stale state:
/// items populated for a superseded generation just close the dropdown
/// and accept nothing (the refreshed fetch is already in flight). The
/// item's span is resolved against `current_text` BEFORE the dropdown
/// closes (see [`CompletionSplice`]). A successful accept bumps the
/// generation so in-flight responses for the pre-accept text are
/// discarded when they land.
pub fn accept_completion(&mut self, current_text: &str) -> Option<CompletionSplice> {
if self.dropdown.generation != self.generation {
self.dropdown.close();
return None;
}
let resolved = self.peek_completion_splice(current_text)?;
self.dropdown.accept()?;
self.generation += 1;
Some(resolved)
}
/// Try progressive matching: if `new_text` extends `last_request_text`
/// by exactly one character that matches the ghost's first character,
/// trim the ghost and return `true`. Otherwise clear the ghost and
/// return `false`.
pub fn try_progressive_match(&mut self, new_text: &str) -> bool {
if self.ghost.text.is_empty() {
return false;
}
let suffix = match new_text.strip_prefix(self.last_request_text.as_str()) {
Some(s) => s,
None => {
self.clear_ghost();
return false;
}
};
let mut chars = suffix.chars();
let typed_char = match (chars.next(), chars.next()) {
(Some(c), None) => c,
_ => {
self.clear_ghost();
return false;
}
};
if !self.ghost.text.starts_with(typed_char) {
self.clear_ghost();
return false;
}
self.ghost.text.drain(..typed_char.len_utf8());
self.last_request_text.clear();
self.last_request_text.push_str(new_text);
if self.ghost.text.is_empty() {
self.ghost.full_text.clear();
self.ghost.source = SuggestionSource::None;
}
true
}
/// Update the progressive-match anchor: the text the on-screen ghost is
/// relative to (reset to the CURRENT text whenever a response lands).
/// Distinct from [`CompletionDropdownState::request_text`], which pins
/// the fetch-time text the dropdown items' ranges index into.
pub fn set_last_request_text(&mut self, text: &str) {
self.last_request_text.clear();
self.last_request_text.push_str(text);
}
/// The whole terminal-Tab policy over the current dropdown items:
/// staleness (generation AND cursor consistency), source shape, the
/// single-candidate rule, and the shared-prefix rule — one seam, so the
/// view executes without reading item internals. Only complete token
/// edits (path/file source AND a range+token pair AND an exhaustive
/// scan) get shell semantics; everything else — whole-line, mixed, or
/// degraded sets — always [`TabAction::Open`].
pub fn tab_decision(&self, current_text: &str, current_cursor: usize) -> TabAction {
if self.dropdown.generation != self.generation || self.dropdown.items.is_empty() {
return TabAction::Nothing;
}
// Items target the token at the FETCH-time cursor. The only
// tolerated drift is typing at the end (the same growth the range
// stretch rule accepts); any other cursor move — a mouse click in
// particular reports no text change — makes them stale, and Tab
// must fetch for the token actually under the cursor.
let grown = current_text
.len()
.saturating_sub(self.dropdown.request_text.len());
if current_cursor != self.dropdown.request_cursor + grown {
return TabAction::Nothing;
}
// Source alone is not enough: old shells send rangeless `path` rows
// whose whole-line fallback would clobber the draft on insta-accept
// (`ls | gr` → `grep`), and a truncated (capped) scan may hide the
// row that disproves a sole match or an LCP.
let token_shaped = self.dropdown.items.iter().all(|i| {
matches!(
i.source,
SuggestionSource::FilePath | SuggestionSource::PathExecutable
) && i.replace_range.is_some()
&& i.token_text.is_some()
&& !i.truncated
});
if token_shaped {
if self.dropdown.items.len() == 1 {
return TabAction::InstaAccept;
}
if let Some((range, fill)) = self.common_prefix_fill(current_text) {
return TabAction::Fill(range, fill);
}
}
TabAction::Open
}
/// Shared-prefix fill for terminal-like Tab: when every dropdown item
/// targets the SAME span and their replacements share a common prefix
/// that strictly extends the typed token, return the validated span and
/// the prefix to write (bash's first-Tab behavior). `None` on any
/// ambiguity — stale generation, mixed or missing ranges, no shared
/// prefix, or one that doesn't extend what's typed (e.g. candidates
/// differing in case) — and [`Self::tab_decision`] falls back to
/// opening the dropdown.
fn common_prefix_fill(&self, current_text: &str) -> Option<(std::ops::Range<usize>, String)> {
if self.dropdown.generation != self.generation {
return None;
}
let items = &self.dropdown.items;
if items.len() < 2 {
return None;
}
let range = items[0].replace_range.clone()?;
if items[1..]
.iter()
.any(|i| i.replace_range.as_ref() != Some(&range))
{
return None;
}
let mut lcp = items[0].span_replacement();
for item in &items[1..] {
lcp = common_str_prefix(lcp, item.span_replacement());
if lcp.is_empty() {
return None;
}
}
// Token texts are rendered shell literals (`a b`/`a$c` arrive as
// `a\ b`/`a\$c`), so their byte LCP can end mid-escape (`a\`) —
// filling that would write a dangling backslash (line
// continuation). Trim the incomplete escape; the strict-extension
// check below then decides whether anything is left to fill.
if lcp.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1 {
lcp = &lcp[..lcp.len() - 1];
}
let range = self.validated_replace_range(range, lcp, current_text)?;
let typed = &current_text[range.clone()];
(lcp.len() > typed.len() && lcp.starts_with(typed)).then(|| (range, lcp.to_owned()))
}
/// Re-validate a completion item's `replace_range` against the current
/// text. Offsets index into [`CompletionDropdownState::request_text`];
/// the only drift a live dropdown survives is progressive typing, so a
/// range that reached the request text's end absorbs the typed tail —
/// but ONLY while the grown span still extends toward `replacement` (a
/// prefix of it). Anything else returns `None`: no-op, never a clobber.
fn validated_replace_range(
&self,
range: std::ops::Range<usize>,
replacement: &str,
current_text: &str,
) -> Option<std::ops::Range<usize>> {
let request = self.dropdown.request_text.as_str();
if range.start > range.end || range.end > request.len() {
return None;
}
if !current_text.starts_with(request) {
return None;
}
let mut end = range.end;
if range.end == request.len() && current_text.len() > request.len() {
if !current_text.is_char_boundary(range.start)
|| !replacement.starts_with(&current_text[range.start..])
{
return None;
}
end = current_text.len();
}
(current_text.is_char_boundary(range.start) && current_text.is_char_boundary(end))
.then_some(range.start..end)
}
/// Arm a Tab-triggered deterministic completion fetch. Deliberately
/// independent of [`enabled`](Self::enabled): Tab in bash mode always
/// completes. Bumps the generation (discarding any in-flight response)
/// and returns it for the fetch effect; `run_tab_on_load` marks the
/// landing response to run the terminal Tab semantics once (the
/// post-accept/fill refreshes pass `false` so their items land
/// silently and wait for the next Tab).
pub fn begin_tab_completion(&mut self, run_tab_on_load: bool) -> u64 {
self.generation += 1;
self.tab_pending = run_tab_on_load.then_some(self.generation);
self.generation
}
/// A Tab-armed fetch is still in flight for the current draft (nothing
/// invalidated it since arming): a repeat Tab keeps the marker and lets
/// that landing run the Tab semantics once — no second RPC.
pub fn tab_fetch_pending(&self) -> bool {
self.tab_pending == Some(self.generation)
}
/// Consume the pending-Tab mark when the response for `generation`
/// lands. `true` only when this is the fetch Tab armed AND it is still
/// current (an edit since the Tab makes it stale).
pub fn take_pending_tab(&mut self, generation: u64) -> bool {
if self.tab_pending == Some(generation) {
self.tab_pending = None;
return generation == self.generation;
}
false
}
/// Current generation counter (for callers that need to pass it to effects).
pub fn generation(&self) -> u64 {
self.generation
}
/// Called on each text change. Returns the action the caller should take.
///
/// If slash is active (or has an inline ghost), suppresses the pipeline
/// entirely. Otherwise, tries progressive matching first; if no match,
/// increments generation and requests a debounce.
pub fn text_changed(
&mut self,
text: &str,
slash_active: bool,
slash_has_inline_ghost: bool,
) -> Option<SuggestionAction> {
if !self.enabled {
// No as-you-type pipeline, but Tab-fetched completion state is
// text-specific: the edit outdates the dropdown items and any
// in-flight Tab fetch (the generation bump makes them stale).
self.invalidate_draft();
return None;
}
if slash_active || slash_has_inline_ghost {
// Suppression must invalidate, not just hide: an already-armed
// debounce or pending Tab landing would otherwise repopulate
// suggestion state behind the slash UI.
self.invalidate_draft();
return None;
}
if text.is_empty() {
self.invalidate_draft();
return None;
}
if self.try_progressive_match(text) {
return Some(SuggestionAction::Matched);
}
// Non-matching edit: `try_progressive_match` clears ghost+dropdown
// on a ghost mismatch, but a ghost-less dropdown (pure path/file
// items) would leak through its empty-ghost early return — this
// edit outdated those items, so tear the dropdown down here.
self.dropdown.close();
self.generation += 1;
Some(SuggestionAction::Debounce {
generation: self.generation,
})
}
/// Called when a debounce timer expires. If the generation still matches,
/// returns `true` and the caller should fire the ACP request.
pub fn on_debounce_expired(&self, generation: u64) -> bool {
generation == self.generation
}
/// Called when an ACP `x.ai/suggest` response arrives, with the text and
/// cursor the request was built from (the anchor item `replace_range`
/// offsets index into, and the position Tab targets). Takes ownership to
/// avoid copying strings. Discards stale responses.
pub fn on_suggestions_loaded(
&mut self,
response: SuggestResponseParsed,
request_text: &str,
request_cursor: usize,
) {
if response.generation != self.generation {
return;
}
match response.ghost {
// The ghost is the env-gated as-you-type surface: Tab-triggered
// (always-on) fetches feed only the dropdown items.
Some(ghost) if self.enabled => self.set_ghost_fields(ghost.suffix, ghost.source),
_ => self.clear_ghost(),
}
self.dropdown.items = response.completions;
self.dropdown.generation = response.generation;
self.dropdown.selected = 0;
self.dropdown.request_text.clear();
self.dropdown.request_text.push_str(request_text);
self.dropdown.request_cursor = request_cursor;
// Don't auto-open; Tab opens it whenever items exist.
}
}
/// Longest common prefix of two strings, trimmed to a char boundary.
fn common_str_prefix<'a>(a: &'a str, b: &str) -> &'a str {
let mut n = a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count();
while n > 0 && !a.is_char_boundary(n) {
n -= 1;
}
&a[..n]
}
/// Find the byte offset after the first word in `s`. A "word" is optional
/// leading whitespace followed by a run of non-whitespace characters.
fn one_word_end(s: &str) -> usize {
let leading_ws = s.len() - s.trim_start().len();
let after_ws = &s[leading_ws..];
let word_len = after_ws.find(char::is_whitespace).unwrap_or(after_ws.len());
leading_ws + word_len
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,550 @@
//! Timeline sidebar: a tick rail (one tick per turn) that replaces the
//! scrollbar in its gutter while enabled. Tick position encodes conversation
//! order, not scroll proportion.
//!
//! Geometry is computed once per frame into a [`TimelineRail`] consumed by
//! both the renderer and mouse hit-testing, so they cannot drift.
use std::ops::Range;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use crate::theme::Theme;
/// Columns reserved for the rail (widest tick).
pub const RAIL_WIDTH: u16 = 2;
/// Terminals narrower than this hide the rail (the transcript needs the
/// columns more than the navigator).
pub const MIN_TERMINAL_WIDTH: u16 = 60;
/// Minimum turns before the rail appears (a 1-turn timeline is noise).
pub const MIN_TURNS: usize = 2;
/// Per-frame rail geometry: where the ticks and chevrons landed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimelineRail {
/// Full rail rect (hit target), spanning the scrollback rows.
pub rect: Rect,
/// Turn indices currently shown as ticks (windowed around the active
/// turn when the conversation has more turns than rows).
pub window: Range<usize>,
/// First tick row.
pub ticks_y: u16,
/// Active turn (viewport top), if any.
pub active: Option<usize>,
/// The ▲ target: nearest turn strictly above the viewport top
/// ([`ScrollbackState::turn_above_viewport_top`]), NOT `active - 1` —
/// stepping from `active` could target trailing turns that no scroll
/// can bring to the top (stuck ▲).
///
/// [`ScrollbackState::turn_above_viewport_top`]:
/// crate::scrollback::ScrollbackState::turn_above_viewport_top
pub up_target: Option<usize>,
/// The ▼ target: nearest turn below the viewport top
/// ([`ScrollbackState::turn_below_viewport_top`]), so ▼ anchors it to the
/// top exactly like clicking its tick (both go through `jump_to_turn`,
/// which over-scrolls trailing turns rather than dimming). `None` only
/// when the last turn already owns the top.
///
/// [`ScrollbackState::turn_below_viewport_top`]:
/// crate::scrollback::ScrollbackState::turn_below_viewport_top
pub down_target: Option<usize>,
/// Chevron rows.
pub up_y: u16,
pub down_y: u16,
}
/// What part of the rail a screen position lands on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimelineHit {
/// A turn tick (turn index).
Tick(usize),
/// The ▲ chevron (previous turn).
Up,
/// The ▼ chevron (next turn).
Down,
}
/// Columns to reserve for the rail this frame — the single eligibility
/// policy (setting, view kind, terminal width, turn count). Geometry
/// feasibility (enough rows) stays in [`compute_rail`].
pub fn rail_width(
show_timeline: bool,
is_subagent_view: bool,
area_width: u16,
turn_count: usize,
) -> u16 {
if show_timeline
&& !is_subagent_view
&& area_width >= MIN_TERMINAL_WIDTH
&& turn_count >= MIN_TURNS
{
RAIL_WIDTH
} else {
0
}
}
/// The viewport-derived turn state the rail is built from, gathered once
/// per frame from `ScrollbackState`. Bundled so [`compute_rail`] takes one
/// argument instead of four adjacent `Option<usize>` / `bool` positionals.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RailViewport {
/// Turn at the viewport top (the highlighted tick), if any.
pub active: Option<usize>,
/// ▲ target: nearest turn strictly above the viewport top.
pub up_target: Option<usize>,
/// ▼ target: nearest turn below the viewport top.
pub down_target: Option<usize>,
/// Viewport is scrolled to the bottom — pins the tick window to the tail.
pub at_bottom: bool,
}
/// Compute rail geometry for this frame, or `None` when the rail should
/// not render (too few turns / no room for chevrons + at least one tick).
pub fn compute_rail(
scrollback_area: Rect,
rail_x: u16,
turn_count: usize,
vp: RailViewport,
) -> Option<TimelineRail> {
if turn_count < MIN_TURNS {
return None;
}
let height = scrollback_area.height as usize;
// Chevrons take 2 rows; require at least 1 tick row.
let max_ticks = height.checked_sub(2)?;
if max_ticks == 0 {
return None;
}
let window = if turn_count <= max_ticks {
0..turn_count
} else {
// More turns than rows: slide a window that keeps the active tick
// visible. At the bottom, prefer the tail so the newest ticks stay
// on screen — but never exclude the viewport-top (active) turn,
// or no tick would highlight.
let tail_start = turn_count - max_ticks;
let start = if vp.at_bottom {
match vp.active {
Some(a) => a.min(tail_start),
None => tail_start,
}
} else {
vp.active
.unwrap_or(turn_count - 1)
.saturating_sub(max_ticks / 2)
.min(tail_start)
};
start..start + max_ticks
};
// Center the chevron + tick stack vertically, like the web rail.
let total_rows = window.len() + 2;
let top = scrollback_area.y + ((height - total_rows) / 2) as u16;
let ticks_y = top + 1;
let down_y = ticks_y + window.len() as u16;
Some(TimelineRail {
rect: Rect {
x: rail_x,
y: scrollback_area.y,
width: RAIL_WIDTH,
height: scrollback_area.height,
},
window,
ticks_y,
active: vp.active,
up_target: vp.up_target,
down_target: vp.down_target,
up_y: top,
down_y,
})
}
/// The turn a rail interaction jumps to, derived from the rail's own
/// fields — the same state that dims the chevrons, so display and action
/// cannot disagree. `None` = end stop (dim chevron, click is a no-op).
///
/// ▼ steps to `down_target` even at the bottom: `jump_to_turn` over-scrolls
/// a trailing turn to the top (identical to clicking its tick), so the
/// chevron matches the click instead of sitting dead.
pub fn chevron_target(rail: &TimelineRail, hit: TimelineHit) -> Option<usize> {
match hit {
TimelineHit::Tick(turn_idx) => Some(turn_idx),
TimelineHit::Up => rail.up_target,
TimelineHit::Down => rail.down_target,
}
}
impl TimelineRail {
/// Hit-test a screen position. The whole rail width is the target.
pub fn hit(&self, col: u16, row: u16) -> Option<TimelineHit> {
if !self.rect.contains((col, row).into()) {
return None;
}
if row == self.up_y {
return Some(TimelineHit::Up);
}
if row == self.down_y {
return Some(TimelineHit::Down);
}
if row >= self.ticks_y {
let rel = (row - self.ticks_y) as usize;
if rel < self.window.len() {
return Some(TimelineHit::Tick(self.window.start + rel));
}
}
None
}
}
/// Render the rail: chevrons + one tick row per windowed turn. The rail
/// draws directly on the scrollback background (no dark track strip — it
/// read as an awkward empty band, especially with few ticks).
pub fn render_rail(
buf: &mut Buffer,
rail: &TimelineRail,
hovered: Option<TimelineHit>,
theme: &Theme,
) {
let dim = Style::default().fg(theme.gray_dim);
let normal = Style::default().fg(theme.gray);
let bright = Style::default().fg(theme.text_primary);
// Chevron dim state derives from the same function the click handler
// uses — a dim chevron is guaranteed to be a no-op.
let up_enabled = chevron_target(rail, TimelineHit::Up).is_some();
let down_enabled = chevron_target(rail, TimelineHit::Down).is_some();
let up_style = if hovered == Some(TimelineHit::Up) && up_enabled {
bright
} else if up_enabled {
normal
} else {
dim
};
let down_style = if hovered == Some(TimelineHit::Down) && down_enabled {
bright
} else if down_enabled {
normal
} else {
dim
};
let chevron_x = rail.rect.x + RAIL_WIDTH - 1;
buf.set_span(
chevron_x,
rail.up_y,
&Span::styled(crate::glyphs::timeline_chevron_up(), up_style),
1,
);
buf.set_span(
chevron_x,
rail.down_y,
&Span::styled(crate::glyphs::timeline_chevron_down(), down_style),
1,
);
for (row, turn_idx) in rail.window.clone().enumerate() {
let y = rail.ticks_y + row as u16;
let is_active = rail.active == Some(turn_idx);
let is_hovered = hovered == Some(TimelineHit::Tick(turn_idx));
let (text, style) = if is_active {
(crate::glyphs::timeline_tick_active(), bright)
} else if is_hovered {
(crate::glyphs::timeline_tick_hover(), bright)
} else {
// Short dim tick in the rightmost cell (precomposed pad + light).
(" \u{2500}", dim)
};
buf.set_span(rail.rect.x, y, &Span::styled(text, style), RAIL_WIDTH);
}
}
/// Floating preview card for a hovered tick, anchored left of the rail.
///
/// Shrink-to-fit, in the house popup chrome (clear + dark base fill +
/// rounded `Block`, like the pickers and /btw panel). The interior must
/// stay `bg_base`: border glyphs draw mid-cell, so any lighter fill
/// bleeds a half-cell past the border line.
pub fn render_tick_hover_popup(
buf: &mut Buffer,
rail: &TimelineRail,
scrollback_area: Rect,
turn_idx: usize,
preview: &str,
theme: &Theme,
) {
if !rail.window.contains(&turn_idx) {
return;
}
let tick_y = rail.ticks_y + (turn_idx - rail.window.start) as u16;
// Wrap to at most 2 lines by display width; ellipsize the last.
let max_text = ((scrollback_area.width / 2).clamp(16, 32)) as usize;
let mut lines: Vec<String> = Vec::new();
let mut rest: &str = preview.trim();
while !rest.is_empty() && lines.len() < 2 {
if lines.len() == 1 {
lines.push(crate::render::line_utils::truncate_str(rest, max_text));
rest = "";
} else {
let end = crate::render::line_utils::byte_offset_at_width(rest, max_text);
lines.push(rest[..end].to_string());
rest = rest[end..].trim_start();
}
}
if lines.is_empty() {
return;
}
let text_w = lines
.iter()
.map(|l| unicode_width::UnicodeWidthStr::width(l.as_str()))
.max()
.unwrap_or(0) as u16;
let card_w = text_w + 4;
let card_h = lines.len() as u16 + 2;
// Too short a terminal to place the card without painting over the
// panes above/below — skip it.
if card_h > scrollback_area.height {
return;
}
let card_x = rail
.rect
.x
.saturating_sub(card_w + 1)
.max(scrollback_area.x);
// Vertically centered on the tick row, clamped to the scrollback rows.
let card_y = tick_y
.saturating_sub(card_h / 2)
.max(scrollback_area.y)
.min(
(scrollback_area.y + scrollback_area.height)
.saturating_sub(card_h)
.min(buf.area.height.saturating_sub(card_h)),
);
let card_area = Rect::new(card_x, card_y, card_w, card_h);
let bg = theme.bg_base;
ratatui::widgets::Widget::render(ratatui::widgets::Clear, card_area, buf);
buf.set_style(card_area, Style::default().bg(bg));
let block = ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(theme.gray).bg(bg));
let inner = block.inner(card_area);
ratatui::widgets::Widget::render(block, card_area, buf);
let text_style = Style::default().fg(theme.text_primary).bg(bg);
for (i, line) in lines.iter().enumerate() {
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
buf.set_line(
inner.x + 1,
y,
&Line::from(Span::styled(line.clone(), text_style)),
text_w,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn area() -> Rect {
Rect {
x: 0,
y: 2,
width: 80,
height: 20,
}
}
/// `compute_rail` with adjacent targets for an off-bottom prompt row.
fn rail(turn_count: usize, active: Option<usize>) -> Option<TimelineRail> {
let vp = RailViewport {
active,
up_target: active.and_then(|a| a.checked_sub(1)),
down_target: active.and_then(|a| (a + 1 < turn_count).then_some(a + 1)),
at_bottom: false,
};
compute_rail(area(), 76, turn_count, vp)
}
#[test]
fn rail_hidden_below_min_turns_or_tiny_area() {
assert!(rail(1, Some(0)).is_none());
let tiny = Rect {
height: 2,
..area()
};
let vp = RailViewport {
active: Some(0),
down_target: Some(1),
..RailViewport::default()
};
assert!(compute_rail(tiny, 76, 5, vp).is_none());
}
#[test]
fn small_conversation_shows_all_ticks_centered() {
let rail = rail(4, Some(1)).unwrap();
assert_eq!(rail.window, 0..4);
// 4 ticks + 2 chevrons = 6 rows centered in 20: top = 2 + 7 = 9.
assert_eq!(rail.up_y, 9);
assert_eq!(rail.ticks_y, 10);
assert_eq!(rail.down_y, 14);
}
#[test]
fn overflow_windows_around_active() {
// 50 turns, 18 tick rows (20 - 2 chevrons).
let rail = rail(50, Some(25)).unwrap();
assert_eq!(rail.window.len(), 18);
assert!(rail.window.contains(&25));
// Window is roughly centered on the active turn, and tick rows map
// to window-relative turn indices.
assert_eq!(rail.window.start, 25 - 9);
assert_eq!(
rail.hit(76, rail.ticks_y),
Some(TimelineHit::Tick(rail.window.start))
);
// Active at the end clamps the window to the tail.
let rail = self::rail(50, Some(49)).unwrap();
assert_eq!(rail.window, 32..50);
// No active turn anchors to the newest.
let rail = self::rail(50, None).unwrap();
assert_eq!(rail.window, 32..50);
// At the bottom the window prefers the tail, but still includes the
// viewport-top (active) turn so a tick stays highlighted.
let rail = compute_rail(
area(),
76,
50,
RailViewport {
active: Some(25),
up_target: Some(24),
down_target: Some(26),
at_bottom: true,
},
)
.unwrap();
assert_eq!(rail.window, 25..43);
assert!(rail.window.contains(&25));
// Active already in the tail → pin to the newest ticks.
let rail = compute_rail(
area(),
76,
50,
RailViewport {
active: Some(40),
up_target: Some(39),
down_target: Some(41),
at_bottom: true,
},
)
.unwrap();
assert_eq!(rail.window, 32..50);
assert!(rail.window.contains(&40));
}
#[test]
fn hit_maps_chevrons_and_ticks() {
let rail = rail(4, Some(1)).unwrap();
// Outside the rail columns (width 2: cols 76-77).
assert_eq!(rail.hit(75, rail.ticks_y), None);
assert_eq!(rail.hit(78, rail.ticks_y), None);
// Chevrons.
assert_eq!(rail.hit(77, rail.up_y), Some(TimelineHit::Up));
assert_eq!(rail.hit(77, rail.down_y), Some(TimelineHit::Down));
// Ticks map window-relative rows to turn indices.
assert_eq!(rail.hit(76, rail.ticks_y), Some(TimelineHit::Tick(0)));
assert_eq!(rail.hit(77, rail.ticks_y + 3), Some(TimelineHit::Tick(3)));
// Rows between chevrons/ticks and rail edges miss.
assert_eq!(rail.hit(76, rail.up_y - 1), None);
}
#[test]
fn chevron_targets_follow_the_rail_state() {
use TimelineHit::{Down, Tick, Up};
let mid = rail(10, Some(3)).unwrap();
// Ticks jump to themselves.
assert_eq!(chevron_target(&mid, Tick(7)), Some(7));
// Chevrons take the rail's viewport-derived targets verbatim.
assert_eq!(chevron_target(&mid, Up), Some(2));
assert_eq!(chevron_target(&mid, Down), Some(4));
// End stops are no-ops (the dim chevrons).
assert_eq!(chevron_target(&rail(10, Some(0)).unwrap(), Up), None);
assert_eq!(chevron_target(&rail(10, Some(9)).unwrap(), Down), None);
// Pre-turn content focuses the first tick, but Down still enters
// that first turn rather than skipping to the second.
let pre = compute_rail(
area(),
76,
10,
RailViewport {
active: Some(0),
down_target: Some(0),
..RailViewport::default()
},
)
.unwrap();
assert_eq!(chevron_target(&pre, Down), Some(0));
assert_eq!(chevron_target(&pre, Up), None);
// At the bottom ▼ still steps to the next turn (jump_to_turn
// over-scrolls it to the top, matching a tick click); ▲ steps up.
let bottom = compute_rail(
area(),
76,
10,
RailViewport {
active: Some(4),
up_target: Some(3),
down_target: Some(5),
at_bottom: true,
},
)
.unwrap();
assert_eq!(chevron_target(&bottom, Up), Some(3));
assert_eq!(chevron_target(&bottom, Down), Some(5));
// ▼ dims only when the last turn already owns the top.
let last = compute_rail(
area(),
76,
10,
RailViewport {
active: Some(9),
up_target: Some(8),
down_target: None,
at_bottom: true,
},
)
.unwrap();
assert_eq!(chevron_target(&last, Down), None);
}
#[test]
fn rail_width_gates_eligibility() {
// All conditions met → rail columns reserved.
assert_eq!(rail_width(true, false, 80, 5), RAIL_WIDTH);
// Setting off / subagent view / narrow terminal / too few turns.
assert_eq!(rail_width(false, false, 80, 5), 0);
assert_eq!(rail_width(true, true, 80, 5), 0);
assert_eq!(rail_width(true, false, MIN_TERMINAL_WIDTH - 1, 5), 0);
assert_eq!(rail_width(true, false, 80, 1), 0);
}
}
@@ -0,0 +1,561 @@
//! Todo pane — renders `TodoItem`s from `kigi-tools` in a `ListPane`.
//!
//! Wraps the canonical `TodoItem` type with a `ListItem` implementation
//! that provides status-icon prefixes and styled content.
use kigi_shell::tools::{TodoItem, TodoStatus};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use super::list_pane::ListItem;
// ---------------------------------------------------------------------------
// TodoPaneStyle — per-status colors
// ---------------------------------------------------------------------------
/// Visual style for each todo status.
#[derive(Debug, Clone, Copy)]
pub struct TodoStatusStyle {
/// Color for the status icon.
pub icon_fg: Color,
/// Style for the content text.
pub text_style: Style,
}
/// Full style configuration for the todo pane.
#[derive(Debug, Clone, Copy)]
pub struct TodoPaneStyle {
pub pending: TodoStatusStyle,
pub in_progress: TodoStatusStyle,
pub completed: TodoStatusStyle,
pub cancelled: TodoStatusStyle,
}
impl Default for TodoPaneStyle {
fn default() -> Self {
// Sourced from theme to ensure colors are quantized for terminal compat.
let theme = crate::theme::Theme::current();
Self {
pending: TodoStatusStyle {
icon_fg: theme.text_primary,
text_style: Style::default().fg(theme.text_primary),
},
in_progress: TodoStatusStyle {
icon_fg: theme.warning,
text_style: Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD),
},
completed: TodoStatusStyle {
icon_fg: theme.accent_success,
text_style: Style::default().fg(theme.gray_bright),
},
cancelled: TodoStatusStyle {
icon_fg: theme.accent_error,
text_style: Style::default()
.fg(theme.gray_bright)
.add_modifier(Modifier::CROSSED_OUT),
},
}
}
}
// ---------------------------------------------------------------------------
// TodoListEntry — ListItem wrapper around TodoItem
// ---------------------------------------------------------------------------
/// A `TodoItem` wrapped for display in a `ListPane`.
///
/// Caches the styled `Line` for `content()` and generates a status-icon
/// `prefix()` per frame.
#[derive(Debug, Clone)]
pub struct TodoListEntry {
/// Unique ID (index in the todo list, or a stable ID from the model).
pub id: u64,
/// The canonical todo item.
pub item: TodoItem,
/// Cached styled content line.
styled: Line<'static>,
/// The style to use for this entry's status.
status_style: TodoStatusStyle,
}
impl TodoListEntry {
/// Create a new entry from a `TodoItem`.
pub fn new(id: u64, item: TodoItem, style: &TodoPaneStyle) -> Self {
let status_style = match item.status {
TodoStatus::Pending => style.pending,
TodoStatus::InProgress => style.in_progress,
TodoStatus::Completed => style.completed,
TodoStatus::Cancelled => style.cancelled,
};
let styled = Line::from(Span::styled(item.content.clone(), status_style.text_style));
Self {
id,
item,
styled,
status_style,
}
}
/// Status icon for the current status.
fn icon(&self) -> &'static str {
match self.item.status {
TodoStatus::Pending => "",
TodoStatus::InProgress => "",
TodoStatus::Completed => crate::glyphs::check_mark(),
TodoStatus::Cancelled => crate::glyphs::ballot_x(),
}
}
}
impl ListItem for TodoListEntry {
fn content(&self) -> &Line<'_> {
&self.styled
}
fn prefix(&self) -> Option<Line<'_>> {
let icon = self.icon();
Some(Line::from(vec![
Span::styled(icon, Style::default().fg(self.status_style.icon_fg)),
Span::raw(" "),
]))
}
fn stable_id(&self) -> u64 {
self.id
}
fn search_text(&self) -> &str {
&self.item.content
}
}
// ---------------------------------------------------------------------------
// TodoPane — self-contained pane owning items, state, and rendering
// ---------------------------------------------------------------------------
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, MouseEventKind};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::StatefulWidget;
use crate::appearance::LayoutConfig;
use crate::theme::ThemeKind;
use super::list_pane::{ListPane, ListPaneConfig, ListPaneState, ListPaneStyle, WrapMode};
use super::overlay::OverlayState;
// ---------------------------------------------------------------------------
// TodoCounts — aggregate status counts for the badge
// ---------------------------------------------------------------------------
/// Counts of todo items by status.
///
/// Used by the status bar badge to show plan progress at a glance.
/// Counts ALL items regardless of `show_done` filter.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TodoCounts {
pub in_progress: usize,
pub pending: usize,
pub completed: usize,
pub cancelled: usize,
}
impl TodoCounts {
/// Total number of items across all statuses.
pub fn total(&self) -> usize {
self.in_progress + self.pending + self.completed + self.cancelled
}
/// Tasks that count toward completion progress — every status except
/// cancelled. Used as the denominator of the status-bar `done/total`
/// badge so cancelled tasks don't keep it from reaching `N/N`.
pub fn total_excluding_cancelled(&self) -> usize {
self.in_progress + self.pending + self.completed
}
}
fn empty_placeholder_message(todos_empty: bool, counts: TodoCounts) -> String {
if todos_empty {
return "No todo items.".into();
}
match (counts.completed, counts.cancelled) {
(_, 0) => "All done.".into(),
(0, c) => format!("{c} cancelled."),
(d, c) => format!("{d} done. {c} cancelled."),
}
}
/// Duration for the badge flash animation on count changes.
const BADGE_FLASH_DURATION: Duration = Duration::from_millis(1200);
/// Absolute maximum height (in lines) the todo pane will request.
const MAX_TODO_HEIGHT: u16 = 10;
/// Maximum fraction of total view height the todo pane may occupy.
const MAX_TODO_FRACTION: f32 = 0.15;
/// Self-contained todo pane component.
///
/// Owns the raw `TodoItem` data, the filtered `TodoListEntry` cache,
/// `ListPaneState`, and style config. `AgentView` holds a single
/// `TodoPane` and delegates input/render to it.
pub struct TodoPane {
/// Raw todo items from ACP Plan updates.
todos: Vec<TodoItem>,
/// Filtered + styled entries for `ListPane` rendering.
/// Rebuilt from `todos` at the start of each `render()` call.
entries: Vec<TodoListEntry>,
/// List pane state (scroll, selection, search, layout cache).
pub list_state: ListPaneState,
/// Per-status visual style for icons and text.
style: TodoPaneStyle,
/// Visual style for the list pane framework (selection bg, etc.).
list_style: ListPaneStyle,
/// Whether to show completed/cancelled items.
show_done: bool,
/// Shared visibility/focus state.
pub overlay: OverlayState,
/// Previous counts snapshot for flash-on-change detection.
prev_counts: TodoCounts,
/// When the badge flash animation expires (500ms after a count change).
badge_flash_until: Option<Instant>,
/// Last theme kind seen — used to detect theme switches and restyle.
last_theme: ThemeKind,
}
impl Default for TodoPane {
fn default() -> Self {
Self::new()
}
}
impl TodoPane {
/// Create a new empty todo pane.
pub fn new() -> Self {
let config = ListPaneConfig {
follow_enabled: false,
wrap_toggle_enabled: false,
search_enabled: true,
copy_enabled: true,
show_selection_when_unfocused: false,
visual_select_enabled: false,
filter_enabled: true,
goto_line_enabled: false,
};
let mut list_state = ListPaneState::new_with_config(WrapMode::NoWrap, false, config);
list_state.set_clipboard_provider(Box::new(crate::clipboard::SystemClipboard));
Self {
todos: Vec::new(),
entries: Vec::new(),
list_state,
style: TodoPaneStyle::default(),
list_style: ListPaneStyle::default(),
show_done: true,
// Starts hidden — auto-shows when items arrive via update_todos.
overlay: OverlayState::hidden(),
prev_counts: TodoCounts::default(),
badge_flash_until: None,
last_theme: crate::theme::Theme::current_kind(),
}
}
// -- Data management -----------------------------------------------------
/// Read-only access to the current todo items.
pub fn todos(&self) -> &[TodoItem] {
&self.todos
}
/// Replace all todo items (called from ACP Plan handler).
///
/// Does NOT auto-show the todo pane — the badge in the status bar is
/// the primary indicator. Users toggle the pane with Ctrl-T or by
/// clicking the badge.
///
/// Triggers a badge flash when counts change (including first arrival).
pub fn update_todos(&mut self, items: Vec<TodoItem>) {
let new_counts = Self::compute_counts(&items);
if new_counts != self.prev_counts {
self.badge_flash_until = Some(Instant::now() + BADGE_FLASH_DURATION);
}
self.prev_counts = new_counts;
self.todos = items;
}
/// Compute status counts from a list of items.
fn compute_counts(items: &[TodoItem]) -> TodoCounts {
let mut c = TodoCounts::default();
for item in items {
match item.status {
TodoStatus::InProgress => c.in_progress += 1,
TodoStatus::Pending => c.pending += 1,
TodoStatus::Completed => c.completed += 1,
TodoStatus::Cancelled => c.cancelled += 1,
}
}
c
}
/// Current status counts (across ALL items, ignoring `show_done`).
pub fn counts(&self) -> TodoCounts {
self.prev_counts
}
/// Whether the badge flash animation is currently active.
pub fn badge_flash_active(&self) -> bool {
self.badge_flash_until.is_some_and(|t| Instant::now() < t)
}
/// Whether the badge needs animation ticks (flash expiry).
pub fn badge_needs_tick(&self) -> bool {
self.badge_flash_until.is_some()
}
/// Advance badge flash timer. Returns `true` if a redraw is needed
/// (flash just expired).
pub fn badge_tick(&mut self) -> bool {
if let Some(t) = self.badge_flash_until
&& Instant::now() >= t
{
self.badge_flash_until = None;
return true;
}
false
}
/// Test-only: backdate an armed badge flash so it reads as expired, letting
/// a single `badge_tick()` clear it deterministically (no 1200ms sleep).
#[cfg(test)]
pub(crate) fn expire_badge_flash_for_test(&mut self) {
if self.badge_flash_until.is_some() {
self.badge_flash_until = Some(Instant::now() - Duration::from_millis(1));
}
}
/// Whether the pane should be visible in the layout.
///
/// Visible when overlay is shown. When empty, shows a placeholder
/// message instead of the list pane.
pub fn is_visible(&self) -> bool {
self.overlay.visible
}
/// Called after overlay state changes that hide the pane.
///
/// Closes the input bar if it's actively open (mid-typing), but
/// preserves any accepted search/filter so it persists across
/// show/hide cycles.
pub fn on_state_change(&mut self) {
if !self.overlay.visible {
self.list_state.close_input_bar();
}
}
/// Whether completed/cancelled items are shown.
pub fn show_done(&self) -> bool {
self.show_done
}
/// Toggle visibility of completed/cancelled items.
pub fn toggle_show_done(&mut self) {
self.show_done = !self.show_done;
}
/// Desired height in lines for layout computation.
///
/// Returns 0 when hidden (overlay not visible or no items).
/// When visible but empty, returns 2 (for placeholder message).
/// Otherwise: `min(10, 15% of view_height)` but at least 1.
pub fn desired_height(&self, view_height: u16) -> u16 {
if !self.overlay.visible {
return 0;
}
let count = self.visible_count();
if count == 0 {
return 1;
}
let fraction_cap = (view_height as f32 * MAX_TODO_FRACTION).floor() as u16;
let max = MAX_TODO_HEIGHT.min(fraction_cap).max(1);
(count as u16).min(max).max(1)
}
/// Number of items that pass the `show_done` filter.
fn visible_count(&self) -> usize {
if self.show_done {
self.todos.len()
} else {
self.todos
.iter()
.filter(|t| !matches!(t.status, TodoStatus::Completed | TodoStatus::Cancelled))
.count()
}
}
/// Rebuild `entries` from `todos`, filtered by `show_done`.
///
/// Items preserve their original order from the agent (no status-based
/// reordering). IDs are based on the original index in `todos` so that
/// `ListPaneState` can maintain selection across rebuilds.
fn rebuild_entries(&mut self) {
self.entries.clear();
for (idx, item) in self.todos.iter().enumerate() {
if !self.show_done
&& matches!(item.status, TodoStatus::Completed | TodoStatus::Cancelled)
{
continue;
}
self.entries
.push(TodoListEntry::new(idx as u64, item.clone(), &self.style));
}
}
// -- Input handling ------------------------------------------------------
/// Handle a key event when the todo pane is focused.
///
/// Returns `true` if the event was consumed.
pub fn handle_key(&mut self, key: &KeyEvent) -> bool {
// 'h' toggles show_done (only when not typing in search/filter bar).
if key.code == KeyCode::Char('h') && self.list_state.input_mode().is_none() {
self.toggle_show_done();
self.rebuild_entries();
return true;
}
// Don't route to ListPaneState when empty.
if self.entries.is_empty() {
return false;
}
self.list_state.handle_key_event(key, &self.entries)
}
/// Handle a mouse scroll event over the todo pane area.
///
/// Caps scroll speed for small viewports — the app-level scroll
/// accumulator can produce large deltas (3-5 lines) which would
/// jump past most items in a 4-row pane.
pub fn handle_scroll(&mut self, lines: i32, col: u16, row: u16) {
let max = match self.list_state.viewport_height() {
0..=5 => 1,
6..=10 => 2,
_ => lines.unsigned_abs() as i32,
};
let capped = lines.signum() * lines.abs().min(max);
self.list_state
.handle_scroll_event(capped, col, row, &self.entries);
}
/// Handle a mouse click/drag event over the todo pane area.
///
/// Returns `true` if the event was consumed.
pub fn handle_mouse(&mut self, kind: MouseEventKind, col: u16, row: u16, area: Rect) -> bool {
self.list_state
.handle_mouse_event(kind, col, row, area, &self.entries)
}
// -- Rendering -----------------------------------------------------------
/// Compute the inner content area with horizontal padding matching
/// the scrollback's `HorizontalLayout` (accent + block_pad_left on
/// the left, block_pad_right on the right).
fn content_area(area: Rect, layout_cfg: &LayoutConfig) -> Rect {
use crate::scrollback::layout::HorizontalLayout;
let pad_left = HorizontalLayout::ACCENT + layout_cfg.block_pad_left;
let pad_right = layout_cfg.block_pad_right;
Rect {
x: area.x + pad_left,
y: area.y,
width: area.width.saturating_sub(pad_left + pad_right),
height: area.height,
}
}
/// Render the todo pane into the given area.
///
/// Rebuilds entries from `todos` each call (cheap — typically <20 items),
/// runs layout, and renders the `ListPane` widget in a padded inner area
/// matching the scrollback's horizontal layout.
pub fn render(
&mut self,
area: Rect,
buf: &mut Buffer,
focused: bool,
layout_cfg: &LayoutConfig,
) {
// Detect theme switch and refresh styles before rebuilding entries.
let current_theme = crate::theme::Theme::current_kind();
if current_theme != self.last_theme {
self.last_theme = current_theme;
self.style = TodoPaneStyle::default();
self.list_style = ListPaneStyle::default();
}
self.rebuild_entries();
let inner = Self::content_area(area, layout_cfg);
if self.entries.is_empty() {
// Empty state: placeholder message in muted style.
if inner.height > 0 && inner.width > 0 {
let msg = empty_placeholder_message(self.todos.is_empty(), self.counts());
let theme = crate::theme::Theme::current();
let span = ratatui::text::Span::styled(
msg,
ratatui::style::Style::default().fg(theme.gray_bright),
);
buf.set_span(inner.x, inner.y, &span, inner.width);
}
return;
}
self.list_state
.prepare_layout(&self.entries, inner.width, inner.height);
ListPane::new(&self.entries)
.focused(focused)
.style(self.list_style)
.render(inner, buf, &mut self.list_state);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn counts(completed: usize, cancelled: usize) -> TodoCounts {
TodoCounts {
completed,
cancelled,
..TodoCounts::default()
}
}
#[test]
fn empty_todos_message() {
assert_eq!(
empty_placeholder_message(true, TodoCounts::default()),
"No todo items."
);
}
#[test]
fn all_completed_is_all_done() {
assert_eq!(empty_placeholder_message(false, counts(3, 0)), "All done.");
}
#[test]
fn mixed_done_and_cancelled_summarizes_counts() {
assert_eq!(
empty_placeholder_message(false, counts(5, 1)),
"5 done. 1 cancelled."
);
}
#[test]
fn only_cancelled() {
assert_eq!(
empty_placeholder_message(false, counts(0, 2)),
"2 cancelled."
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,377 @@
//! Hero box component — side-by-side logo + menu inside a bordered box.
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Flex, Layout, Position, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, BorderType, Borders, Widget};
use crate::theme::Theme;
use super::WelcomeLayout;
/// Minimum terminal width for the side-by-side hero box layout.
pub(super) const HERO_BOX_MIN_WIDTH: u16 = 90;
/// Vertical padding (rows) between the box border and its inner content.
const V_PAD: u16 = 1;
/// Horizontal inset (cols) between the right column's content and the box
/// border; also the collapsed left-column width when the logo is hidden.
const H_INSET: u16 = 2;
/// Horizontal gap (cols) between the logo and the right column inside the box.
const LOGO_H_PAD: u16 = 3;
const HERO_SUBTITLE: &str = "Thanks for trying Grok Build, give feedback with /feedback!";
use super::{PROMPT_HEIGHT, VERSION_GAP};
/// Rows the "thanks" subtitle occupies. Hidden when the in-box info slot
/// (changelog) is shown, to keep the box compact.
fn subtitle_rows(info_height: u16) -> u16 {
if info_height > 0 { 0 } else { 1 }
}
/// Height of the hero box's right column: version + optional subtitle +
/// optional info block + the gap before the menu + the menu itself.
fn right_col_height(menu_height: u16, info_height: u16) -> u16 {
let info_gap = if info_height > 0 { 1u16 } else { 0 };
// version(1) + subtitle + [info_gap + info] + gap-before-menu(1) + menu
1 + subtitle_rows(info_height) + info_gap + info_height + 1 + menu_height
}
/// Minimum content-area height the hero box needs to render without truncating:
/// the optional error row, the box, a one-row flex gap, and the fixed rows
/// below (tip + prompt + version). The box always shows the full-height logo,
/// so a terminal shorter than this falls back to the stacked layout instead of
/// overflowing.
pub(super) fn min_content_height(
error_height: u16,
menu_height: u16,
tip_height: u16,
info_height: u16,
) -> u16 {
let inner = super::logo::full_logo_line_count().max(right_col_height(menu_height, info_height));
let hero_box_height = 2 + V_PAD * 2 + inner;
let gap_after_error = if error_height > 0 { 1u16 } else { 0 };
gap_after_error + error_height + hero_box_height + 1 + WelcomeLayout::fixed_below(tip_height)
}
/// Width (cols) of the hero box's left (logo) column, including padding.
/// Collapses to a small inset when the logo is hidden.
fn left_col_width() -> u16 {
let logo_width = super::logo::full_logo_visual_width();
if logo_width == 0 {
H_INSET
} else {
logo_width + LOGO_H_PAD.saturating_sub(1) + LOGO_H_PAD
}
}
/// Compute the hero box layout: bordered box with logo left, version + menu right.
///
/// Sizes the in-box info slot here (the fixed `changelog_height`) so the
/// renderer just draws into `hero_info`.
pub(super) fn compute_hero_box(
content_area: Rect,
error_height: u16,
menu_height: u16,
tip_height: u16,
changelog_height: u16,
) -> WelcomeLayout {
let zero = Rect::default();
let tip_gap = if tip_height > 0 { 1u16 } else { 0 };
let fixed_below = WelcomeLayout::fixed_below(tip_height);
// Column widths are height-independent, so derive them once and reuse for
// both the measurement and the rects: `hero_info.width == info_slot_width`,
// i.e. measured == drawn.
let box_width = content_area.width.saturating_sub(6).min(120);
let inner_width = box_width.saturating_sub(2);
let left_col_width = left_col_width();
let right_width = inner_width.saturating_sub(left_col_width);
let info_slot_width = right_width.saturating_sub(H_INSET);
let info_height = changelog_height;
let logo_rows = super::logo::full_logo_line_count();
let info_gap = if info_height > 0 { 1u16 } else { 0 };
let inner_height = logo_rows.max(right_col_height(menu_height, info_height));
let hero_box_height = 2 + V_PAD * 2 + inner_height;
let gap_after_error = if error_height > 0 { 1 } else { 0 };
let fixed_above = gap_after_error + error_height;
// Top padding for vertical centering (use the default menu height so the
// logo position stays constant regardless of picker/focus state).
let default_menu_height = 4u16;
let default_inner = logo_rows.max(right_col_height(default_menu_height, info_height));
let default_hero = 2 + V_PAD * 2 + default_inner;
let remaining = content_area.height.saturating_sub(fixed_above);
let top_pad = remaining
.saturating_sub(default_hero)
.saturating_sub(fixed_below)
/ 3;
// Centering derives top_pad from the default-menu box, but the fit gate
// (min_content_height) sizes for the actual box with no pad. Clamp to the
// real slack so a taller-than-default menu can't push the rows below the
// box off the bottom at the tight boundary.
let top_pad = top_pad.min(
content_area
.height
.saturating_sub(fixed_above + hero_box_height + 1 + fixed_below),
);
let [
_,
_,
error,
hero_box_slot,
_,
tip,
_,
prompt,
_,
version_slot,
] = Layout::vertical([
Constraint::Length(top_pad),
Constraint::Length(gap_after_error),
Constraint::Length(error_height),
Constraint::Length(hero_box_height),
Constraint::Min(1), // flex gap
Constraint::Length(tip_height),
Constraint::Length(tip_gap),
Constraint::Length(PROMPT_HEIGHT),
Constraint::Length(VERSION_GAP),
Constraint::Length(1),
])
.areas(content_area);
// Horizontally center the hero box (`box_width` derived above).
let [_, hero_box, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(box_width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(hero_box_slot);
// Inner area inside the border + v_pad. Widths reuse the values above; only
// x/y come from the laid-out box.
let inner = Rect {
x: hero_box.x + 1,
y: hero_box.y + 1 + V_PAD,
width: inner_width,
height: inner_height,
};
// Left column: balanced padding around the logo; collapses to a small
// inset when the logo is hidden.
let logo_width = super::logo::full_logo_visual_width();
// Logo body leans right; shave a column off the left pad to optically center.
let logo_left_pad = LOGO_H_PAD.saturating_sub(1);
// Logo top-aligned, horizontally centered within left column.
let hero_logo = Rect {
x: inner.x + logo_left_pad,
y: inner.y,
width: logo_width.min(inner.width.saturating_sub(logo_left_pad)),
height: logo_rows.min(inner.height),
};
// Right column: rest of inner width after left column.
let right_x = inner.x + left_col_width;
// Version line at top of right column.
let hero_version = Rect {
x: right_x,
y: inner.y,
width: right_width,
height: 1,
};
// Subtitle line below version — hidden when the info slot is shown.
let hero_subtitle = if subtitle_rows(info_height) > 0 {
Rect {
x: right_x,
y: inner.y + 1,
width: right_width,
height: 1,
}
} else {
zero
};
// Info block (changelog) below version + optional subtitle.
let info_y = inner.y + 1 + subtitle_rows(info_height) + info_gap;
let hero_info = if info_height > 0 {
Rect {
x: right_x,
y: info_y,
width: info_slot_width,
height: info_height,
}
} else {
zero
};
// version + subtitle + info_gap + info + gap-before-menu
let right_header_rows = 1 + subtitle_rows(info_height) + info_gap + info_height + 1;
// Menu below the header rows, left-aligned in right column.
let hero_menu = Rect {
x: right_x,
y: inner.y + right_header_rows,
width: info_slot_width,
height: menu_height.min(inner.height.saturating_sub(right_header_rows)),
};
WelcomeLayout {
logo: zero,
error,
menu: zero,
changelog: zero,
tip,
prompt,
version: version_slot,
hero_box,
hero_logo,
hero_version,
hero_subtitle,
hero_info,
hero_menu,
}
}
/// Changelog content shown in the hero box info slot.
pub(super) struct ChangelogDisplay<'a> {
pub(super) bullets: &'a [String],
pub(super) has_full_notes: bool,
}
/// Hit-test rects produced by [`render_hero_box`].
pub(super) struct HeroBoxRects {
/// Hit-test rect per menu item row (for click/hover).
pub(super) menu_rects: Vec<Rect>,
/// Clickable changelog info block, if drawn.
pub(super) changelog_cta_rect: Option<Rect>,
}
/// Render the bordered hero box with logo left, version + subtitle + menu right.
pub(super) fn render_hero_box(
layout: &WelcomeLayout,
buf: &mut Buffer,
theme: &Theme,
menu_items: &[(&str, &str)],
selected: Option<usize>,
mouse_pos: Option<(u16, u16)>,
changelog: ChangelogDisplay<'_>,
) -> HeroBoxRects {
// Dim the box border toward the background for a softer, dimmer gray.
let border_color = crate::render::color::blend_color(theme.bg_base, theme.gray_dim, 0.45)
.unwrap_or(theme.gray_dim);
let border_block = Block::new()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color));
border_block.render(layout.hero_box, buf);
super::logo::render_full_logo(layout.hero_logo, buf, theme);
super::render_version_badge(
layout.hero_version,
buf,
theme,
None,
0,
false,
super::VersionBadgeMode::HeroInline,
);
// Subtitle line below the version.
if layout.hero_subtitle.height > 0 {
let subtitle_style = Style::default().fg(theme.gray);
buf.set_span(
layout.hero_subtitle.x,
layout.hero_subtitle.y,
&Span::styled(HERO_SUBTITLE, subtitle_style),
layout.hero_subtitle.width,
);
}
// In-box info slot: the changelog, always in this same position.
let mut changelog_cta_rect = None;
if layout.hero_info.height > 0 && !changelog.bullets.is_empty() {
changelog_cta_rect = render_hero_changelog(
buf,
theme,
layout.hero_info,
changelog.bullets,
changelog.has_full_notes,
mouse_pos,
);
}
let menu_rects = super::menu::render_menu(
layout.hero_menu,
buf,
theme,
menu_items,
selected,
mouse_pos,
layout.hero_menu.width,
);
HeroBoxRects {
menu_rects,
changelog_cta_rect,
}
}
/// Render the changelog block (header + bullets) in the info slot. When
/// `clickable` (full notes exist), the whole block opens the notes on click and
/// brightens while hovered; returns that clickable rect.
fn render_hero_changelog(
buf: &mut Buffer,
theme: &Theme,
area: Rect,
bullets: &[String],
clickable: bool,
mouse_pos: Option<(u16, u16)>,
) -> Option<Rect> {
if area.width == 0 || area.height == 0 {
return None;
}
let hovered =
clickable && mouse_pos.is_some_and(|(mx, my)| area.contains(Position::new(mx, my)));
let header_style = super::hover_style(
theme,
hovered,
Style::default()
.fg(theme.gray_bright)
.add_modifier(Modifier::DIM),
);
let title = "Changelog";
buf.set_span(
area.x,
area.y,
&Span::styled(title, header_style),
area.width,
);
// Bullets start 2 rows down (header + blank), matching the height budget.
let bullet_style = super::hover_style(theme, hovered, Style::default().fg(theme.gray_bright));
let max_text_width = area.width.saturating_sub(4) as usize; // " • " prefix + pad
for (i, bullet) in bullets.iter().enumerate() {
let row = area.y + 2 + i as u16;
if row >= area.y + area.height {
break;
}
let truncated = crate::render::line_utils::truncate_str(bullet, max_text_width);
let text = format!(" \u{2022} {truncated}");
buf.set_span(area.x, row, &Span::styled(text, bullet_style), area.width);
}
clickable.then_some(area)
}
@@ -0,0 +1,318 @@
//! Logo component — renders the braille art logo.
//!
//! Hidden entirely on legacy Windows consoles: the U+2800 braille block is
//! not covered by the ConHost raster fonts and would render as tofu.
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
use crate::render::color::blend_color;
use crate::theme::Theme;
const LOGO: &str = include_str!("../../../assets/logo/logo07.txt");
const LOGO_SMALL: &str = include_str!("../../../assets/logo/logo05.txt");
/// Height at or above which the small logo is shown (below it, no logo).
const SMALL_LOGO_MIN_HEIGHT: u16 = 22;
/// Height at or above which the full logo is shown.
const FULL_LOGO_MIN_HEIGHT: u16 = 26;
fn pick_logo(window_height: u16) -> Option<&'static str> {
pick_logo_for(window_height, logo_hidden())
}
/// Pure tier selection so tests can drive the legacy-console flag directly.
fn pick_logo_for(window_height: u16, hidden: bool) -> Option<&'static str> {
if hidden || window_height < SMALL_LOGO_MIN_HEIGHT {
None
} else if window_height < FULL_LOGO_MIN_HEIGHT {
Some(LOGO_SMALL)
} else {
Some(LOGO)
}
}
/// The braille art has no ASCII stand-in; see the module doc.
fn logo_hidden() -> bool {
crate::glyphs::is_legacy_windows_console()
}
fn non_empty_lines(logo: &str) -> impl Iterator<Item = &str> {
logo.lines().filter(|l| !l.is_empty())
}
fn count_lines(logo: &str) -> u16 {
non_empty_lines(logo).count() as u16
}
fn visual_width(logo: &str) -> u16 {
non_empty_lines(logo)
.map(unicode_width::UnicodeWidthStr::width)
.max()
.unwrap_or(24) as u16
}
/// Animation phase in seconds since the first render. Wall-clock based so the
/// shimmer speed is independent of the frame rate.
fn anim_phase_secs() -> f32 {
use std::sync::OnceLock;
use std::time::Instant;
static START: OnceLock<Instant> = OnceLock::new();
START.get_or_init(Instant::now).elapsed().as_secs_f32()
}
/// Shimmer redraw cadence in frames per second. The sweep is slow, so a few fps
/// looks smooth while sparing the long-lived welcome screen from full-rate
/// repaints.
const SHIMMER_FPS: f32 = 12.0;
/// Quantized shimmer frame for the current wall-clock phase. The welcome screen
/// redraws only when this advances, throttling the animation to ~`SHIMMER_FPS`
/// rather than the full event-loop tick rate. Pinned to 0 when the logo is
/// hidden.
pub fn shimmer_frame() -> u64 {
if logo_hidden() {
return 0;
}
(anim_phase_secs() * SHIMMER_FPS) as u64
}
/// Per-glyph shine opacity in `[0, 1]` at normalized diagonal position `diag`
/// (0 = bottom-left .. 1 = top-right) and animation time `secs`. A raised-cosine
/// band sweeps bottom-left → top-right and parks off-screen between sweeps; a
/// gentle global pulse breathes underneath it. 0 keeps the resting gray, 1 is
/// full bright.
fn shine_opacity(diag: f32, secs: f32) -> f32 {
const BAND: f32 = 0.38; // half-width of the shine band — wider = more gradual falloff
const CYCLE: f32 = 4.0; // seconds per sweep + rest
const SWEEP_FRAC: f32 = 0.32; // portion of the cycle spent sweeping (~1.3s glint, rest idles)
const SHINE: f32 = 0.33; // peak shine strength
const PULSE: f32 = 0.06; // global breathing amount
const PULSE_SECS: f32 = 5.0; // breathing period
let p = (secs % CYCLE) / CYCLE;
let q = (p / SWEEP_FRAC).min(1.0); // parks the band off-screen during the rest
let band_pos = -BAND + q * (1.0 + 2.0 * BAND);
let pulse = PULSE * (0.5 - 0.5 * (std::f32::consts::TAU * secs / PULSE_SECS).cos());
let d = (diag - band_pos).abs();
let shine = if d < BAND {
0.5 * (1.0 + (std::f32::consts::PI * d / BAND).cos())
} else {
0.0
};
(pulse + SHINE * shine).clamp(0.0, 1.0)
}
fn render_into(area: Rect, buf: &mut Buffer, theme: &Theme, logo: &str) {
let lines: Vec<&str> = non_empty_lines(logo).collect();
let rows = lines.len().max(1) as f32;
let cols = lines
.iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(1)
.max(1) as f32;
let secs = anim_phase_secs();
// Blend each glyph from the resting gray toward the bright text color by its
// shine opacity, so a sheen sweeps across the braille art. Adjacent glyphs
// that land on the same blended color share one Span to hold down the
// per-frame allocation.
let base = theme.gray;
let hilite = theme.text_primary;
let logo_lines: Vec<Line> = lines
.iter()
.enumerate()
.map(|(row, line)| {
let mut spans: Vec<Span> = Vec::new();
let mut run = String::new();
let mut run_color: Option<Color> = None;
for (col, ch) in line.chars().enumerate() {
// Sweep along the bottom-left → top-right diagonal: the
// coordinate grows as col increases and row decreases.
let diag = (col as f32 + (rows - 1.0 - row as f32)) / (cols + rows);
let color = blend_color(base, hilite, shine_opacity(diag, secs)).unwrap_or(base);
if run_color != Some(color) {
if let Some(prev) = run_color {
spans.push(Span::styled(
std::mem::take(&mut run),
Style::default().fg(prev),
));
}
run_color = Some(color);
}
run.push(ch);
}
if let Some(prev) = run_color {
spans.push(Span::styled(run, Style::default().fg(prev)));
}
Line::from(spans).alignment(Alignment::Center)
})
.collect();
Paragraph::new(logo_lines).render(area, buf);
}
pub fn logo_line_count(window_height: u16) -> u16 {
pick_logo(window_height).map_or(0, count_lines)
}
pub fn logo_visual_width(window_height: u16) -> u16 {
pick_logo(window_height).map_or(24, visual_width)
}
pub fn render_logo(area: Rect, buf: &mut Buffer, theme: &Theme, window_height: u16) {
if let Some(logo) = pick_logo(window_height) {
render_into(area, buf, theme, logo);
}
}
/// The hero box always shows the full logo: it is laid out beside the menu, so
/// it fits whenever the box does. These report and render that logo directly,
/// independent of the height-based [`pick_logo`] tiers used by the stacked
/// layout. When [`logo_hidden`], they report 0 and render nothing.
pub fn full_logo_line_count() -> u16 {
full_logo_line_count_for(logo_hidden())
}
fn full_logo_line_count_for(hidden: bool) -> u16 {
if hidden { 0 } else { count_lines(LOGO) }
}
pub fn full_logo_visual_width() -> u16 {
full_logo_visual_width_for(logo_hidden())
}
fn full_logo_visual_width_for(hidden: bool) -> u16 {
if hidden { 0 } else { visual_width(LOGO) }
}
pub fn render_full_logo(area: Rect, buf: &mut Buffer, theme: &Theme) {
if !logo_hidden() {
render_into(area, buf, theme, LOGO);
}
}
/// Line count of the small logo used in minimal's committed welcome card
/// (0 on a legacy Windows console, where the braille art is suppressed).
pub fn compact_logo_line_count() -> u16 {
if logo_hidden() {
0
} else {
count_lines(LOGO_SMALL)
}
}
/// Render the small braille logo (centered) into `area` for minimal's welcome
/// card. No-op when the logo is hidden.
pub fn render_compact_logo(area: Rect, buf: &mut Buffer, theme: &Theme) {
if !logo_hidden() {
render_into(area, buf, theme, LOGO_SMALL);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn logo_sizes_by_height() {
assert!(pick_logo_for(SMALL_LOGO_MIN_HEIGHT - 1, false).is_none());
assert_eq!(
pick_logo_for(SMALL_LOGO_MIN_HEIGHT, false),
Some(LOGO_SMALL)
);
assert_eq!(
pick_logo_for(FULL_LOGO_MIN_HEIGHT - 1, false),
Some(LOGO_SMALL)
);
assert_eq!(pick_logo_for(FULL_LOGO_MIN_HEIGHT, false), Some(LOGO));
}
// The braille art has no legacy-safe stand-in, so every height tier must
// collapse to no logo when the legacy-console flag is set.
#[test]
fn logo_hidden_on_legacy_console_at_every_height() {
for h in [0, SMALL_LOGO_MIN_HEIGHT, FULL_LOGO_MIN_HEIGHT, u16::MAX] {
assert!(pick_logo_for(h, true).is_none(), "height {h}");
}
}
#[test]
fn hero_box_always_uses_full_logo() {
// The box renders the full logo regardless of height (it's laid out
// beside the menu), and it's the large variant — never the small one.
assert_eq!(full_logo_line_count_for(false), count_lines(LOGO));
assert_eq!(full_logo_visual_width_for(false), visual_width(LOGO));
assert!(full_logo_line_count_for(false) > count_lines(LOGO_SMALL));
assert!(full_logo_visual_width_for(false) > visual_width(LOGO_SMALL));
}
#[test]
fn full_logo_helpers_collapse_when_hidden() {
assert_eq!(full_logo_line_count_for(true), 0);
assert_eq!(full_logo_visual_width_for(true), 0);
}
#[test]
fn compact_logo_line_count_matches_small_logo_when_visible() {
// The minimal welcome card budgets exactly the small logo's rows. When
// the logo isn't hidden, the count equals the small art's line count and
// is strictly shorter than the full logo.
if !logo_hidden() {
assert_eq!(compact_logo_line_count(), count_lines(LOGO_SMALL));
assert!(compact_logo_line_count() < count_lines(LOGO));
assert!(compact_logo_line_count() > 0);
} else {
assert_eq!(compact_logo_line_count(), 0);
}
}
#[test]
fn shine_opacity_stays_in_unit_range() {
let mut secs = 0.0;
while secs < 10.0 {
for i in 0..=20 {
let diag = i as f32 / 20.0;
let op = shine_opacity(diag, secs);
assert!(
(0.0..=1.0).contains(&op),
"opacity {op} out of range at diag {diag}, secs {secs}"
);
}
secs += 0.13;
}
}
#[test]
fn shine_band_sweeps_across() {
// The brightest point along the diagonal advances left → right as the
// sweep progresses through its active phase.
let brightest = |secs: f32| -> f32 {
(0..=100)
.map(|i| i as f32 / 100.0)
.max_by(|a, b| {
shine_opacity(*a, secs)
.partial_cmp(&shine_opacity(*b, secs))
.unwrap()
})
.unwrap()
};
let early = brightest(0.1);
let mid = brightest(0.4);
let late = brightest(0.7);
assert!(early < mid, "early {early} should precede mid {mid}");
assert!(mid < late, "mid {mid} should precede late {late}");
}
#[test]
fn shine_rests_dim_between_sweeps() {
// During the rest phase the band is parked off-screen, so an interior
// glyph falls back to at most the gentle pulse — never full bright.
let op = shine_opacity(0.5, 6.0); // secs % 4.0 = 2.0 → past SWEEP_FRAC, in the rest phase
assert!(op < 0.2, "resting opacity {op} should stay dim");
}
}
@@ -0,0 +1,137 @@
//! Menu component — renders shortcut key menus.
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use crate::theme::Theme;
use super::logo::logo_visual_width;
/// Render the welcome menu rows as `label … shortcut`, padded within each row.
/// Returns the Rect for each item row (for hit-testing clicks and hover).
pub fn render_menu(
area: Rect,
buf: &mut Buffer,
theme: &Theme,
items: &[(&str, &str)],
selected: Option<usize>,
mouse_pos: Option<(u16, u16)>,
min_width_hint: u16,
) -> Vec<Rect> {
let label_style = Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD);
let label_selected_style = Style::default()
.fg(theme.text_primary)
.bg(theme.bg_highlight)
.add_modifier(Modifier::BOLD);
let key_style = Style::default().fg(theme.gray_bright);
let key_selected_style = Style::default()
.fg(theme.gray_bright)
.bg(theme.bg_highlight);
// Width: label + gap + key. Keep a 4-col gap between label and key for
// readability.
let content_min: u16 = items
.iter()
.map(|(key, label)| (key.len() + label.len() + 4) as u16)
.max()
.unwrap_or(0);
let menu_width = logo_visual_width(area.height)
.max(30)
.max(content_min)
.max(min_width_hint);
let [_, menu_centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(menu_width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(area);
let mut rects = Vec::with_capacity(items.len());
for (y, (i, (key, label))) in (menu_centered.y..).zip(items.iter().enumerate()) {
if y >= menu_centered.y + menu_centered.height {
break;
}
let is_selected = selected == Some(i);
let key_width = key.len() as u16;
let label_len = label.len() as u16;
let row_rect = Rect {
x: menu_centered.x,
y,
width: menu_centered.width,
height: 1,
};
rects.push(row_rect);
// Fill row background when selected/hovered
if is_selected {
let hover_bg = Style::default().bg(theme.bg_highlight);
for x in menu_centered.x..menu_centered.x + menu_centered.width {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(hover_bg);
}
}
}
// Label, flush with the left edge of the menu column.
let lstyle = if is_selected {
label_selected_style
} else {
label_style
};
buf.set_span(menu_centered.x, y, &Span::styled(*label, lstyle), label_len);
// Key shortcut flush with the right edge of the menu column.
let kstyle = if is_selected {
key_selected_style
} else {
key_style
};
buf.set_span(
menu_centered.x + menu_centered.width - key_width,
y,
&Span::styled(*key, kstyle),
key_width,
);
// [x] dismiss affordance restyling (for the import row)
if let Some(x_offset) = key.rfind("[x]") {
let key_x_start = menu_centered.x + menu_centered.width - key_width;
let dismiss_start = key_x_start + x_offset as u16;
let dismiss_end = dismiss_start + 3;
let mouse_on_dismiss = mouse_pos
.is_some_and(|(mx, my)| my == y && mx >= dismiss_start && mx < dismiss_end);
let dismiss_color = if mouse_on_dismiss {
theme.text_primary
} else {
theme.gray_bright
};
let dismiss_style = if is_selected {
Style::default()
.fg(dismiss_color)
.bg(theme.bg_highlight)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(dismiss_color)
.add_modifier(Modifier::BOLD)
};
for (offset, ch) in "[x]".chars().enumerate() {
let col = dismiss_start + offset as u16;
if let Some(cell) = buf.cell_mut((col, y)) {
cell.set_char(ch);
cell.set_style(dismiss_style);
}
}
}
}
rects
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
//! Prompt component — renders the welcome screen prompt using PromptWidget.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use crate::views::prompt_widget::{PromptInfo, PromptStyle, PromptWidget};
use super::WelcomePromptFocus;
pub fn prompt_inset(compact: bool) -> u16 {
if compact { 0 } else { 2 }
}
/// Render the welcome prompt using the shared PromptWidget.
/// Returns the cursor position and ownership-bearing post-flush output.
#[allow(clippy::too_many_arguments)]
pub fn render_prompt(
area: Rect,
buf: &mut Buffer,
focus: WelcomePromptFocus,
prompt: &mut PromptWidget,
info: &PromptInfo<'_>,
pad_left: u16,
pad_right: u16,
compact: bool,
) -> (
Option<(u16, u16)>,
Option<crate::terminal::overlay::PostFlush>,
) {
let focused = focus == WelcomePromptFocus::Focused;
let style = PromptStyle {
focused,
show_prefix: true,
vpad_top: 1,
compact,
chrome: true,
chrome_pad_left: pad_left,
chrome_pad_right: pad_right,
placeholder_override: Some("Type a message..."),
..PromptStyle::default()
};
// Inset the prompt area so the selection box border sits over dark background.
// In compact mode, no inset (prompt_inset returns 0) to match session layout.
let inset = prompt_inset(compact);
let inset_area = Rect {
x: area.x + inset,
y: area.y,
width: area.width.saturating_sub(inset * 2),
height: area.height,
};
let result = prompt.draw(buf, inset_area, None, &style, Some(info));
(result.cursor_pos, result.post_flush_escapes.map(Into::into))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
use crossterm::Command;
fn png() -> [u8; 8] {
[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']
}
#[test]
fn prompt_post_flush_keeps_ownership_when_plain_bytes_are_appended() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
crate::terminal::overlay::reset_owner();
let _ = crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, 71)
.unwrap()
.commit();
let area = Rect::new(0, 0, 80, 3);
let mut buf = Buffer::empty(area);
let mut prompt = PromptWidget::new();
let info = PromptInfo {
model_name: "test",
flags: &[],
multiline: false,
usage_warning: None,
usage_warning_critical: false,
};
let (_, post_flush) = render_prompt(
area,
&mut buf,
WelcomePromptFocus::Focused,
&mut prompt,
&info,
2,
2,
false,
);
let mut post_flush = post_flush.expect("welcome clear");
let mut cursor_bytes = String::new();
let _ = crate::terminal::SetPointerCursor.write_ansi(&mut cursor_bytes);
assert!(!cursor_bytes.is_empty());
post_flush.append_plain(&cursor_bytes);
assert!(post_flush.as_str().contains("a=d"));
assert!(post_flush.as_str().ends_with(cursor_bytes.as_str()));
assert!(
!crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, 71)
.unwrap()
.as_str()
.contains("a=t"),
"constructing welcome output must not commit its clear"
);
let mut emitted = Vec::new();
post_flush.write_to(&mut emitted).unwrap();
assert!(
crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, 71)
.unwrap()
.as_str()
.contains("a=t"),
"writing welcome output must commit its clear"
);
}
}
@@ -0,0 +1,165 @@
//! Top bar component — renders cwd and git info.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use std::path::{Path, PathBuf};
use crate::git_info;
use crate::render::line_utils::truncate_line;
use crate::theme::Theme;
pub fn render_top_bar(area: Rect, buf: &mut Buffer, theme: &Theme) {
let line = truncate_line(location_line(theme), area.width as usize);
let line_width = line.width() as u16;
buf.set_line(area.x, area.y, &line, line_width.min(area.width));
}
/// Build the `{git branch} {worktree} {cwd}` line for the welcome top bar,
/// reading the live process cwd.
pub(crate) fn location_line(theme: &Theme) -> Line<'static> {
location_line_at(theme, &process_cwd())
}
/// As [`location_line`], but for an explicit `cwd`. The dashboard header
/// passes its staged `app.cwd` so the line tracks a `/cd` immediately,
/// before (or even if) `Effect::SetWorkingDir` moves the process cwd.
///
/// Render-safe: reads the per-cwd git cache; never blocks or spawns `git`.
/// The caller width-truncates the returned line.
pub(crate) fn location_line_at(theme: &Theme, cwd: &Path) -> Line<'static> {
let info_style = Style::default().fg(theme.gray);
let info = git_info::cwd_git_info_lazy(cwd);
let mut parts: Vec<Span> = Vec::new();
if let Some(branch) = info.as_ref().and_then(|i| i.branch.as_deref()) {
let icon = git_info::branch_icon();
let git_text = if branch.is_empty() {
format!("{icon} detached")
} else {
format!("{icon} {branch}")
};
let git_style = Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::DIM);
parts.push(Span::styled(git_text, git_style));
parts.push(Span::styled(" ", info_style));
}
// Worktree badge — matches the session status bar's `worktree ` marker
// (accent_user) before the path when the cwd is a linked worktree.
if info.as_ref().is_some_and(|i| i.is_worktree) {
parts.push(Span::styled(
"worktree ",
Style::default().fg(theme.accent_user),
));
}
let cwd_display = format_cwd_display(cwd, info.as_ref());
let cwd_style = Style::default().fg(theme.gray_dim);
parts.push(Span::styled(cwd_display, cwd_style));
Line::from(parts)
}
fn process_cwd() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
/// Format the cwd for the welcome top bar / dashboard header: the actual
/// working directory (tilde-collapsed), with a `(worktree of …)` suffix
/// when `info` reports a linked worktree's main repo. Matches the session
/// status bar (the `worktree ` badge itself is painted by [`location_line`]).
///
/// Pure formatting over the per-cwd git probe — never spawns `git`. On a
/// cache miss (`info == None`, e.g. the very first frame) it still shows the
/// raw cwd path with `~` collapsed; the worktree suffix fills in once the
/// probe lands.
fn format_cwd_display(cwd: &Path, info: Option<&git_info::CwdGitInfo>) -> String {
let display = collapse_home(cwd);
let main_repo = info.and_then(|i| i.main_repo.as_deref());
format_cwd_parts(&display, main_repo)
}
/// Pure formatting for the cwd display — no global state, easy to test.
fn format_cwd_parts(display: &str, main_repo: Option<&str>) -> String {
if let Some(main_repo) = main_repo {
format!("{display} (worktree of {main_repo})")
} else {
display.to_string()
}
}
fn collapse_home(dir: &std::path::Path) -> String {
let path = dir.display().to_string();
match git_info::home_dir() {
Some(home) => path
.strip_prefix(&home)
.map(|s| format!("~{s}"))
.unwrap_or(path),
None => path,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_cwd_plain_repo() {
assert_eq!(format_cwd_parts("~/xai", None), "~/xai");
}
/// A linked worktree shows the `(worktree of …)` suffix — matching the
/// session status bar — regardless of the worktree's human label (the
/// label is no longer shown here; the `worktree ` badge stands in for it).
#[test]
fn format_cwd_worktree_shows_main_repo() {
assert_eq!(
format_cwd_parts("~/wt/session-1", Some("~/xai")),
"~/wt/session-1 (worktree of ~/xai)"
);
}
/// The header shows the ACTUAL cwd, not the git repo root: switching
/// into a subdirectory of a repo reflects the subdirectory. (`/work/...`
/// is outside `$HOME`, so `collapse_home` leaves it verbatim.)
#[test]
fn format_cwd_display_shows_subdir_not_repo_root() {
let info = git_info::CwdGitInfo {
branch: Some("main".into()),
is_worktree: false,
main_repo: None,
worktree_label: None,
};
assert_eq!(
format_cwd_display(Path::new("/work/xai/frontend/apps"), Some(&info)),
"/work/xai/frontend/apps",
);
}
/// A worktree subdirectory shows the `(worktree of …)` suffix (matching
/// the session status bar) while still showing the real subdirectory path.
#[test]
fn format_cwd_display_worktree_subdir_shows_main_repo() {
let info = git_info::CwdGitInfo {
branch: Some("kevin/x".into()),
is_worktree: true,
main_repo: Some("~/xai".into()),
worktree_label: Some("location-picker".into()),
};
assert_eq!(
format_cwd_display(Path::new("/work/wt/location-picker/frontend"), Some(&info)),
"/work/wt/location-picker/frontend (worktree of ~/xai)",
);
}
/// On a cache miss (`info == None`) the header still shows the raw cwd.
#[test]
fn format_cwd_display_cache_miss_shows_raw_cwd() {
assert_eq!(
format_cwd_display(Path::new("/work/xai/frontend/apps"), None),
"/work/xai/frontend/apps",
);
}
}