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:
@@ -0,0 +1,769 @@
|
||||
//! 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("grok", &["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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user