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.
770 lines
29 KiB
Rust
770 lines
29 KiB
Rust
//! Verb-group aggregation: the "Read 10 files, Ran 2 subagents" header
|
|
//! label for a folded run of consecutive non-destructive tool calls and
|
|
//! subagent lifecycle rows, plus any finished collapsed thoughts the run
|
|
//! claims. Also home of the run classification ([`run_step`]) shared by the
|
|
//! layout fold, range resolution, and the label walk.
|
|
//!
|
|
//! The layout pass in `state/layout.rs` detects the runs and marks the header
|
|
//! via `EntryLayoutInfo::verb_group_header`; the render loop calls
|
|
//! [`verb_group_header_label`] to build the live label each frame (running
|
|
//! entries repaint every tick, so tense and counts update in place — no
|
|
//! per-call detail churns beside the label while the run executes).
|
|
//!
|
|
//! The same bucket vocabulary labels group-truncation ("N more") headers:
|
|
//! the render loop calls [`truncation_header_label`] with the fold's span,
|
|
//! and both walks feed the shared `BucketAccumulator` so the two label
|
|
//! families can't drift.
|
|
|
|
use ratatui::style::Modifier;
|
|
use ratatui::text::{Line, Span};
|
|
|
|
use crate::scrollback::block::RenderBlock;
|
|
use crate::scrollback::blocks::SubagentBlockKind;
|
|
use crate::scrollback::blocks::tool::{ToolCallBlock, VerbGroupKind};
|
|
use crate::scrollback::entry::ScrollbackEntry;
|
|
use crate::scrollback::types::DisplayMode;
|
|
use crate::theme::Theme;
|
|
|
|
/// One step of a verb-group run walk.
|
|
pub(crate) enum RunStep {
|
|
/// A collapsed verb-groupable tool or subagent entry: joins the run and
|
|
/// counts toward the fold threshold ([`RunScan::folds`]).
|
|
Member(VerbGroupKind),
|
|
/// A finished, collapsed, shown thinking entry: claims into the run
|
|
/// (folds to height 0) but never counts toward the threshold and never
|
|
/// appears in the header label.
|
|
ThoughtMember,
|
|
/// An entry that renders its own rows (or none) without joining or
|
|
/// breaking the run: hidden, streaming, user-opened, or chrome-carrying
|
|
/// thinking, and a manually-opened verb-groupable tool.
|
|
Transparent,
|
|
/// Anything else: ends the run.
|
|
Break,
|
|
}
|
|
|
|
/// Classify one entry for run walking — the single source of truth shared by
|
|
/// the layout fold scan, `verb_group_range_of`, and the label walk.
|
|
///
|
|
/// Members are collapsed verb-groupable tool calls and subagent lifecycle
|
|
/// rows; pending-user-input and hook-annotated rows stay standalone (their
|
|
/// prompt / `[hooks: N/M]` chrome must remain visible). A manually-opened
|
|
/// member is [`RunStep::Transparent`] — it keeps its own rows inside the run
|
|
/// instead of splitting it. Thinking never breaks a run: a finished
|
|
/// collapsed thought folds in as [`RunStep::ThoughtMember`]; hidden,
|
|
/// still-streaming, opened, or chrome-carrying thinking is
|
|
/// [`RunStep::Transparent`].
|
|
pub(crate) fn run_step(entry: &ScrollbackEntry, show_thinking: bool) -> RunStep {
|
|
// Prompt / `[hooks: N/M]` chrome must stay visible, so chrome-carrying
|
|
// entries never claim into a run.
|
|
let no_chrome = !entry.is_pending_user_input && entry.hook_data.is_none();
|
|
// Claimed entries are collapsed single-row + chromeless — the contract
|
|
// the fold's absolute height-2 expanded slot leans on. Tools check the
|
|
// collapsed half inline to split Member from Transparent.
|
|
let claimable = entry.display_mode == DisplayMode::Collapsed && no_chrome;
|
|
if let RenderBlock::ToolCall(block) = &entry.block
|
|
&& let Some(kind) = block.verb_group_kind()
|
|
&& no_chrome
|
|
{
|
|
if entry.display_mode == DisplayMode::Collapsed {
|
|
RunStep::Member(kind)
|
|
} else {
|
|
// A manually-opened member keeps its own rows without splitting
|
|
// the run — same treatment as opened thinking — so toggling a
|
|
// member of an expanded group never dissolves the group.
|
|
RunStep::Transparent
|
|
}
|
|
} else if matches!(entry.block, RenderBlock::Subagent(_)) {
|
|
if claimable {
|
|
RunStep::Member(VerbGroupKind::Subagent)
|
|
} else {
|
|
// Subagent rows are always collapsed single-row entries; this
|
|
// arm only guards chrome — prompt / hook rows must stay visible,
|
|
// so such an entry splits the run like a chrome-carrying tool.
|
|
RunStep::Break
|
|
}
|
|
} else if entry.block.is_thinking() {
|
|
if show_thinking && !entry.is_running && claimable {
|
|
RunStep::ThoughtMember
|
|
} else {
|
|
RunStep::Transparent
|
|
}
|
|
} else {
|
|
RunStep::Break
|
|
}
|
|
}
|
|
|
|
/// Whether an in-place block swap changes the entry's verb-group kind (e.g.
|
|
/// the eager `Other` placeholder refining into a `Read`). Such swaps change
|
|
/// fold membership, so the swap site must mark the entry structurally dirty
|
|
/// for the layout fold to catch up on the next frame.
|
|
pub(crate) fn verb_group_kind_changed(old: &RenderBlock, new: &RenderBlock) -> bool {
|
|
let kind_of = |block: &RenderBlock| match block {
|
|
RenderBlock::ToolCall(tc) => tc.verb_group_kind(),
|
|
_ => None,
|
|
};
|
|
kind_of(old) != kind_of(new)
|
|
}
|
|
|
|
/// Shape of one forward run walk, as reported by [`scan_run_forward`].
|
|
pub(crate) struct RunScan {
|
|
/// Member entries counted (tool calls and subagent rows), including a
|
|
/// member start entry. Thought members claim but never count — the fold
|
|
/// threshold is members-only.
|
|
pub(crate) members: usize,
|
|
/// Exclusive run end: one past the last claimed entry (member or thought
|
|
/// member), so trailing transparent entries stay outside the run.
|
|
pub(crate) end: usize,
|
|
/// Where the walk stopped: the breaking entry's index, or the first index
|
|
/// where `entry_at` returned `None`.
|
|
pub(crate) stop: usize,
|
|
}
|
|
|
|
impl RunScan {
|
|
/// Whether the run folds into a verb-group header row. One member is
|
|
/// enough — the compact label beats the member's own row, and the header
|
|
/// appearing with the first streaming call avoids a fold-in jump when the
|
|
/// second arrives. Members-only: thought members claim into runs but
|
|
/// never count, so a pure-thought run (whose label would be empty) never
|
|
/// folds. The single predicate shared by the layout fold and
|
|
/// `verb_group_range_of` so the two can't drift.
|
|
pub(crate) fn folds(&self) -> bool {
|
|
self.members >= 1
|
|
}
|
|
}
|
|
|
|
/// Walk a run forward from `start` until a breaking entry or the end of the
|
|
/// entries, and report the run's shape. Returns `None` when the entry at
|
|
/// `start` is missing or cannot anchor a run (members and thought members
|
|
/// can; transparent and breaking entries cannot) — anchor eligibility lives
|
|
/// in this function's matches, not in caller pre-checks — so a returned scan
|
|
/// always has `end > start` and `stop > start` (`members` may be 0 for a
|
|
/// thought-anchored walk with no members). The layout fold scan and
|
|
/// `verb_group_range_of` share this walk so both agree on the exact run
|
|
/// shape; the label walk needs per-member block data and stays its own loop,
|
|
/// kept in sync by its exhaustive `RunStep` match.
|
|
pub(crate) fn scan_run_forward<'e>(
|
|
entry_at: impl Fn(usize) -> Option<&'e ScrollbackEntry>,
|
|
start: usize,
|
|
show_thinking: bool,
|
|
) -> Option<RunScan> {
|
|
// Members and finished thoughts anchor runs; transparent thinking may
|
|
// sit inside one but cannot start one.
|
|
match run_step(entry_at(start)?, show_thinking) {
|
|
RunStep::Member(_) | RunStep::ThoughtMember => {}
|
|
RunStep::Transparent | RunStep::Break => return None,
|
|
}
|
|
let mut members = 0usize;
|
|
let mut end = start;
|
|
let mut i = start;
|
|
while let Some(entry) = entry_at(i) {
|
|
match run_step(entry, show_thinking) {
|
|
RunStep::Member(_) => {
|
|
members += 1;
|
|
end = i + 1;
|
|
}
|
|
RunStep::ThoughtMember => end = i + 1,
|
|
RunStep::Transparent => {}
|
|
RunStep::Break => break,
|
|
}
|
|
i += 1;
|
|
}
|
|
Some(RunScan {
|
|
members,
|
|
end,
|
|
stop: i,
|
|
})
|
|
}
|
|
|
|
/// Aggregated header state for one verb-group run.
|
|
pub struct VerbGroupHeaderLabel {
|
|
/// Styled label line rendered on the header row.
|
|
pub line: Line<'static>,
|
|
/// Plain-text label (selection/copy text for the header row).
|
|
pub text: String,
|
|
/// Any member still running (animated accent + present-tense verbs).
|
|
pub running: bool,
|
|
/// Any member failed (error accent).
|
|
pub failed: bool,
|
|
}
|
|
|
|
/// The single channel a group-header row's aggregated label travels
|
|
/// through, mirroring the fold families of `groups::GroupKind`. A header
|
|
/// row belongs to exactly one fold, so a row carries at most one label —
|
|
/// the exclusivity is structural. The variant picks the header chrome
|
|
/// (verb-run headers wear run-state accents; truncation headers keep the
|
|
/// dimmed fold chrome); the label payload is shared.
|
|
pub enum GroupHeaderLabel {
|
|
/// Verb-group run header ("Read 3 files, Searched 2 patterns").
|
|
VerbRun(VerbGroupHeaderLabel),
|
|
/// Labeled truncation ("N more") header ("Ran 6 commands").
|
|
Truncation(VerbGroupHeaderLabel),
|
|
}
|
|
|
|
impl GroupHeaderLabel {
|
|
/// The aggregated label payload, whichever fold family produced it.
|
|
pub fn label(&self) -> &VerbGroupHeaderLabel {
|
|
match self {
|
|
GroupHeaderLabel::VerbRun(label) | GroupHeaderLabel::Truncation(label) => label,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Per-kind aggregation bucket, ordered by first appearance in the run.
|
|
/// Borrows citation strings from the walked blocks (per-frame, no allocation).
|
|
struct Bucket<'e> {
|
|
kind: VerbGroupKind,
|
|
calls: usize,
|
|
/// Distinct-count override: when non-empty its size replaces `calls` as
|
|
/// the displayed count. Holds WebSearch citation URLs (distinct result
|
|
/// websites) and subagent child session ids (started + terminal rows of
|
|
/// one subagent count once; a burst of terminal rows counts each
|
|
/// distinct subagent).
|
|
sources: std::collections::HashSet<&'e str>,
|
|
}
|
|
|
|
/// Walk the verb-group run starting at `header_idx` (same [`run_step`] rules
|
|
/// as the layout fold: thinking and hidden entries are skipped, anything
|
|
/// else ends the run) and build the aggregated label. The label counts
|
|
/// members only: folded thoughts contribute nothing here and surface as
|
|
/// their own member rows only when the group is expanded.
|
|
///
|
|
/// `end` is the run's exclusive upper bound in `entries` indices. Callers
|
|
/// with the fold's span (see `state::groups`) pass its exact end so the
|
|
/// label counts precisely the entries the fold claimed; callers without one
|
|
/// pass `entries.len()` and rely on the [`RunStep::Break`] arm, which is
|
|
/// kept as the in-bound stop in either case.
|
|
pub fn verb_group_header_label(
|
|
entries: &[&ScrollbackEntry],
|
|
header_idx: usize,
|
|
end: usize,
|
|
show_thinking: bool,
|
|
theme: &Theme,
|
|
) -> VerbGroupHeaderLabel {
|
|
let mut acc = BucketAccumulator::default();
|
|
|
|
let end = end.min(entries.len());
|
|
for &entry in &entries[header_idx.min(end)..end] {
|
|
let kind = match run_step(entry, show_thinking) {
|
|
RunStep::Member(kind) => kind,
|
|
RunStep::Break => break,
|
|
RunStep::ThoughtMember | RunStep::Transparent => continue,
|
|
};
|
|
acc.push(kind, entry);
|
|
}
|
|
|
|
acc.into_label(theme)
|
|
}
|
|
|
|
/// Aggregated label for a truncation ("N more") header, describing the rows
|
|
/// the fold hid — "Ran 6 commands, Read 2 files" — through the same bucket
|
|
/// vocabulary as verb-group headers.
|
|
///
|
|
/// Walks the span's participants (skipping hidden thinking exactly like the
|
|
/// fold's projection) from `range.start`, stopping after `limit`
|
|
/// participants when given — the collapsed header describes only its hidden
|
|
/// prefix; the expanded collapse header passes `None` and describes the
|
|
/// whole run. Thoughts occupy participant slots but are NEVER bucketed:
|
|
/// like verb-group labels, group labels stay tools-only. Returns `None` —
|
|
/// the caller keeps the plain "N more" count — when nothing was bucketed (a
|
|
/// pure-thought prefix) or when any walked participant has no bucket
|
|
/// (System/SessionEvent rows, lifecycle chrome): thoughts are the only
|
|
/// participants a label may silently omit, anything else would make it
|
|
/// under-describe what the fold conceals.
|
|
pub fn truncation_header_label(
|
|
entries: &[&ScrollbackEntry],
|
|
range: std::ops::Range<usize>,
|
|
limit: Option<usize>,
|
|
show_thinking: bool,
|
|
theme: &Theme,
|
|
) -> Option<VerbGroupHeaderLabel> {
|
|
let mut acc = BucketAccumulator::default();
|
|
let end = range.end.min(entries.len());
|
|
let mut participants = 0usize;
|
|
|
|
for &entry in &entries[range.start.min(end)..end] {
|
|
if limit.is_some_and(|n| participants >= n) {
|
|
break;
|
|
}
|
|
if entry.is_hidden_thinking(show_thinking) {
|
|
continue;
|
|
}
|
|
participants += 1;
|
|
if entry.block.is_thinking() {
|
|
continue;
|
|
}
|
|
match &entry.block {
|
|
RenderBlock::ToolCall(block) => acc.push(block.label_kind()?, entry),
|
|
RenderBlock::Subagent(_) => acc.push(VerbGroupKind::Subagent, entry),
|
|
// A participant the vocabulary can't name would leave the label
|
|
// dishonest about what's hidden; decline so the numerically
|
|
// exact plain count renders instead.
|
|
_ => return None,
|
|
}
|
|
}
|
|
|
|
if acc.is_empty() {
|
|
return None;
|
|
}
|
|
Some(acc.into_label(theme))
|
|
}
|
|
|
|
/// Shared bucket accumulation + label rendering for the aggregated group
|
|
/// headers. Callers own the walk (which entries join and under what
|
|
/// classification); this owns per-kind counting, distinct-source overrides,
|
|
/// failure counting, and the rendered line.
|
|
#[derive(Default)]
|
|
struct BucketAccumulator<'e> {
|
|
buckets: Vec<Bucket<'e>>,
|
|
running: bool,
|
|
failed_count: usize,
|
|
}
|
|
|
|
impl<'e> BucketAccumulator<'e> {
|
|
fn is_empty(&self) -> bool {
|
|
self.buckets.is_empty()
|
|
}
|
|
|
|
fn push(&mut self, kind: VerbGroupKind, entry: &'e ScrollbackEntry) {
|
|
let pos = match self.buckets.iter().position(|b| b.kind == kind) {
|
|
Some(pos) => pos,
|
|
None => {
|
|
self.buckets.push(Bucket {
|
|
kind,
|
|
calls: 0,
|
|
sources: std::collections::HashSet::new(),
|
|
});
|
|
self.buckets.len() - 1
|
|
}
|
|
};
|
|
let bucket = &mut self.buckets[pos];
|
|
bucket.calls += 1;
|
|
// Bucketed entries are tool-call or subagent rows by construction
|
|
// (both walks); the block feeds the distinct-count override and
|
|
// failure detection.
|
|
match &entry.block {
|
|
RenderBlock::ToolCall(block) => {
|
|
if let ToolCallBlock::WebSearch(b) = block
|
|
&& b.is_success()
|
|
{
|
|
bucket
|
|
.sources
|
|
.extend(b.citations.iter().map(String::as_str));
|
|
}
|
|
if block_failed(block) {
|
|
self.failed_count += 1;
|
|
}
|
|
}
|
|
RenderBlock::Subagent(sb) => {
|
|
bucket.sources.insert(sb.child_session_id.as_str());
|
|
// Cancelled is deliberate, not an error — only Failed feeds
|
|
// the red suffix.
|
|
if matches!(sb.kind, SubagentBlockKind::Failed { .. }) {
|
|
self.failed_count += 1;
|
|
}
|
|
}
|
|
// Unreachable today; release keeps the generic count so the
|
|
// label can't desync from the fold that claimed the entry.
|
|
_ => debug_assert!(false, "bucketed entry has a block with no label-extras arm"),
|
|
}
|
|
|
|
if entry.is_running {
|
|
self.running = true;
|
|
}
|
|
}
|
|
|
|
fn into_label(self, theme: &Theme) -> VerbGroupHeaderLabel {
|
|
let text_style = theme.fg(theme.gray_bright).add_modifier(Modifier::BOLD);
|
|
let mut spans: Vec<Span<'static>> = Vec::new();
|
|
let mut text = String::new();
|
|
for (i, bucket) in self.buckets.iter().enumerate() {
|
|
let count = if bucket.sources.is_empty() {
|
|
bucket.calls
|
|
} else {
|
|
bucket.sources.len()
|
|
};
|
|
let segment = format!(
|
|
"{}{} {} {}",
|
|
if i == 0 { "" } else { ", " },
|
|
bucket.kind.verb(self.running),
|
|
count,
|
|
bucket.kind.noun(count)
|
|
);
|
|
text.push_str(&segment);
|
|
spans.push(Span::styled(segment, text_style));
|
|
}
|
|
if self.failed_count > 0 {
|
|
let suffix = format!(" · {} failed", self.failed_count);
|
|
text.push_str(&suffix);
|
|
spans.push(Span::styled(suffix, theme.fg(theme.accent_error)));
|
|
}
|
|
|
|
VerbGroupHeaderLabel {
|
|
line: Line::from(spans),
|
|
text,
|
|
running: self.running,
|
|
failed: self.failed_count > 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether a bucketed block completed with an error. Variants are listed
|
|
/// explicitly so a new `ToolCallBlock` variant must decide here too. The
|
|
/// action kinds reach labels only through truncation buckets (verb folds
|
|
/// exclude them), where their failures count like any other member's.
|
|
fn block_failed(block: &ToolCallBlock) -> bool {
|
|
match block {
|
|
ToolCallBlock::Read(b) => !b.is_success(),
|
|
ToolCallBlock::ListDir(b) => !b.is_success(),
|
|
ToolCallBlock::Search(b) => !b.is_success(),
|
|
ToolCallBlock::WebFetch(b) => !b.is_success(),
|
|
ToolCallBlock::WebSearch(b) => !b.is_success(),
|
|
ToolCallBlock::MemorySearch(b) => !b.is_success(),
|
|
ToolCallBlock::IntegrationSearch(b) => !b.is_success(),
|
|
ToolCallBlock::Skill(b) => !b.is_success(),
|
|
ToolCallBlock::Execute(b) => !b.is_success(),
|
|
ToolCallBlock::Edit(b) => !b.is_success(),
|
|
ToolCallBlock::UseTool(b) => !b.is_success(),
|
|
ToolCallBlock::Other(b) => !b.is_success(),
|
|
ToolCallBlock::Lifecycle(_) => false,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::scrollback::blocks::SubagentBlock;
|
|
use crate::scrollback::blocks::tool::{
|
|
ListDirToolCallBlock, ReadToolCallBlock, SearchToolCallBlock, WebSearchToolCallBlock,
|
|
};
|
|
|
|
fn entry(block: ToolCallBlock) -> ScrollbackEntry {
|
|
ScrollbackEntry::new(RenderBlock::ToolCall(block)).with_display_mode(DisplayMode::Collapsed)
|
|
}
|
|
|
|
fn read(path: &str) -> ScrollbackEntry {
|
|
entry(ToolCallBlock::Read(ReadToolCallBlock::new(path)))
|
|
}
|
|
|
|
fn subagent(block: SubagentBlock) -> ScrollbackEntry {
|
|
ScrollbackEntry::new(RenderBlock::Subagent(block))
|
|
}
|
|
|
|
fn sub_started(child_sid: &str) -> ScrollbackEntry {
|
|
subagent(SubagentBlock::started(
|
|
"task", child_sid, "explore", None, None, None, /*is_background=*/ true,
|
|
))
|
|
}
|
|
|
|
fn sub_completed(child_sid: &str) -> ScrollbackEntry {
|
|
subagent(SubagentBlock::completed(
|
|
"task",
|
|
child_sid,
|
|
std::time::Duration::from_secs(3),
|
|
))
|
|
}
|
|
|
|
fn label(entries: &[ScrollbackEntry]) -> VerbGroupHeaderLabel {
|
|
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
|
|
verb_group_header_label(
|
|
&refs,
|
|
0,
|
|
refs.len(),
|
|
/*show_thinking=*/ true,
|
|
&Theme::current(),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn buckets_in_first_appearance_order_with_plurality() {
|
|
let entries = vec![
|
|
read("a.rs"),
|
|
entry(ToolCallBlock::Search(SearchToolCallBlock::new("todo"))),
|
|
read("b.rs"),
|
|
entry(ToolCallBlock::ListDir(ListDirToolCallBlock::new("src"))),
|
|
];
|
|
let l = label(&entries);
|
|
assert_eq!(l.text, "Read 2 files, Searched 1 pattern, Listed 1 dir");
|
|
assert!(!l.running);
|
|
assert!(!l.failed);
|
|
}
|
|
|
|
#[test]
|
|
fn skill_reads_bucket_separately_from_files() {
|
|
let entries = vec![
|
|
read("a.rs"),
|
|
read("/x/skills/deploy/SKILL.md"),
|
|
read("b.rs"),
|
|
];
|
|
let l = label(&entries);
|
|
assert_eq!(l.text, "Read 2 files, Read 1 skill");
|
|
}
|
|
|
|
#[test]
|
|
fn failed_members_append_suffix_and_flag() {
|
|
let entries = vec![
|
|
read("a.rs"),
|
|
entry(ToolCallBlock::Read(
|
|
ReadToolCallBlock::new("gone.rs").with_error("no such file"),
|
|
)),
|
|
entry(ToolCallBlock::Read(
|
|
ReadToolCallBlock::new("also-gone.rs").with_error("no such file"),
|
|
)),
|
|
];
|
|
let l = label(&entries);
|
|
assert_eq!(l.text, "Read 3 files · 2 failed");
|
|
assert!(l.failed);
|
|
}
|
|
|
|
#[test]
|
|
fn running_flips_tense_only() {
|
|
let mut entries = vec![
|
|
read("a.rs"),
|
|
entry(ToolCallBlock::Search(SearchToolCallBlock::new("todo"))),
|
|
];
|
|
entries[1].is_running = true;
|
|
let l = label(&entries);
|
|
assert_eq!(l.text, "Reading 1 file, Searching 1 pattern");
|
|
assert!(l.running);
|
|
|
|
entries[1].is_running = false;
|
|
let l = label(&entries);
|
|
assert_eq!(l.text, "Read 1 file, Searched 1 pattern");
|
|
assert!(!l.running);
|
|
}
|
|
|
|
#[test]
|
|
fn web_search_counts_distinct_sources_with_call_fallback() {
|
|
let searched = |query: &str, citations: &[&str]| {
|
|
let mut b = WebSearchToolCallBlock::new(query);
|
|
b.citations = citations.iter().map(|s| s.to_string()).collect();
|
|
b.content = Some("results".into());
|
|
entry(ToolCallBlock::WebSearch(b))
|
|
};
|
|
// Three distinct URLs across two searches, one duplicated.
|
|
let entries = vec![
|
|
searched("kigi", &["https://a.com", "https://b.com"]),
|
|
searched("pager", &["https://b.com", "https://c.com"]),
|
|
];
|
|
let l = label(&entries);
|
|
assert_eq!(l.text, "Searched 3 websites");
|
|
|
|
// No citations yet (still running / no results): fall back to call count.
|
|
let entries = vec![
|
|
entry(ToolCallBlock::WebSearch(WebSearchToolCallBlock::new("a"))),
|
|
entry(ToolCallBlock::WebSearch(WebSearchToolCallBlock::new("b"))),
|
|
];
|
|
let l = label(&entries);
|
|
assert_eq!(l.text, "Searched 2 websites");
|
|
}
|
|
|
|
#[test]
|
|
fn run_ends_at_separator_and_skips_hidden_thinking() {
|
|
let entries = [
|
|
read("a.rs"),
|
|
ScrollbackEntry::new(RenderBlock::thinking("hmm")),
|
|
read("b.rs"),
|
|
ScrollbackEntry::new(RenderBlock::execute("ls")),
|
|
read("c.rs"),
|
|
];
|
|
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
|
|
let l = verb_group_header_label(
|
|
&refs,
|
|
0,
|
|
refs.len(),
|
|
/*show_thinking=*/ false,
|
|
&Theme::current(),
|
|
);
|
|
assert_eq!(l.text, "Read 2 files");
|
|
}
|
|
|
|
#[test]
|
|
fn label_stays_tools_only_across_shown_thinking_states() {
|
|
let mut streaming = ScrollbackEntry::new(RenderBlock::thinking("live"));
|
|
streaming.is_running = true;
|
|
let entries = [
|
|
read("a.rs"),
|
|
// Finished + collapsed: folds into the run, never labeled.
|
|
ScrollbackEntry::new(RenderBlock::thinking("done"))
|
|
.with_display_mode(DisplayMode::Collapsed),
|
|
read("b.rs"),
|
|
// Streaming: transparent, keeps its own live panel.
|
|
streaming,
|
|
read("c.rs"),
|
|
// User-opened: transparent, keeps its own rows.
|
|
ScrollbackEntry::new(RenderBlock::thinking("opened"))
|
|
.with_display_mode(DisplayMode::Expanded),
|
|
read("d.rs"),
|
|
// Non-thinking separators still end the run.
|
|
ScrollbackEntry::new(RenderBlock::execute("ls")),
|
|
read("e.rs"),
|
|
];
|
|
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
|
|
let l = verb_group_header_label(
|
|
&refs,
|
|
0,
|
|
refs.len(),
|
|
/*show_thinking=*/ true,
|
|
&Theme::current(),
|
|
);
|
|
assert_eq!(l.text, "Read 4 files");
|
|
}
|
|
|
|
fn execute() -> ScrollbackEntry {
|
|
ScrollbackEntry::new(RenderBlock::execute("ls")).with_display_mode(DisplayMode::Collapsed)
|
|
}
|
|
|
|
fn thought() -> ScrollbackEntry {
|
|
ScrollbackEntry::new(RenderBlock::thinking("hmm")).with_display_mode(DisplayMode::Collapsed)
|
|
}
|
|
|
|
fn trunc_label(
|
|
entries: &[ScrollbackEntry],
|
|
limit: Option<usize>,
|
|
) -> Option<VerbGroupHeaderLabel> {
|
|
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
|
|
truncation_header_label(
|
|
&refs,
|
|
0..refs.len(),
|
|
limit,
|
|
/*show_thinking=*/ true,
|
|
&Theme::current(),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn truncation_label_buckets_commands_and_never_thoughts() {
|
|
// 3 commands + 2 thoughts hidden: thoughts occupy participant slots
|
|
// but the label stays tools-only.
|
|
let entries = vec![execute(), thought(), execute(), thought(), execute()];
|
|
let l = trunc_label(&entries, None).expect("commands bucket");
|
|
assert_eq!(l.text, "Ran 3 commands");
|
|
}
|
|
|
|
#[test]
|
|
fn truncation_label_limit_counts_participants_not_buckets() {
|
|
// limit=3 covers [execute, thought, execute]: the thought consumes a
|
|
// participant slot without appearing in the label.
|
|
let entries = vec![execute(), thought(), execute(), execute(), execute()];
|
|
let l = trunc_label(&entries, Some(3)).expect("prefix buckets");
|
|
assert_eq!(l.text, "Ran 2 commands");
|
|
}
|
|
|
|
#[test]
|
|
fn truncation_label_mixes_kinds_in_first_appearance_order() {
|
|
let entries = vec![
|
|
execute(),
|
|
read("a.rs"),
|
|
ScrollbackEntry::new(RenderBlock::edit("src/main.rs", None))
|
|
.with_display_mode(DisplayMode::Collapsed),
|
|
execute(),
|
|
];
|
|
let l = trunc_label(&entries, None).expect("buckets");
|
|
assert_eq!(l.text, "Ran 2 commands, Read 1 file, Edited 1 file");
|
|
}
|
|
|
|
#[test]
|
|
fn truncation_label_none_for_pure_thought_prefix() {
|
|
let entries = vec![thought(), thought(), execute()];
|
|
assert!(
|
|
trunc_label(&entries, Some(2)).is_none(),
|
|
"a prefix of only thoughts buckets nothing; caller falls back to 'N more'"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn truncation_label_none_for_prefix_with_unbucketable_rows() {
|
|
let system = ScrollbackEntry::new(RenderBlock::system("hook ran"))
|
|
.with_display_mode(DisplayMode::Collapsed);
|
|
let entries = vec![execute(), system, execute()];
|
|
assert!(
|
|
trunc_label(&entries, None).is_none(),
|
|
"a hidden System row has no bucket; the plain count stays numerically honest"
|
|
);
|
|
// The unbucketable row past the limit never walks: the prefix labels.
|
|
let entries = vec![
|
|
execute(),
|
|
ScrollbackEntry::new(RenderBlock::system("hook ran"))
|
|
.with_display_mode(DisplayMode::Collapsed),
|
|
];
|
|
let l = trunc_label(&entries, Some(1)).expect("prefix buckets");
|
|
assert_eq!(l.text, "Ran 1 command");
|
|
}
|
|
|
|
#[test]
|
|
fn truncation_label_counts_failed_commands() {
|
|
let failed = RenderBlock::execute_with_output("false", "", Some("exit 1"));
|
|
let entries = vec![
|
|
execute(),
|
|
ScrollbackEntry::new(failed).with_display_mode(DisplayMode::Collapsed),
|
|
];
|
|
let l = trunc_label(&entries, None).expect("buckets");
|
|
assert_eq!(l.text, "Ran 2 commands · 1 failed");
|
|
assert!(l.failed);
|
|
}
|
|
|
|
#[test]
|
|
fn truncation_label_skips_hidden_thinking_without_consuming_limit() {
|
|
let mut hidden_thought = ScrollbackEntry::new(RenderBlock::thinking("hidden"));
|
|
hidden_thought.set_display_mode(DisplayMode::Collapsed);
|
|
let entries = [execute(), hidden_thought, execute()];
|
|
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
|
|
// show_thinking=false: the thought is hidden chrome, not a
|
|
// participant — both commands fit in a limit of 2.
|
|
let l = truncation_header_label(&refs, 0..refs.len(), Some(2), false, &Theme::current())
|
|
.expect("buckets");
|
|
assert_eq!(l.text, "Ran 2 commands");
|
|
}
|
|
|
|
#[test]
|
|
fn subagent_rows_bucket_with_tools_and_count_distinct_subagents() {
|
|
// A background subagent leaves BOTH its started row and a terminal
|
|
// row in the run; the child-session-id source override collapses
|
|
// them to one displayed subagent.
|
|
let entries = vec![
|
|
read("a.rs"),
|
|
sub_started("child-A"),
|
|
read("b.rs"),
|
|
sub_completed("child-A"),
|
|
];
|
|
let l = label(&entries);
|
|
assert_eq!(l.text, "Read 2 files, Ran 1 subagent");
|
|
assert!(!l.failed);
|
|
}
|
|
|
|
#[test]
|
|
fn subagent_completion_burst_counts_each_subagent() {
|
|
let entries = vec![sub_completed("child-A"), sub_completed("child-B")];
|
|
let l = label(&entries);
|
|
assert_eq!(l.text, "Ran 2 subagents");
|
|
}
|
|
|
|
#[test]
|
|
fn subagent_failed_feeds_suffix_cancelled_does_not() {
|
|
let entries = vec![
|
|
subagent(SubagentBlock::failed(
|
|
"task",
|
|
"child-A",
|
|
std::time::Duration::from_secs(3),
|
|
Some("boom".into()),
|
|
)),
|
|
subagent(SubagentBlock::cancelled(
|
|
"task",
|
|
"child-B",
|
|
std::time::Duration::from_secs(3),
|
|
)),
|
|
];
|
|
let l = label(&entries);
|
|
assert_eq!(l.text, "Ran 2 subagents · 1 failed");
|
|
assert!(l.failed);
|
|
}
|
|
|
|
#[test]
|
|
fn running_subagent_flips_group_tense() {
|
|
let mut entries = vec![read("a.rs"), sub_started("child-A")];
|
|
entries[1].is_running = true;
|
|
let l = label(&entries);
|
|
assert_eq!(l.text, "Reading 1 file, Running 1 subagent");
|
|
assert!(l.running);
|
|
}
|
|
}
|