The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
1520 lines
56 KiB
Rust
1520 lines
56 KiB
Rust
//! SessionEventBlock — typed session-level events displayed in scrollback.
|
|
//!
|
|
//! Unlike [`super::SystemMessageBlock`] (which renders arbitrary text),
|
|
//! `SessionEventBlock` uses a [`SessionEvent`] enum so each event variant
|
|
//! carries structured data (e.g., elapsed time, error messages, token counts).
|
|
//! This enables variant-specific rendering and future styling differentiation.
|
|
|
|
use std::time::Duration;
|
|
|
|
use ratatui::style::Modifier;
|
|
use ratatui::text::{Line, Span};
|
|
|
|
use super::tool::HookRunEntry;
|
|
use crate::render::wrapping::word_wrap_lines;
|
|
use crate::scrollback::block::BlockContent;
|
|
use crate::scrollback::types::{
|
|
AccentStyle, BlockContext, BlockLine, BlockOutput, DisplayMode, Selectable,
|
|
};
|
|
use crate::theme::Theme;
|
|
use crate::util::format_duration;
|
|
|
|
/// Shared text-selection range id for recap body lines (header is excluded).
|
|
const RECAP_BODY_RANGE: u16 = 0;
|
|
|
|
/// A session-level event with structured data.
|
|
///
|
|
/// Each variant carries the information needed to render a concise,
|
|
/// informational message in the scrollback. These are non-interactive:
|
|
/// unselectable, unfoldable, no accent.
|
|
#[derive(Debug, Clone)]
|
|
pub enum SessionEvent {
|
|
/// Agent turn completed normally.
|
|
TurnCompleted {
|
|
/// Wall-clock elapsed time for the turn. `None` when unknown — a
|
|
/// wake turn whose deltas carried no `turnStartMs` (old shells)
|
|
/// renders without a duration rather than lying with "0.0s".
|
|
elapsed: Option<Duration>,
|
|
},
|
|
/// Agent turn was cancelled by the user.
|
|
TurnCancelled {
|
|
/// Wall-clock elapsed time before cancellation.
|
|
elapsed: Duration,
|
|
},
|
|
/// Agent turn was halted by the system (e.g. doom loop detection).
|
|
TurnHalted {
|
|
/// Wall-clock elapsed time before the turn was halted.
|
|
elapsed: Duration,
|
|
},
|
|
/// Agent turn failed with an error.
|
|
TurnFailed {
|
|
/// Error description.
|
|
error: String,
|
|
/// Elapsed time, if available.
|
|
elapsed: Option<Duration>,
|
|
},
|
|
/// Auto-compaction started (context window threshold reached).
|
|
CompactionStarted {
|
|
/// Percentage of context window used (e.g., 85).
|
|
percentage: u8,
|
|
},
|
|
/// Auto-compaction completed successfully.
|
|
CompactionCompleted {
|
|
/// Tokens used before compaction (`None` from older shells).
|
|
tokens_before: Option<u64>,
|
|
/// Tokens used after compaction.
|
|
tokens_after: u64,
|
|
/// How long compaction took (milliseconds).
|
|
elapsed_ms: Option<i64>,
|
|
},
|
|
/// Auto-compaction failed.
|
|
CompactionFailed {
|
|
/// Error description.
|
|
error: String,
|
|
},
|
|
/// Auto-compaction was cancelled (turn was cancelled mid-compact).
|
|
CompactionCancelled,
|
|
/// Retry failed — all retries exhausted or a non-retryable error.
|
|
///
|
|
/// Covers both `RetryState::Exhausted` (tried N times, all failed) and
|
|
/// `RetryState::Failed` (non-retryable error like auth or 413).
|
|
RetryFailed {
|
|
/// Human-readable error description.
|
|
error: String,
|
|
/// Structured error category from `RetryState::Failed::error_type`.
|
|
/// Used to match known error patterns without fragile string matching.
|
|
error_type: Option<String>,
|
|
},
|
|
/// The server rejected the credentials (401 / auth error) and automatic
|
|
/// recovery was exhausted. Rendered as a prominent call-to-action that
|
|
/// points the user at `/login` to re-authenticate, replacing the raw
|
|
/// "Retry failed: Unauthorized (401) …" dump.
|
|
ReAuthRequired,
|
|
/// Terminal context overflow — ideally unreachable, since auto-compaction should
|
|
/// shrink the conversation first; a safeguard for when it didn't (estimate drift
|
|
/// vs the server's max_prompt_length, or compaction suppressed/failed). One actionable
|
|
/// prompt, replacing the CompactionFailed + RetryFailed + TurnFailed stack.
|
|
ContextTooLarge,
|
|
/// Manual `/compact` command completed.
|
|
CompactCompleted {
|
|
/// Wall-clock elapsed time for the command.
|
|
elapsed: Duration,
|
|
},
|
|
/// Hook annotation — displayed inline after a tool call.
|
|
/// Message comes from agent via XaiSessionUpdate::HookAnnotation.
|
|
HookAnnotation {
|
|
/// The hook message
|
|
message: String,
|
|
},
|
|
/// The session's persisted model is no longer available after re-auth.
|
|
/// Both IDs are empty when re-shown on blocked prompt attempts.
|
|
ModelUnavailable {
|
|
previous_model_id: String,
|
|
new_model_id: String,
|
|
reason: String,
|
|
},
|
|
/// Memory was saved (flush, dream, or session-end).
|
|
MemorySaved {
|
|
/// File path that was written.
|
|
path: String,
|
|
/// What triggered the save: "session-end", "flush", or "dream".
|
|
trigger: String,
|
|
},
|
|
/// A `/goal` finished (status → Complete). Carries the goal's total
|
|
/// elapsed time across all its turns, distinct from the per-turn
|
|
/// "Worked for" marker.
|
|
GoalCompleted {
|
|
/// Goal end-to-end elapsed time (`GoalUpdated.elapsed_ms`).
|
|
elapsed: Duration,
|
|
},
|
|
/// A session recap — a short "where was I" summary of the session so far.
|
|
/// Surfaced on demand via `/recap` (`auto = false`) or automatically when
|
|
/// the user returns to the terminal after being away (`auto = true`).
|
|
Recap {
|
|
/// The one-line recap text.
|
|
summary: String,
|
|
/// `true` for the automatic return-from-away recap, `false` for `/recap`.
|
|
auto: bool,
|
|
},
|
|
}
|
|
|
|
impl SessionEvent {
|
|
/// Format the event as a human-readable string.
|
|
pub fn message(&self) -> String {
|
|
match self {
|
|
SessionEvent::TurnCompleted {
|
|
elapsed: Some(elapsed),
|
|
} => {
|
|
format!("Worked for {}.", format_duration(*elapsed))
|
|
}
|
|
SessionEvent::TurnCompleted { elapsed: None } => "Turn completed.".to_string(),
|
|
SessionEvent::TurnCancelled { elapsed } => {
|
|
format!("Turn cancelled by user in {}.", format_duration(*elapsed))
|
|
}
|
|
SessionEvent::TurnHalted { elapsed } => {
|
|
format!(
|
|
"Agent was unable to make progress \u{2014} turn ended in {}.",
|
|
format_duration(*elapsed)
|
|
)
|
|
}
|
|
SessionEvent::TurnFailed {
|
|
error,
|
|
elapsed: Some(elapsed),
|
|
} => {
|
|
format!("Turn failed in {}: {error}", format_duration(*elapsed))
|
|
}
|
|
SessionEvent::TurnFailed {
|
|
error,
|
|
elapsed: None,
|
|
} => {
|
|
format!("Turn failed: {error}")
|
|
}
|
|
SessionEvent::CompactionStarted { percentage } => {
|
|
format!("Context {percentage}% full. Compacting…")
|
|
}
|
|
SessionEvent::CompactionCompleted {
|
|
tokens_before,
|
|
tokens_after,
|
|
elapsed_ms,
|
|
} => {
|
|
let after = format_tokens(*tokens_after);
|
|
// Older shells don't send tokens_before — keep the legacy format.
|
|
let body = match tokens_before {
|
|
Some(before) if *before > 0 => {
|
|
format!(
|
|
"Context compacted: {} → {after} tokens",
|
|
format_tokens(*before)
|
|
)
|
|
}
|
|
_ => format!("Context compacted → {after} tokens"),
|
|
};
|
|
if let Some(ms) = elapsed_ms {
|
|
let secs = *ms as f64 / 1000.0;
|
|
format!("{body} ({secs:.1}s)")
|
|
} else {
|
|
body
|
|
}
|
|
}
|
|
SessionEvent::CompactionFailed { error } => {
|
|
if error.trim().is_empty() {
|
|
"Compaction failed.".to_string()
|
|
} else {
|
|
format!("Compaction failed: {error}")
|
|
}
|
|
}
|
|
SessionEvent::CompactionCancelled => "Compaction cancelled.".to_string(),
|
|
SessionEvent::RetryFailed { error, error_type } => {
|
|
if error_type.as_deref() == Some("encrypted_content_mismatch") {
|
|
"This session's conversation history is incompatible with the \
|
|
current model. Please start a new session."
|
|
.to_string()
|
|
} else {
|
|
format!("Retry failed: {error}")
|
|
}
|
|
}
|
|
SessionEvent::ReAuthRequired => {
|
|
"Authentication required \u{2014} your session has expired or your \
|
|
credentials were rejected. Run /login to re-authenticate, then resend \
|
|
your message."
|
|
.to_string()
|
|
}
|
|
SessionEvent::ContextTooLarge => {
|
|
"This conversation is too large for the model's context window. \
|
|
Use /new to start a new session."
|
|
.to_string()
|
|
}
|
|
SessionEvent::CompactCompleted { elapsed } => {
|
|
format!("Compaction completed in {}.", format_duration(*elapsed))
|
|
}
|
|
SessionEvent::HookAnnotation { message } => message.clone(),
|
|
SessionEvent::ModelUnavailable {
|
|
new_model_id,
|
|
reason,
|
|
..
|
|
} => {
|
|
if new_model_id.is_empty() {
|
|
reason.clone()
|
|
} else {
|
|
format!("{reason} Switched to \"{new_model_id}\".")
|
|
}
|
|
}
|
|
SessionEvent::MemorySaved { path, trigger } => {
|
|
let short_path = crate::util::abbreviate_path(path);
|
|
format!("Memory saved ({trigger}) \u{2192} {short_path} \u{00b7} /memory to view")
|
|
}
|
|
SessionEvent::GoalCompleted { elapsed } => {
|
|
format!(
|
|
"Goal complete \u{2014} {} end-to-end.",
|
|
format_duration(*elapsed)
|
|
)
|
|
}
|
|
SessionEvent::Recap { summary, auto: _ } => {
|
|
// Always "Recap —" (manual `/recap` and auto return-from-away).
|
|
format!("Recap \u{2014} {summary}")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The recap summary text when this is a [`SessionEvent::Recap`].
|
|
///
|
|
/// Recap events render in the tool-call visual style (bullet + bold
|
|
/// "Recap" header + muted body); every other variant stays a plain
|
|
/// informational line. This accessor is the single branch point the
|
|
/// `SessionEventBlock` trait methods use to opt the recap into that style.
|
|
fn recap_summary(&self) -> Option<&str> {
|
|
match self {
|
|
SessionEvent::Recap { summary, .. } => Some(summary.as_str()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Whether this event marks the end of an agent turn (the "Turn
|
|
/// completed/cancelled/failed" markers). These are the only events that
|
|
/// can carry the turn's stop/stop_failure hook runs inline — but a
|
|
/// parked marker renders mid-turn while the turn is still running
|
|
/// shell-side, before any Stop hook fires, so hook eligibility is the
|
|
/// block-level [`SessionEventBlock::accepts_stop_hooks`].
|
|
pub fn is_turn_terminal(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
SessionEvent::TurnCompleted { .. }
|
|
| SessionEvent::TurnCancelled { .. }
|
|
| SessionEvent::TurnHalted { .. }
|
|
| SessionEvent::TurnFailed { .. }
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Format a token count with "k" suffix for thousands.
|
|
fn format_tokens(tokens: u64) -> String {
|
|
if tokens >= 1000 {
|
|
format!("{:.1}k", tokens as f64 / 1000.0)
|
|
} else {
|
|
tokens.to_string()
|
|
}
|
|
}
|
|
|
|
/// Background work counted on a turn-end marker's work suffix
|
|
/// ("1 command, 1 monitor and 2 subagents still running."). A static
|
|
/// snapshot taken at push time — nothing mutates it; a later completion
|
|
/// re-emits a fresh work-only status line when no wake response follows,
|
|
/// and otherwise gets the wake turn's own end marker (see the pager's
|
|
/// `push_wake_end_marker`).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub struct EndWork {
|
|
/// Background bash commands still running (`BgTaskStatus::Running`,
|
|
/// non-monitor).
|
|
pub running_commands: usize,
|
|
/// Background monitors still running — counted apart from commands so a
|
|
/// long-lived watch reads as "1 monitor", not a stuck command.
|
|
pub running_monitors: usize,
|
|
/// Subagents still running.
|
|
pub running_subagents: usize,
|
|
}
|
|
|
|
impl EndWork {
|
|
/// `None` at all-zero counts — the one spelling of "a workless marker
|
|
/// carries no `end_work`", so it renders the legacy text and closes the
|
|
/// between-turns status window.
|
|
pub fn nonzero(self) -> Option<Self> {
|
|
(self != Self::default()).then_some(self)
|
|
}
|
|
|
|
/// The "N … still running." line, `None` when every count is zero so a
|
|
/// marker collapses to the plain legacy completion text (and no
|
|
/// between-turns status line is emitted). Doubles as the work-only
|
|
/// status line re-emitted after a background completion. Ends with a
|
|
/// period, never an ellipsis: the marker states a settled fact, not a
|
|
/// progress spinner.
|
|
pub fn still_running_text(&self) -> Option<String> {
|
|
let count = |n: usize, noun: &str| -> Option<String> {
|
|
match n {
|
|
0 => None,
|
|
1 => Some(format!("1 {noun}")),
|
|
n => Some(format!("{n} {noun}s")),
|
|
}
|
|
};
|
|
let parts: Vec<String> = [
|
|
count(self.running_commands, "command"),
|
|
count(self.running_monitors, "monitor"),
|
|
count(self.running_subagents, "subagent"),
|
|
]
|
|
.into_iter()
|
|
.flatten()
|
|
.collect();
|
|
let (last, head) = parts.split_last()?;
|
|
let joined = if head.is_empty() {
|
|
last.clone()
|
|
} else {
|
|
format!("{} and {last}", head.join(", "))
|
|
};
|
|
Some(format!("{joined} still running."))
|
|
}
|
|
}
|
|
|
|
/// Block that renders a [`SessionEvent`] in scrollback.
|
|
///
|
|
/// Visually identical to [`super::SystemMessageBlock`] (muted text, compact,
|
|
/// unselectable). The structured `event` field is available for future
|
|
/// styling differentiation (e.g., red text for failures).
|
|
#[derive(Debug, Clone)]
|
|
pub struct SessionEventBlock {
|
|
/// The typed event data.
|
|
pub event: SessionEvent,
|
|
/// Stop/stop_failure hook runs folded into a turn-terminal marker
|
|
/// (`(event_name, runs)` per hook batch). Rendered as a right-justified
|
|
/// `stop [hooks: N]` summary on the marker line, with per-hook detail
|
|
/// on expand. Always empty for non-terminal events.
|
|
pub stop_hooks: Vec<(String, Vec<HookRunEntry>)>,
|
|
/// The prompt turn a terminal marker belongs to, when known. Gates
|
|
/// which stop-hook batches may merge into it.
|
|
pub prompt_id: Option<String>,
|
|
/// Work counts appended to a turn-end marker as a "still running"
|
|
/// suffix, snapshotted at push time. `None` for every non-marker event
|
|
/// and for markers pushed with no work in flight — those render the
|
|
/// legacy text unchanged.
|
|
pub end_work: Option<EndWork>,
|
|
/// The marker was pushed at park time (user-interruptible blocking
|
|
/// wait): the turn is still running shell-side, so it must never accept
|
|
/// stop hooks. Rendering is unchanged — a parked wait reads as stopped —
|
|
/// and the real completion still prints its own marker.
|
|
pub parked: bool,
|
|
}
|
|
|
|
impl SessionEventBlock {
|
|
/// Create a new session event block.
|
|
pub fn new(event: SessionEvent) -> Self {
|
|
Self {
|
|
event,
|
|
stop_hooks: Vec::new(),
|
|
prompt_id: None,
|
|
end_work: None,
|
|
parked: false,
|
|
}
|
|
}
|
|
|
|
/// A turn-terminal marker carrying the turn's stop-hook runs and prompt id.
|
|
pub fn with_stop_hooks(
|
|
event: SessionEvent,
|
|
stop_hooks: Vec<(String, Vec<HookRunEntry>)>,
|
|
prompt_id: Option<String>,
|
|
) -> Self {
|
|
debug_assert!(stop_hooks.is_empty() || event.is_turn_terminal());
|
|
Self {
|
|
event,
|
|
stop_hooks,
|
|
prompt_id,
|
|
end_work: None,
|
|
parked: false,
|
|
}
|
|
}
|
|
|
|
/// Whether this marker may carry/accept stop-hook runs: a turn-terminal
|
|
/// event that is not a parked line (which renders while the turn is
|
|
/// still running shell-side, before any Stop hook fires).
|
|
pub fn accepts_stop_hooks(&self) -> bool {
|
|
self.event.is_turn_terminal() && !self.parked
|
|
}
|
|
|
|
/// The marker line text: the turn-scoped prefix ("Worked for X.")
|
|
/// plus the work suffix ("1 command still running.") snapshotted at push
|
|
/// time. No `end_work` (or all-zero counts) is byte-identical to the
|
|
/// legacy event text.
|
|
pub fn marker_text(&self) -> String {
|
|
match self.end_work.as_ref().and_then(EndWork::still_running_text) {
|
|
Some(work) => format!("{} {work}", self.event.message()),
|
|
None => self.event.message(),
|
|
}
|
|
}
|
|
|
|
/// Whether any attached stop hook actually ran (non-skipped). Gates the
|
|
/// fold/selection affordances and the inline summary, mirroring
|
|
/// [`ToolCallHookData::has_content`](super::tool::ToolCallHookData::has_content).
|
|
pub fn has_stop_hook_content(&self) -> bool {
|
|
self.stop_hooks.iter().any(|(_, runs)| {
|
|
runs.iter()
|
|
.any(|r| !matches!(r.status, super::tool::HookRunStatus::Skipped))
|
|
})
|
|
}
|
|
|
|
/// A recap with real body content — i.e. not the empty loading spinner or a
|
|
/// stray empty recap. Gates the interactive affordances (folding + j/k
|
|
/// selection) so navigation never lands on a recap that can't fold.
|
|
fn recap_has_body(&self) -> bool {
|
|
self.event
|
|
.recap_summary()
|
|
.is_some_and(|s| !s.trim().is_empty())
|
|
}
|
|
|
|
/// Merge the stop-hook runs into the marker's output: a right-justified
|
|
/// `stop [hooks: N]` summary on the marker line (its own right-justified
|
|
/// line when the marker text leaves no room or wraps), plus per-hook
|
|
/// detail lines when expanded.
|
|
///
|
|
/// The summary spans are decoration — [`Selectable::Spans`] keeps
|
|
/// drag-copy on the marker text only, so a copied "Worked for
|
|
/// 4.4s." never drags the padding and hook counts along.
|
|
fn append_stop_hooks(&self, lines: &mut Vec<BlockLine>, ctx: &BlockContext) {
|
|
use super::tool::hook::{render_hooks_for_mode, render_stop_hooks_summary};
|
|
|
|
if !self.has_stop_hook_content() {
|
|
return;
|
|
}
|
|
let Some(summary) = render_stop_hooks_summary(&self.stop_hooks) else {
|
|
return;
|
|
};
|
|
|
|
let avail = ctx.width as usize;
|
|
let summary_width: usize = summary
|
|
.iter()
|
|
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
|
|
.sum();
|
|
// Inline attach is for single-line markers only: on a wrapped marker
|
|
// (a long TurnFailed error) the summary would land mid-paragraph.
|
|
let single_line = lines.len() == 1;
|
|
if let Some(first) = lines.first_mut() {
|
|
let text = crate::scrollback::types::line_plain_text(&first.content);
|
|
let text_width = unicode_width::UnicodeWidthStr::width(text.as_str());
|
|
// Restrict drag-copy to the marker text span(s) before padding.
|
|
let text_spans = first.content.spans.len();
|
|
if single_line && text_width + 2 + summary_width <= avail {
|
|
let pad = avail - text_width - summary_width;
|
|
first.content.spans.push(Span::raw(" ".repeat(pad)));
|
|
first.content.spans.extend(summary);
|
|
first.selectable = Selectable::Spans(0..text_spans);
|
|
first.selection_text = Some(text);
|
|
} else {
|
|
// No room on the marker line (or a wrapped marker) —
|
|
// right-justify on its own line below the text.
|
|
let pad = avail.saturating_sub(summary_width);
|
|
let mut spans = vec![Span::raw(" ".repeat(pad))];
|
|
spans.extend(summary);
|
|
lines.push(BlockLine::separator(Line::from(spans)));
|
|
}
|
|
}
|
|
|
|
// Expanded: per-hook detail below the marker line. The section header
|
|
// ("stop") is redundant with the inline summary for a single batch;
|
|
// keep it when both stop_failure and stop ran so the groups read apart.
|
|
if !matches!(ctx.mode, DisplayMode::Collapsed) {
|
|
let multiple = self.stop_hooks.len() > 1;
|
|
for (event_name, runs) in &self.stop_hooks {
|
|
let detail = if multiple {
|
|
render_hooks_for_mode(event_name, runs, ctx.mode)
|
|
} else {
|
|
super::tool::hook::render_hooks_detail(runs, ctx.mode)
|
|
};
|
|
lines.extend(detail);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Render a recap event in the tool-call visual style.
|
|
///
|
|
/// Mirrors [`OtherToolCallBlock`](super::OtherToolCallBlock): a bold
|
|
/// "Recap" header (the dot bullet is prepended later by
|
|
/// `RenderBlock::output` via [`has_bullet`](BlockContent::has_bullet))
|
|
/// with the summary shown as muted body text below when expanded. When
|
|
/// collapsed, the summary's first line trails the header as a preview.
|
|
///
|
|
/// While the recap is still being generated the entry is `is_running`, so
|
|
/// only the header is shown and the animated accent sidebar (see
|
|
/// [`accent`](BlockContent::accent)) signals progress.
|
|
///
|
|
/// Text selection mirrors [`ThinkingBlock`](super::ThinkingBlock): the
|
|
/// "Recap" label (and the blank separator under it) are decoration
|
|
/// ([`BlockLine::separator`] / [`Selectable::None`]) so drag-highlight and
|
|
/// copy only include the summary body, never the chrome label.
|
|
fn recap_output(&self, ctx: &BlockContext, summary: &str) -> BlockOutput {
|
|
let theme = Theme::current();
|
|
let muted_collapsed =
|
|
ctx.mute_when_collapsed(ctx.appearance.scrollback.blocks.tool.muted_collapsed);
|
|
|
|
// "Recap" header — bold, neutral primary text (like a tool-call header).
|
|
// Dimmed to muted gray while collapsed-and-unselected.
|
|
let header_text_style = if muted_collapsed {
|
|
theme.muted()
|
|
} else {
|
|
theme.primary()
|
|
};
|
|
let header_style = header_text_style.add_modifier(Modifier::BOLD);
|
|
// Non-selectable chrome (same as Thinking / tool label prefixes).
|
|
let header_line =
|
|
|| BlockLine::separator(Line::from(Span::styled("Recap".to_string(), header_style)));
|
|
|
|
// Loading: header only; the animated gray sidebar is the feedback.
|
|
if ctx.is_running {
|
|
return BlockOutput {
|
|
lines: vec![header_line()],
|
|
};
|
|
}
|
|
|
|
match ctx.mode {
|
|
DisplayMode::Collapsed => {
|
|
let mut spans = vec![Span::styled("Recap".to_string(), header_style)];
|
|
let preview = summary.lines().next().unwrap_or(summary).trim();
|
|
if !preview.is_empty() {
|
|
spans.push(Span::styled(format!(" {preview}"), theme.muted()));
|
|
}
|
|
let line = crate::render::line_utils::truncate_line(
|
|
Line::from(spans),
|
|
ctx.content_width(),
|
|
);
|
|
// Only the preview span is copyable — never the "Recap" label.
|
|
// No preview (empty after trim) → fully non-selectable.
|
|
let selectable = if preview.is_empty() {
|
|
Selectable::None
|
|
} else {
|
|
Selectable::Spans(1..2)
|
|
};
|
|
BlockOutput {
|
|
lines: vec![BlockLine {
|
|
content: line,
|
|
selectable,
|
|
selection_range: (!preview.is_empty()).then_some(RECAP_BODY_RANGE),
|
|
selection_text: (!preview.is_empty()).then(|| preview.to_string()),
|
|
..Default::default()
|
|
}],
|
|
}
|
|
}
|
|
DisplayMode::Truncated | DisplayMode::Expanded => {
|
|
let mut lines: Vec<BlockLine> = vec![header_line()];
|
|
// Blank gap under the header is decoration, not copyable text.
|
|
lines.push(BlockLine::separator(Line::from("")));
|
|
|
|
let styled_lines = summary
|
|
.split('\n')
|
|
.map(|line| Line::from(Span::styled(line.to_string(), theme.muted())));
|
|
let wrapped =
|
|
word_wrap_lines(styled_lines, (ctx.width as usize).saturating_sub(2).max(20));
|
|
for wrapped_line in wrapped {
|
|
lines.push(
|
|
BlockLine::styled(wrapped_line)
|
|
.with_selection_range(Some(RECAP_BODY_RANGE)),
|
|
);
|
|
}
|
|
|
|
BlockOutput { lines }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BlockContent for SessionEventBlock {
|
|
fn output(&self, ctx: &BlockContext) -> BlockOutput {
|
|
// Recap renders in the tool-call style (bullet + bold header + body).
|
|
if let Some(summary) = self.event.recap_summary() {
|
|
return self.recap_output(ctx, summary);
|
|
}
|
|
|
|
let theme = Theme::current();
|
|
// Failures and re-auth / context-overflow prompts are actionable, not
|
|
// informational — render them in the warning color, not muted noise.
|
|
let style = if matches!(
|
|
self.event,
|
|
SessionEvent::ReAuthRequired
|
|
| SessionEvent::ContextTooLarge
|
|
| SessionEvent::CompactionFailed { .. }
|
|
) {
|
|
ratatui::style::Style::default().fg(theme.warning)
|
|
} else {
|
|
theme.muted()
|
|
};
|
|
|
|
let text = self.marker_text();
|
|
let wrapped = if text.contains('\n') {
|
|
let input_lines = text
|
|
.split('\n')
|
|
.map(|s| Line::from(Span::styled(s.to_owned(), style)));
|
|
word_wrap_lines(input_lines, ctx.width as usize)
|
|
} else {
|
|
word_wrap_lines(
|
|
std::iter::once(Line::from(Span::styled(text, style))),
|
|
ctx.width as usize,
|
|
)
|
|
};
|
|
let mut lines: Vec<BlockLine> = wrapped
|
|
.into_iter()
|
|
.map(|line| BlockLine::styled(line).with_selection_range(Some(0)))
|
|
.collect();
|
|
|
|
if lines.is_empty() {
|
|
lines.push(BlockLine::styled(Line::from("")).with_selection_range(Some(0)));
|
|
}
|
|
self.append_stop_hooks(&mut lines, ctx);
|
|
BlockOutput { lines }
|
|
}
|
|
|
|
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
|
|
let theme = Theme::current();
|
|
if self.event.recap_summary().is_some() {
|
|
// Loading: animated sidebar so there's feedback that the recap is
|
|
// being generated. Gray rather than the magenta `accent_running` —
|
|
// the recap is a passive marker, not an active tool turn.
|
|
if ctx.is_running {
|
|
return Some(AccentStyle::animated(theme.gray));
|
|
}
|
|
// Finished: neutral tool accent bar when expanded (no special color).
|
|
return (ctx.mode != DisplayMode::Collapsed)
|
|
.then(|| AccentStyle::static_color(theme.accent_tool));
|
|
}
|
|
if matches!(
|
|
self.event,
|
|
SessionEvent::ReAuthRequired
|
|
| SessionEvent::ContextTooLarge
|
|
| SessionEvent::CompactionFailed { .. }
|
|
) {
|
|
Some(AccentStyle::static_color(theme.warning))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
|
|
// Recap: animated dot while loading; default gray dot when collapsed-idle;
|
|
// accent color when expanded. Other events never show a bullet.
|
|
if self.event.recap_summary().is_some()
|
|
&& !ctx.is_running
|
|
&& ctx.mode == DisplayMode::Collapsed
|
|
{
|
|
return None;
|
|
}
|
|
self.accent(ctx)
|
|
}
|
|
|
|
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
|
|
false // Compact like SystemMessageBlock
|
|
}
|
|
|
|
fn has_raw_mode(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn is_foldable(&self) -> bool {
|
|
// A recap with body content folds, as does a turn marker carrying
|
|
// stop-hook runs (fold = per-hook detail). Other events are single
|
|
// informational lines with nothing to collapse.
|
|
self.recap_has_body() || self.has_stop_hook_content()
|
|
}
|
|
|
|
fn is_selectable(&self) -> bool {
|
|
// Recap is tool-like: navigable so it can be folded — but only once it
|
|
// has body content (mirrors `is_foldable`), so j/k never lands on the
|
|
// loading spinner or an empty recap. A turn marker with stop hooks is
|
|
// navigable for the same reason. Other events stay non-interactive.
|
|
self.recap_has_body() || self.has_stop_hook_content()
|
|
}
|
|
|
|
fn default_display_mode(&self) -> DisplayMode {
|
|
// A marker with stop hooks starts collapsed: the right-justified
|
|
// summary is the resting state; detail is opt-in via fold.
|
|
if self.has_stop_hook_content() {
|
|
DisplayMode::Collapsed
|
|
} else {
|
|
DisplayMode::Expanded
|
|
}
|
|
}
|
|
|
|
fn has_bullet(&self, ctx: &BlockContext) -> bool {
|
|
// Recap only, and only when the shared tool bullet is configured — so it
|
|
// tracks the same appearance setting as real tool calls.
|
|
self.event.recap_summary().is_some()
|
|
&& ctx
|
|
.appearance
|
|
.scrollback
|
|
.blocks
|
|
.tool
|
|
.bullet
|
|
.char()
|
|
.is_some()
|
|
}
|
|
|
|
fn is_groupable(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn turn_completed_message() {
|
|
let event = SessionEvent::TurnCompleted {
|
|
elapsed: Some(Duration::from_secs(125)),
|
|
};
|
|
assert_eq!(event.message(), "Worked for 2m5s.");
|
|
}
|
|
|
|
#[test]
|
|
fn turn_cancelled_message() {
|
|
let event = SessionEvent::TurnCancelled {
|
|
elapsed: Duration::from_secs(10),
|
|
};
|
|
assert_eq!(event.message(), "Turn cancelled by user in 10s.");
|
|
}
|
|
|
|
#[test]
|
|
fn goal_completed_message_shows_end_to_end_time() {
|
|
let event = SessionEvent::GoalCompleted {
|
|
elapsed: Duration::from_secs(619),
|
|
};
|
|
assert_eq!(event.message(), "Goal complete \u{2014} 10m19s end-to-end.");
|
|
}
|
|
|
|
#[test]
|
|
fn turn_halted_message() {
|
|
let event = SessionEvent::TurnHalted {
|
|
elapsed: Duration::from_secs(45),
|
|
};
|
|
assert_eq!(
|
|
event.message(),
|
|
"Agent was unable to make progress \u{2014} turn ended in 45s."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_failed_with_elapsed_message() {
|
|
let event = SessionEvent::TurnFailed {
|
|
error: "connection reset".into(),
|
|
elapsed: Some(Duration::from_secs(3)),
|
|
};
|
|
assert_eq!(event.message(), "Turn failed in 3.0s: connection reset");
|
|
}
|
|
|
|
#[test]
|
|
fn turn_failed_without_elapsed_message() {
|
|
let event = SessionEvent::TurnFailed {
|
|
error: "auth error".into(),
|
|
elapsed: None,
|
|
};
|
|
assert_eq!(event.message(), "Turn failed: auth error");
|
|
}
|
|
|
|
#[test]
|
|
fn model_unavailable_with_switch() {
|
|
let event = SessionEvent::ModelUnavailable {
|
|
previous_model_id: "kigi-4.5".into(),
|
|
new_model_id: "kigi".into(),
|
|
reason: "Model \"kigi-4.5\" is no longer available.".into(),
|
|
};
|
|
assert_eq!(
|
|
event.message(),
|
|
"Model \"kigi-4.5\" is no longer available. Switched to \"kigi\"."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_unavailable_blocked_reprompt() {
|
|
let event = SessionEvent::ModelUnavailable {
|
|
previous_model_id: String::new(),
|
|
new_model_id: String::new(),
|
|
reason: "Your previous model is no longer available. Please start a new session."
|
|
.into(),
|
|
};
|
|
assert_eq!(
|
|
event.message(),
|
|
"Your previous model is no longer available. Please start a new session."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn retry_failed_generic() {
|
|
let event = SessionEvent::RetryFailed {
|
|
error: "connection timeout".into(),
|
|
error_type: None,
|
|
};
|
|
assert_eq!(event.message(), "Retry failed: connection timeout");
|
|
}
|
|
|
|
#[test]
|
|
fn retry_failed_encrypted_content_mismatch() {
|
|
let event = SessionEvent::RetryFailed {
|
|
error: "raw API error message".into(),
|
|
error_type: Some("encrypted_content_mismatch".into()),
|
|
};
|
|
assert_eq!(
|
|
event.message(),
|
|
"This session's conversation history is incompatible with the \
|
|
current model. Please start a new session."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn retry_failed_other_error_type_shows_raw() {
|
|
let event = SessionEvent::RetryFailed {
|
|
error: "bad request".into(),
|
|
error_type: Some("api_400".into()),
|
|
};
|
|
assert_eq!(event.message(), "Retry failed: bad request");
|
|
}
|
|
|
|
#[test]
|
|
fn reauth_required_message_points_at_login() {
|
|
let msg = SessionEvent::ReAuthRequired.message();
|
|
assert!(msg.contains("/login"), "must tell the user to run /login");
|
|
assert!(
|
|
msg.to_lowercase().contains("authentication")
|
|
|| msg.to_lowercase().contains("credentials"),
|
|
"must explain it is an auth problem: {msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reauth_required_has_warning_accent() {
|
|
let block = SessionEventBlock::new(SessionEvent::ReAuthRequired);
|
|
let theme = Theme::current();
|
|
let accent = block.accent(&ctx());
|
|
assert_eq!(
|
|
accent.map(|a| a.color),
|
|
Some(theme.warning),
|
|
"re-auth prompt must stand out with a warning accent"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn context_too_large_message_is_actionable() {
|
|
let msg = SessionEvent::ContextTooLarge.message();
|
|
assert!(
|
|
msg.to_lowercase().contains("too large"),
|
|
"must explain the conversation is too large: {msg}"
|
|
);
|
|
assert!(
|
|
msg.contains("/new"),
|
|
"must offer /new as the recovery action: {msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn context_too_large_has_warning_accent() {
|
|
let block = SessionEventBlock::new(SessionEvent::ContextTooLarge);
|
|
let theme = Theme::current();
|
|
let accent = block.accent(&ctx());
|
|
assert_eq!(
|
|
accent.map(|a| a.color),
|
|
Some(theme.warning),
|
|
"context-too-large prompt must stand out with a warning accent"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compaction_completed_renders_before_after_delta() {
|
|
let event = SessionEvent::CompactionCompleted {
|
|
tokens_before: Some(48_800),
|
|
tokens_after: 27_100,
|
|
elapsed_ms: Some(21_000),
|
|
};
|
|
assert_eq!(
|
|
event.message(),
|
|
"Context compacted: 48.8k → 27.1k tokens (21.0s)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compaction_completed_without_before_keeps_legacy_format() {
|
|
let event = SessionEvent::CompactionCompleted {
|
|
tokens_before: None,
|
|
tokens_after: 27_100,
|
|
elapsed_ms: None,
|
|
};
|
|
assert_eq!(event.message(), "Context compacted → 27.1k tokens");
|
|
}
|
|
|
|
#[test]
|
|
fn compaction_failed_empty_error_is_terse() {
|
|
let event = SessionEvent::CompactionFailed {
|
|
error: String::new(),
|
|
};
|
|
assert_eq!(event.message(), "Compaction failed.");
|
|
}
|
|
|
|
#[test]
|
|
fn compaction_failed_curated_error_is_appended() {
|
|
let event = SessionEvent::CompactionFailed {
|
|
error: "out of credits or over your spending limit. Add credits and retry.".into(),
|
|
};
|
|
assert_eq!(
|
|
event.message(),
|
|
"Compaction failed: out of credits or over your spending limit. Add credits and retry."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compaction_failed_has_warning_accent() {
|
|
let block = SessionEventBlock::new(SessionEvent::CompactionFailed {
|
|
error: "out of credits or over your spending limit. Add credits and retry.".into(),
|
|
});
|
|
let theme = Theme::current();
|
|
assert_eq!(
|
|
block.accent(&ctx()).map(|a| a.color),
|
|
Some(theme.warning),
|
|
"an actionable compaction failure must use a warning accent, not muted"
|
|
);
|
|
}
|
|
|
|
fn ctx() -> BlockContext {
|
|
BlockContext {
|
|
mode: crate::scrollback::types::DisplayMode::Expanded,
|
|
is_running: false,
|
|
width: 80,
|
|
raw: false,
|
|
max_lines: None,
|
|
appearance: crate::appearance::AppearanceConfig::default(),
|
|
is_selected: false,
|
|
cwd: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn memory_saved_message_formats_correctly() {
|
|
let event = SessionEvent::MemorySaved {
|
|
path: "/some/absolute/path/MEMORY.md".into(),
|
|
trigger: "flush".into(),
|
|
};
|
|
let msg = event.message();
|
|
assert!(msg.starts_with("Memory saved (flush)"));
|
|
assert!(msg.contains("/memory to view"));
|
|
}
|
|
|
|
#[test]
|
|
fn recap_manual_and_auto_use_same_label() {
|
|
let manual = SessionEvent::Recap {
|
|
summary: "refactored the parser".into(),
|
|
auto: false,
|
|
};
|
|
assert_eq!(manual.message(), "Recap \u{2014} refactored the parser");
|
|
|
|
let auto = SessionEvent::Recap {
|
|
summary: "refactored the parser".into(),
|
|
auto: true,
|
|
};
|
|
assert_eq!(auto.message(), "Recap \u{2014} refactored the parser");
|
|
}
|
|
|
|
/// `ctx()` with an overridden display mode / selection state.
|
|
fn recap_ctx(mode: DisplayMode, is_selected: bool) -> BlockContext {
|
|
BlockContext {
|
|
mode,
|
|
is_selected,
|
|
..ctx()
|
|
}
|
|
}
|
|
|
|
/// `ctx()` in the running/loading state (mode is `Expanded` from `ctx()`).
|
|
fn recap_running_ctx() -> BlockContext {
|
|
BlockContext {
|
|
is_running: true,
|
|
..ctx()
|
|
}
|
|
}
|
|
|
|
fn plain(line: &BlockLine) -> String {
|
|
crate::scrollback::types::line_plain_text(&line.content)
|
|
}
|
|
|
|
#[test]
|
|
fn recap_renders_tool_style_header_and_body() {
|
|
let block = SessionEventBlock::new(SessionEvent::Recap {
|
|
summary: "Refactored the parser and added tests.".into(),
|
|
auto: false,
|
|
});
|
|
let out = block.output(&recap_ctx(DisplayMode::Expanded, false));
|
|
assert_eq!(
|
|
plain(&out.lines[0]),
|
|
"Recap",
|
|
"header line is the 'Recap' label"
|
|
);
|
|
let body = out.lines.iter().map(plain).collect::<Vec<_>>().join("\n");
|
|
assert!(
|
|
body.contains("Refactored the parser and added tests."),
|
|
"summary is shown as body text: {body}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recap_is_foldable_selectable_and_bulleted_open_by_default() {
|
|
let block = SessionEventBlock::new(SessionEvent::Recap {
|
|
summary: "did stuff".into(),
|
|
auto: false,
|
|
});
|
|
assert!(
|
|
block.is_foldable(),
|
|
"recap collapses/expands like a tool call"
|
|
);
|
|
assert!(
|
|
block.is_selectable(),
|
|
"recap is navigable so it can be folded"
|
|
);
|
|
assert!(
|
|
block.has_bullet(&ctx()),
|
|
"recap shows the shared tool bullet under default appearance"
|
|
);
|
|
assert_eq!(
|
|
block.default_display_mode(),
|
|
DisplayMode::Expanded,
|
|
"recap is open by default"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recap_accent_and_bullet_use_neutral_tool_color_when_idle() {
|
|
let block = SessionEventBlock::new(SessionEvent::Recap {
|
|
summary: "did stuff".into(),
|
|
auto: false,
|
|
});
|
|
let theme = Theme::current();
|
|
// Expanded: static neutral tool accent bar (no special color).
|
|
let expanded = block.accent(&recap_ctx(DisplayMode::Expanded, false));
|
|
assert_eq!(expanded.map(|a| a.color), Some(theme.accent_tool));
|
|
assert_eq!(expanded.map(|a| a.animated), Some(false));
|
|
// Collapsed: no accent bar; bullet falls back to the default gray dot.
|
|
assert_eq!(
|
|
block.accent(&recap_ctx(DisplayMode::Collapsed, false)),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
block.bullet(&recap_ctx(DisplayMode::Collapsed, false)),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recap_loading_shows_header_only_with_animated_sidebar() {
|
|
// Empty summary + running entry = the in-flight loading state.
|
|
let block = SessionEventBlock::new(SessionEvent::Recap {
|
|
summary: String::new(),
|
|
auto: false,
|
|
});
|
|
let theme = Theme::current();
|
|
let rc = recap_running_ctx();
|
|
|
|
// Header only — no blank line or body while still generating.
|
|
let out = block.output(&rc);
|
|
assert_eq!(out.lines.len(), 1, "loading recap is just the header");
|
|
assert_eq!(plain(&out.lines[0]), "Recap");
|
|
|
|
// The sidebar + bullet animate in gray (the feedback) — not the magenta
|
|
// running color used for active tool turns.
|
|
let accent = block.accent(&rc).expect("loading recap has an accent bar");
|
|
assert_eq!(accent.color, theme.gray);
|
|
assert!(accent.animated, "loading sidebar animates");
|
|
assert_eq!(
|
|
block.bullet(&rc),
|
|
Some(accent),
|
|
"bullet animates while loading"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recap_collapsed_shows_header_with_first_line_preview() {
|
|
let block = SessionEventBlock::new(SessionEvent::Recap {
|
|
summary: "First line of recap.\nSecond line.".into(),
|
|
auto: false,
|
|
});
|
|
let out = block.output(&recap_ctx(DisplayMode::Collapsed, false));
|
|
assert_eq!(out.lines.len(), 1, "collapsed recap is a single line");
|
|
let text = plain(&out.lines[0]);
|
|
assert!(text.starts_with("Recap"), "starts with the header: {text}");
|
|
assert!(
|
|
text.contains("First line of recap."),
|
|
"shows a preview: {text}"
|
|
);
|
|
assert!(
|
|
!text.contains("Second line"),
|
|
"preview is first line only: {text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recap_loading_or_empty_is_not_selectable_or_foldable() {
|
|
// The in-flight spinner (empty summary) — and any stray empty recap —
|
|
// must not be selectable or foldable, so j/k never stops on a block
|
|
// that can't fold and offers no interaction (mirrors `is_foldable`).
|
|
for summary in ["", " \n "] {
|
|
let block = SessionEventBlock::new(SessionEvent::Recap {
|
|
summary: summary.into(),
|
|
auto: false,
|
|
});
|
|
assert!(
|
|
!block.is_selectable(),
|
|
"empty/loading recap is not selectable: {summary:?}"
|
|
);
|
|
assert!(
|
|
!block.is_foldable(),
|
|
"empty/loading recap is not foldable: {summary:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn recap_expanded_header_is_not_text_selectable() {
|
|
let block = SessionEventBlock::new(SessionEvent::Recap {
|
|
summary: "We fixed the recap body selection.\nSecond line.".into(),
|
|
auto: false,
|
|
});
|
|
let out = block.output(&recap_ctx(DisplayMode::Expanded, false));
|
|
assert!(
|
|
matches!(out.lines[0].selectable, Selectable::None),
|
|
"header must be decoration, not copyable"
|
|
);
|
|
assert_eq!(out.lines[0].selection_range, None);
|
|
assert!(
|
|
matches!(out.lines[1].selectable, Selectable::None),
|
|
"blank gap under header must not be selectable"
|
|
);
|
|
let body: Vec<_> = out.lines.iter().skip(2).collect();
|
|
assert!(!body.is_empty(), "expected body lines");
|
|
for line in body {
|
|
assert!(
|
|
matches!(line.selectable, Selectable::All),
|
|
"body lines are fully selectable"
|
|
);
|
|
assert_eq!(
|
|
line.selection_range,
|
|
Some(0),
|
|
"body shares one selection range so multi-line drag works"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn recap_collapsed_only_preview_is_text_selectable() {
|
|
let block = SessionEventBlock::new(SessionEvent::Recap {
|
|
summary: "First line of recap.\nSecond line.".into(),
|
|
auto: false,
|
|
});
|
|
let out = block.output(&recap_ctx(DisplayMode::Collapsed, false));
|
|
assert_eq!(out.lines.len(), 1);
|
|
let line = &out.lines[0];
|
|
assert!(
|
|
matches!(&line.selectable, Selectable::Spans(r) if *r == (1..2)),
|
|
"only the preview span is selectable, not the Recap label: {:?}",
|
|
line.selectable
|
|
);
|
|
assert_eq!(line.selection_range, Some(0));
|
|
assert_eq!(
|
|
line.selection_text.as_deref(),
|
|
Some("First line of recap."),
|
|
"copy payload is the preview body only"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn non_recap_events_stay_non_interactive() {
|
|
let block = SessionEventBlock::new(SessionEvent::TurnCompleted {
|
|
elapsed: Some(Duration::from_secs(5)),
|
|
});
|
|
assert!(!block.is_foldable());
|
|
assert!(!block.is_selectable());
|
|
assert!(!block.has_bullet(&ctx()));
|
|
assert_eq!(block.accent(&ctx()), None);
|
|
}
|
|
|
|
fn stop_group(name: &str) -> (String, Vec<HookRunEntry>) {
|
|
use super::super::tool::HookRunStatus;
|
|
(
|
|
name.to_string(),
|
|
vec![HookRunEntry {
|
|
name: "global/notify".into(),
|
|
status: HookRunStatus::Success {
|
|
elapsed: Duration::from_millis(12),
|
|
},
|
|
output: None,
|
|
}],
|
|
)
|
|
}
|
|
|
|
fn completed_with_stop_hooks() -> SessionEventBlock {
|
|
SessionEventBlock::with_stop_hooks(
|
|
SessionEvent::TurnCompleted {
|
|
elapsed: Some(Duration::from_secs(5)),
|
|
},
|
|
vec![stop_group("stop")],
|
|
None,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn stop_hooks_summary_is_right_justified_on_marker_line() {
|
|
let block = completed_with_stop_hooks();
|
|
let out = block.output(&BlockContext {
|
|
mode: DisplayMode::Collapsed,
|
|
..ctx()
|
|
});
|
|
assert_eq!(out.lines.len(), 1, "collapsed marker stays a single line");
|
|
let text = plain(&out.lines[0]);
|
|
assert!(
|
|
text.starts_with("Worked for 5.0s."),
|
|
"marker text keeps the left edge: {text}"
|
|
);
|
|
assert!(
|
|
text.ends_with("stop [hooks: 1]"),
|
|
"summary sits at the right edge: {text}"
|
|
);
|
|
assert_eq!(
|
|
unicode_width::UnicodeWidthStr::width(text.as_str()),
|
|
80,
|
|
"padding right-justifies the summary to the content width"
|
|
);
|
|
// Drag-copy stays on the marker text, never the padding or counts.
|
|
assert!(
|
|
matches!(&out.lines[0].selectable, Selectable::Spans(r) if *r == (0..1)),
|
|
"only the marker text span is selectable: {:?}",
|
|
out.lines[0].selectable
|
|
);
|
|
assert_eq!(
|
|
out.lines[0].selection_text.as_deref(),
|
|
Some("Worked for 5.0s.")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stop_hooks_summary_wraps_to_own_line_when_narrow() {
|
|
let block = completed_with_stop_hooks();
|
|
// "Worked for 5.0s." is 16 cols; the summary is 16 — no room
|
|
// at width 30, so the summary right-justifies on its own line.
|
|
let out = block.output(&BlockContext {
|
|
mode: DisplayMode::Collapsed,
|
|
width: 30,
|
|
..ctx()
|
|
});
|
|
assert_eq!(out.lines.len(), 2);
|
|
let summary_line = plain(&out.lines[1]);
|
|
assert!(summary_line.ends_with("stop [hooks: 1]"));
|
|
assert_eq!(
|
|
unicode_width::UnicodeWidthStr::width(summary_line.as_str()),
|
|
30
|
|
);
|
|
assert!(
|
|
matches!(out.lines[1].selectable, Selectable::None),
|
|
"the overflow summary line is decoration"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stop_hooks_summary_goes_below_wrapped_multi_line_marker() {
|
|
// A wrapped TurnFailed marker whose first line has room for the
|
|
// summary: attaching there would read mid-paragraph, so the summary
|
|
// right-justifies on its own line below the text instead.
|
|
let block = SessionEventBlock::with_stop_hooks(
|
|
SessionEvent::TurnFailed {
|
|
error: format!("boom {}", "x".repeat(70)),
|
|
elapsed: Some(Duration::from_secs(3)),
|
|
},
|
|
vec![stop_group("stop")],
|
|
None,
|
|
);
|
|
let out = block.output(&BlockContext {
|
|
mode: DisplayMode::Collapsed,
|
|
..ctx()
|
|
});
|
|
assert_eq!(out.lines.len(), 3, "two wrapped text lines + summary line");
|
|
assert!(
|
|
!plain(&out.lines[0]).contains("[hooks:"),
|
|
"no summary interleaved with the wrapped text: {}",
|
|
plain(&out.lines[0])
|
|
);
|
|
let summary_line = plain(&out.lines[2]);
|
|
assert!(summary_line.ends_with("stop [hooks: 1]"));
|
|
assert!(
|
|
matches!(out.lines[2].selectable, Selectable::None),
|
|
"the summary line is decoration"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stop_hooks_detail_only_when_expanded() {
|
|
let block = completed_with_stop_hooks();
|
|
let collapsed = block.output(&BlockContext {
|
|
mode: DisplayMode::Collapsed,
|
|
..ctx()
|
|
});
|
|
let collapsed_text = collapsed
|
|
.lines
|
|
.iter()
|
|
.map(plain)
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
!collapsed_text.contains("global/notify"),
|
|
"collapsed marker hides per-hook detail: {collapsed_text}"
|
|
);
|
|
|
|
let expanded = block.output(&ctx());
|
|
let expanded_text = expanded
|
|
.lines
|
|
.iter()
|
|
.map(plain)
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
expanded_text.contains("global/notify (12ms)"),
|
|
"expanded marker shows per-hook detail: {expanded_text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn marker_with_stop_hooks_is_interactive_and_starts_collapsed() {
|
|
let block = completed_with_stop_hooks();
|
|
assert!(block.is_foldable(), "fold reveals per-hook detail");
|
|
assert!(block.is_selectable(), "navigable so it can be folded");
|
|
assert_eq!(block.default_display_mode(), DisplayMode::Collapsed);
|
|
|
|
// All-skipped batches change nothing (mirrors has_content()).
|
|
use super::super::tool::HookRunStatus;
|
|
let skipped = SessionEventBlock::with_stop_hooks(
|
|
SessionEvent::TurnCompleted {
|
|
elapsed: Some(Duration::from_secs(5)),
|
|
},
|
|
vec![(
|
|
"stop".into(),
|
|
vec![HookRunEntry {
|
|
name: "h".into(),
|
|
status: HookRunStatus::Skipped,
|
|
output: None,
|
|
}],
|
|
)],
|
|
None,
|
|
);
|
|
assert!(!skipped.is_foldable());
|
|
assert!(!skipped.is_selectable());
|
|
let out = skipped.output(&BlockContext {
|
|
mode: DisplayMode::Collapsed,
|
|
..ctx()
|
|
});
|
|
assert_eq!(plain(&out.lines[0]), "Worked for 5.0s.");
|
|
}
|
|
|
|
#[test]
|
|
fn stop_and_stop_failure_groups_render_labeled_sections() {
|
|
let block = SessionEventBlock::with_stop_hooks(
|
|
SessionEvent::TurnFailed {
|
|
error: "boom".into(),
|
|
elapsed: Some(Duration::from_secs(3)),
|
|
},
|
|
vec![stop_group("stop_failure"), stop_group("stop")],
|
|
None,
|
|
);
|
|
let out = block.output(&BlockContext {
|
|
mode: DisplayMode::Collapsed,
|
|
..ctx()
|
|
});
|
|
let text = plain(&out.lines[0]);
|
|
assert!(
|
|
text.ends_with("stop_failure [hooks: 1] stop [hooks: 1]"),
|
|
"both groups summarized: {text}"
|
|
);
|
|
|
|
let expanded = block.output(&ctx());
|
|
let expanded_text = expanded
|
|
.lines
|
|
.iter()
|
|
.map(plain)
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
expanded_text.contains("stop_failure") && expanded_text.contains("global/notify"),
|
|
"multi-group detail keeps section headers: {expanded_text}"
|
|
);
|
|
}
|
|
|
|
/// A marker block with snapshotted work counts (parked by default — the
|
|
/// shape `maybe_push_parked_marker` pushes).
|
|
fn marker_with_work(commands: usize, monitors: usize, subagents: usize) -> SessionEventBlock {
|
|
SessionEventBlock {
|
|
event: SessionEvent::TurnCompleted {
|
|
elapsed: Some(Duration::from_secs(24)),
|
|
},
|
|
stop_hooks: Vec::new(),
|
|
prompt_id: None,
|
|
end_work: Some(EndWork {
|
|
running_commands: commands,
|
|
running_monitors: monitors,
|
|
running_subagents: subagents,
|
|
}),
|
|
parked: true,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn end_work_suffix_names_actual_pending_work() {
|
|
let msg = |c, m, s| marker_with_work(c, m, s).marker_text();
|
|
assert_eq!(msg(1, 0, 0), "Worked for 24s. 1 command still running.");
|
|
assert_eq!(msg(0, 0, 1), "Worked for 24s. 1 subagent still running.");
|
|
assert_eq!(msg(3, 0, 0), "Worked for 24s. 3 commands still running.");
|
|
assert_eq!(
|
|
msg(2, 0, 1),
|
|
"Worked for 24s. 2 commands and 1 subagent still running."
|
|
);
|
|
// Monitors are counted apart from commands so a long-lived watch
|
|
// reads as "1 monitor", not a stuck command.
|
|
assert_eq!(msg(0, 1, 0), "Worked for 24s. 1 monitor still running.");
|
|
assert_eq!(
|
|
msg(1, 1, 2),
|
|
"Worked for 24s. 1 command, 1 monitor and 2 subagents still running."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn end_work_suffix_disappears_at_zero_counts() {
|
|
// All-zero counts (a sleep-park with nothing running) and a legacy
|
|
// `end_work: None` marker must both render the byte-identical plain
|
|
// completion text.
|
|
assert_eq!(marker_with_work(0, 0, 0).marker_text(), "Worked for 24s.");
|
|
assert_eq!(
|
|
SessionEventBlock::new(SessionEvent::TurnCompleted {
|
|
elapsed: Some(Duration::from_secs(24)),
|
|
})
|
|
.marker_text(),
|
|
"Worked for 24s."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn still_running_text_is_the_work_only_status_form() {
|
|
// The between-turns status line reuses the marker suffix verbatim
|
|
// (no turn prefix), and stays absent at zero counts.
|
|
let work = EndWork {
|
|
running_commands: 2,
|
|
running_monitors: 0,
|
|
running_subagents: 1,
|
|
};
|
|
assert_eq!(
|
|
work.still_running_text().as_deref(),
|
|
Some("2 commands and 1 subagent still running.")
|
|
);
|
|
assert_eq!(EndWork::default().still_running_text(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn parked_markers_never_accept_stop_hooks() {
|
|
// A parked marker renders mid-turn, before any Stop hook fires.
|
|
let block = marker_with_work(1, 0, 0);
|
|
assert!(!block.accepts_stop_hooks(), "parked marker refuses hooks");
|
|
|
|
// The real terminal marker accepts.
|
|
let settled = SessionEventBlock::new(SessionEvent::TurnCompleted {
|
|
elapsed: Some(Duration::from_secs(24)),
|
|
});
|
|
assert!(settled.accepts_stop_hooks());
|
|
// Non-terminal events never accept, parked or not.
|
|
let recap = SessionEventBlock::new(SessionEvent::Recap {
|
|
summary: "did stuff".into(),
|
|
auto: false,
|
|
});
|
|
assert!(!recap.accepts_stop_hooks());
|
|
}
|
|
|
|
#[test]
|
|
fn parked_marker_output_reads_as_completed_with_work_suffix() {
|
|
// The rendered block line matches `marker_text` (the suffix rides the
|
|
// same muted line, wrapped like any other session event).
|
|
let block = marker_with_work(1, 0, 2);
|
|
let out = block.output(&ctx());
|
|
assert_eq!(
|
|
plain(&out.lines[0]),
|
|
"Worked for 24s. 1 command and 2 subagents still running."
|
|
);
|
|
}
|
|
}
|