Files
Kigi-CLI/crates/codegen/kigi-tui/src/notifications/title.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
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.
2026-07-18 02:48:46 -04:00

888 lines
28 KiB
Rust

use std::fmt::Write;
use crossterm::terminal::SetTitle;
use super::config::{TitleConfig, TitleItem};
use crate::acp::tracker::TurnActivity;
const TITLE_SPINNER: &[char] = &[
'\u{280B}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283C}', '\u{2834}', '\u{2826}', '\u{2827}',
];
/// Hold each spinner frame for this many ticks before advancing.
///
/// Terminals (notably Ghostty) debounce tab title updates, so writing a
/// new title every tick (~33ms at 30fps) produces more OSC 0 writes than
/// the tab bar can render. A divisor of 8 gives ~264ms per frame — slow
/// enough for debounced renderers while still looking animated.
const TITLE_SPINNER_DIVISOR: u64 = 8;
/// Hold the "⚠ Action Required" label for this many ticks before toggling
/// (only while unfocused; see focused field below).
///
/// A divisor of 15 at 30fps gives ~500ms visible, ~500ms hidden — a calm 1s
/// blink cycle that reads as intentional rather than broken flickering. When
/// focused we show the prefix statically to eliminate oscillation during
/// active interaction (e.g. typing in permission modals).
const ACTION_REQUIRED_BLINK_DIVISOR: u64 = 15;
/// State passed into `TitleManager::update()` each tick.
pub struct TitleState<'a> {
pub session_name: Option<&'a str>,
pub model: Option<&'a str>,
pub activity: Option<&'a TurnActivity>,
pub has_pending_permissions: bool,
pub cwd: Option<&'a str>,
pub turn_elapsed: Option<std::time::Duration>,
/// Whether the agent is busy (turn or command running), even if
/// `activity` is `None` (the "Waiting" gap before first chunk).
pub is_busy: bool,
/// Whether the terminal pane/window is currently focused (from
/// FocusTracker). Suppresses title blinking/oscillation while the
/// user is actively interacting.
pub focused: bool,
}
pub struct TitleManager {
items: Vec<TitleItem>,
last_title: String,
composed: String,
spinner_frame: usize,
tick_count: u64,
}
impl TitleManager {
pub fn new(config: &TitleConfig) -> Self {
Self {
items: config.items.clone(),
last_title: String::new(),
composed: String::new(),
spinner_frame: 0,
tick_count: 0,
}
}
/// Compose the title string from the current state.
///
/// Returns the escape sequence bytes to set the terminal title when the
/// composed title differs from the last one emitted. Returns `None` when
/// the title is unchanged (dedup).
pub fn update(&mut self, state: &TitleState<'_>) -> Option<String> {
self.composed.clear();
let mut has_parts = false;
// Iterate by index: TitleItem is Copy, so indexing avoids borrowing
// self.items while we mutate self.composed.
for i in 0..self.items.len() {
let item = self.items[i];
if write_item(
&mut self.composed,
&mut has_parts,
item,
state,
self.spinner_frame,
self.tick_count,
) {
continue;
}
}
if !has_parts {
self.composed.clear();
self.composed.push_str("kigi");
}
let result = if self.composed != self.last_title {
Some(build_title_escape(&self.composed))
} else {
None
};
// Swap into last_title when changed (update the dedup cache).
if result.is_some() {
std::mem::swap(&mut self.last_title, &mut self.composed);
}
// Advance counters after rendering so the first tick sees
// tick_count=0 (phase 0, ActionRequired visible) and spinner_frame=0.
self.tick_count = self.tick_count.wrapping_add(1);
self.spinner_frame =
(self.tick_count / TITLE_SPINNER_DIVISOR) as usize % TITLE_SPINNER.len();
result
}
pub fn reset(&mut self) -> String {
let esc = build_title_escape("kigi");
self.last_title.clear();
self.last_title.push_str("kigi");
self.spinner_frame = 0;
self.tick_count = 0;
esc
}
}
/// Render a single title item into `buf`. Returns `true` if a part was written.
fn write_item(
buf: &mut String,
has_parts: &mut bool,
item: TitleItem,
state: &TitleState<'_>,
spinner_frame: usize,
tick_count: u64,
) -> bool {
match item {
TitleItem::Kigi => {
push_separator(buf, has_parts);
buf.push_str("kigi");
}
TitleItem::Spinner => {
if !state.is_busy && state.activity.is_none() {
return false;
}
push_separator(buf, has_parts);
buf.push(TITLE_SPINNER[spinner_frame]);
}
TitleItem::Activity => {
if let Some(activity) = state.activity {
push_separator(buf, has_parts);
write_activity(buf, activity);
} else if state.is_busy {
push_separator(buf, has_parts);
buf.push_str("Waiting");
} else {
return false;
}
}
TitleItem::SessionName => {
let Some(name) = state.session_name.filter(|s| !s.is_empty()) else {
return false;
};
push_separator(buf, has_parts);
write_truncated(buf, name, 40);
}
TitleItem::Model => {
let Some(model) = state.model.filter(|s| !s.is_empty()) else {
return false;
};
push_separator(buf, has_parts);
write_truncated(buf, model, 30);
}
TitleItem::Cwd => {
let Some(cwd) = state.cwd else {
return false;
};
let short = cwd.rsplit('/').next().unwrap_or(cwd);
if short.is_empty() {
return false;
}
push_separator(buf, has_parts);
write_truncated(buf, short, 30);
}
TitleItem::TurnTimer => {
let Some(elapsed) = state.turn_elapsed else {
return false;
};
let secs = elapsed.as_secs();
if secs < 1 {
return false;
}
push_separator(buf, has_parts);
let _ = write!(buf, "{}s", secs);
}
TitleItem::ActionRequired => {
if !state.has_pending_permissions {
return false;
}
// Blink (oscillate) only while unfocused, for tab attention.
// When focused (user actively interacting, e.g. in permission
// modal or prompt), show static prefix to stop distracting flash.
let should_blink =
!state.focused && !(tick_count / ACTION_REQUIRED_BLINK_DIVISOR).is_multiple_of(2);
if should_blink {
return false;
}
push_separator(buf, has_parts);
buf.push_str("\u{26A0} Action Required");
}
}
*has_parts = true;
true
}
fn push_separator(buf: &mut String, has_parts: &mut bool) {
if *has_parts {
buf.push_str(" - ");
}
}
fn write_activity(buf: &mut String, activity: &TurnActivity) {
match activity {
TurnActivity::Thinking => buf.push_str("Thinking"),
TurnActivity::Responding => buf.push_str("Responding"),
TurnActivity::ToolRunning { title, description } => {
if let Some(desc) = description
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
buf.push_str(&crate::acp::tracker::format_waiting_for_subject(desc));
} else if title.is_empty() {
buf.push_str("Running tool");
} else {
buf.push_str("Running: ");
write_truncated(buf, title, 30);
}
}
TurnActivity::AutoCompacting => buf.push_str("Compacting"),
TurnActivity::Retrying {
attempt,
max_retries,
..
} => {
let _ = write!(buf, "Retrying ({}/{})", attempt, max_retries);
}
TurnActivity::Waiting(reason) => buf.push_str(&reason.label()),
}
}
fn write_truncated(buf: &mut String, s: &str, max: usize) {
// Fast path: ASCII-only strings where byte length == char count.
if s.len() <= max {
buf.push_str(s);
return;
}
// Slow path: iterate chars for multi-byte or over-limit strings.
for (count, ch) in s.chars().enumerate() {
if count >= max {
buf.push('\u{2026}');
return;
}
buf.push(ch);
}
}
/// Build the escape sequence for setting the terminal title without writing
/// it to stderr. The caller is responsible for routing these bytes through
/// the frame pipeline.
///
/// Control characters are stripped here: title parts include remote-sourced
/// strings (e.g. kimi.com conversation titles), which must not terminate the
/// OSC sequence early or inject escapes into the terminal.
fn build_title_escape(title: &str) -> String {
let sanitized: String = title.chars().filter(|c| !c.is_control()).collect();
let mut buf = Vec::new();
let _ = crossterm::queue!(&mut buf, SetTitle(sanitized));
String::from_utf8(buf).expect("crossterm SetTitle produces valid UTF-8")
}
#[cfg(test)]
mod tests {
use super::*;
fn default_config() -> TitleConfig {
TitleConfig::default()
}
fn config_with_items(items: Vec<TitleItem>) -> TitleConfig {
TitleConfig {
enabled: true,
items,
}
}
fn idle_state<'a>() -> TitleState<'a> {
TitleState {
session_name: None,
model: None,
activity: None,
has_pending_permissions: false,
cwd: None,
turn_elapsed: None,
is_busy: false,
focused: true,
}
}
// --- Title composition tests ---
#[test]
fn kigi_only_produces_just_kigi() {
let cfg = config_with_items(vec![TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let state = idle_state();
mgr.update(&state);
assert_eq!(mgr.last_title, "kigi");
}
#[test]
fn session_name_and_kigi_joined_with_separator() {
let cfg = config_with_items(vec![TitleItem::SessionName, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let state = TitleState {
session_name: Some("my project"),
..idle_state()
};
mgr.update(&state);
assert_eq!(mgr.last_title, "my project - kigi");
}
#[test]
fn missing_session_name_skipped() {
let cfg = config_with_items(vec![TitleItem::SessionName, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let state = idle_state();
mgr.update(&state);
assert_eq!(mgr.last_title, "kigi");
}
#[test]
fn empty_session_name_skipped() {
let cfg = config_with_items(vec![TitleItem::SessionName, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let state = TitleState {
session_name: Some(""),
..idle_state()
};
mgr.update(&state);
assert_eq!(mgr.last_title, "kigi");
}
#[test]
fn spinner_only_shown_when_active() {
let cfg = config_with_items(vec![TitleItem::Spinner, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
// Idle: spinner absent
mgr.update(&idle_state());
assert_eq!(mgr.last_title, "kigi");
// Active: spinner present
let activity = TurnActivity::Thinking;
let state = TitleState {
activity: Some(&activity),
..idle_state()
};
mgr.update(&state);
assert!(mgr.last_title.contains(" - kigi"));
let spinner_part: String = mgr.last_title.chars().take(1).collect();
assert!(
TITLE_SPINNER.contains(&spinner_part.chars().next().unwrap()),
"expected braille spinner char, got: {}",
spinner_part
);
}
#[test]
fn spinner_advances_with_divisor() {
let cfg = config_with_items(vec![TitleItem::Spinner]);
let mut mgr = TitleManager::new(&cfg);
let activity = TurnActivity::Thinking;
let state = TitleState {
activity: Some(&activity),
..idle_state()
};
// Run through one full cycle (DIVISOR ticks per frame * frame count).
let total = TITLE_SPINNER_DIVISOR as usize * TITLE_SPINNER.len();
let mut frames = Vec::new();
for _ in 0..total {
mgr.update(&state);
frames.push(mgr.last_title.clone());
}
// Across a full cycle we should see all spinner frames.
let unique: std::collections::HashSet<_> = frames.iter().collect();
assert_eq!(unique.len(), TITLE_SPINNER.len());
}
#[test]
fn spinner_holds_frame_for_divisor_ticks() {
let cfg = config_with_items(vec![TitleItem::Spinner]);
let mut mgr = TitleManager::new(&cfg);
let activity = TurnActivity::Thinking;
let state = TitleState {
activity: Some(&activity),
..idle_state()
};
// First frame should be stable for DIVISOR ticks.
mgr.update(&state);
let first = mgr.last_title.clone();
for _ in 1..TITLE_SPINNER_DIVISOR {
mgr.update(&state);
assert_eq!(
mgr.last_title, first,
"spinner should hold frame during divisor window"
);
}
// After DIVISOR ticks, the frame should advance.
mgr.update(&state);
assert_ne!(
mgr.last_title, first,
"spinner should advance after divisor ticks"
);
}
#[test]
fn spinner_wraps_around() {
let cfg = config_with_items(vec![TitleItem::Spinner]);
let mut mgr = TitleManager::new(&cfg);
let activity = TurnActivity::Thinking;
let state = TitleState {
activity: Some(&activity),
..idle_state()
};
// Run through more than one full cycle.
mgr.update(&state);
let first = mgr.last_title.clone();
let total = TITLE_SPINNER_DIVISOR as usize * TITLE_SPINNER.len();
for _ in 1..total {
mgr.update(&state);
}
// After a full cycle, the frame should wrap back to the first char.
mgr.update(&state);
assert_eq!(mgr.last_title, first);
}
#[test]
fn activity_label_thinking() {
let cfg = config_with_items(vec![TitleItem::Activity]);
let mut mgr = TitleManager::new(&cfg);
let activity = TurnActivity::Thinking;
let state = TitleState {
activity: Some(&activity),
..idle_state()
};
mgr.update(&state);
assert_eq!(mgr.last_title, "Thinking");
}
#[test]
fn activity_label_responding() {
let cfg = config_with_items(vec![TitleItem::Activity]);
let mut mgr = TitleManager::new(&cfg);
let activity = TurnActivity::Responding;
let state = TitleState {
activity: Some(&activity),
..idle_state()
};
mgr.update(&state);
assert_eq!(mgr.last_title, "Responding");
}
#[test]
fn activity_label_tool_running_with_title() {
let cfg = config_with_items(vec![TitleItem::Activity]);
let mut mgr = TitleManager::new(&cfg);
let activity = TurnActivity::ToolRunning {
title: "cargo build".to_owned(),
description: None,
};
let state = TitleState {
activity: Some(&activity),
..idle_state()
};
mgr.update(&state);
assert_eq!(mgr.last_title, "Running: cargo build");
}
#[test]
fn activity_label_tool_running_empty_title() {
let cfg = config_with_items(vec![TitleItem::Activity]);
let mut mgr = TitleManager::new(&cfg);
let activity = TurnActivity::ToolRunning {
title: String::new(),
description: None,
};
let state = TitleState {
activity: Some(&activity),
..idle_state()
};
mgr.update(&state);
assert_eq!(mgr.last_title, "Running tool");
}
#[test]
fn activity_label_retrying() {
let cfg = config_with_items(vec![TitleItem::Activity]);
let mut mgr = TitleManager::new(&cfg);
let activity = TurnActivity::Retrying {
attempt: 2,
max_retries: 5,
reason: "timeout".to_owned(),
};
let state = TitleState {
activity: Some(&activity),
..idle_state()
};
mgr.update(&state);
assert_eq!(mgr.last_title, "Retrying (2/5)");
}
#[test]
fn activity_hidden_when_idle() {
let cfg = config_with_items(vec![TitleItem::Activity, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
mgr.update(&idle_state());
assert_eq!(mgr.last_title, "kigi");
}
#[test]
fn spinner_shown_when_busy_without_activity() {
let cfg = config_with_items(vec![TitleItem::Spinner, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let state = TitleState {
is_busy: true,
..idle_state()
};
mgr.update(&state);
assert!(mgr.last_title.contains(" - kigi"));
let spinner_part: String = mgr.last_title.chars().take(1).collect();
assert!(
TITLE_SPINNER.contains(&spinner_part.chars().next().unwrap()),
"expected braille spinner char during Waiting, got: {}",
spinner_part
);
}
#[test]
fn activity_shows_waiting_when_busy_without_activity() {
let cfg = config_with_items(vec![TitleItem::Activity, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let state = TitleState {
is_busy: true,
..idle_state()
};
mgr.update(&state);
assert_eq!(mgr.last_title, "Waiting - kigi");
}
#[test]
fn activity_prefers_specific_activity_over_waiting() {
let cfg = config_with_items(vec![TitleItem::Activity, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let activity = TurnActivity::Thinking;
let state = TitleState {
activity: Some(&activity),
is_busy: true,
..idle_state()
};
mgr.update(&state);
assert_eq!(mgr.last_title, "Thinking - kigi");
}
// --- Action Required blinking ---
#[test]
fn action_required_visible_on_first_tick() {
let cfg = config_with_items(vec![TitleItem::ActionRequired, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let state = TitleState {
has_pending_permissions: true,
..idle_state()
};
// tick_count=0 (even) on first render → ActionRequired visible.
mgr.update(&state);
assert!(
mgr.last_title.contains("Action Required"),
"first tick should show ActionRequired, got: {}",
mgr.last_title
);
}
#[test]
fn action_required_blinks_across_ticks() {
let cfg = config_with_items(vec![TitleItem::ActionRequired, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let state = TitleState {
has_pending_permissions: true,
focused: false, // unfocused → should blink
..idle_state()
};
// First tick: tick_count=0, phase=0 (visible).
mgr.update(&state);
let t1 = mgr.last_title.clone();
// Title stays stable for the rest of the visible phase.
for _ in 1..ACTION_REQUIRED_BLINK_DIVISOR {
mgr.update(&state);
assert_eq!(
mgr.last_title, t1,
"title should stay stable within a blink phase"
);
}
// Crossing into the hidden phase.
mgr.update(&state);
let t2 = mgr.last_title.clone();
assert_ne!(t1, t2);
assert!(t1.contains("Action Required"));
assert!(!t2.contains("Action Required"));
}
#[test]
fn action_required_hidden_when_no_permissions() {
let cfg = config_with_items(vec![TitleItem::ActionRequired, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let state = TitleState {
has_pending_permissions: false,
..idle_state()
};
mgr.update(&state);
assert_eq!(mgr.last_title, "kigi");
mgr.update(&state);
assert_eq!(mgr.last_title, "kigi");
}
// --- Dedup (no-op when unchanged) ---
#[test]
fn dedup_skips_emission_when_unchanged() {
let cfg = config_with_items(vec![TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let state = idle_state();
mgr.update(&state);
assert_eq!(mgr.last_title, "kigi");
// Second update: title is identical, last_title stays the same (no re-emit).
let title_before = mgr.last_title.clone();
mgr.update(&state);
assert_eq!(mgr.last_title, title_before);
}
// --- Empty items list ---
#[test]
fn empty_items_produces_kigi_fallback() {
let cfg = config_with_items(vec![]);
let mut mgr = TitleManager::new(&cfg);
mgr.update(&idle_state());
assert_eq!(mgr.last_title, "kigi");
}
// --- Model item ---
#[test]
fn model_item_shown_when_present() {
let cfg = config_with_items(vec![TitleItem::Model, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let state = TitleState {
model: Some("kigi-3"),
..idle_state()
};
mgr.update(&state);
assert_eq!(mgr.last_title, "kigi-3 - kigi");
}
#[test]
fn model_item_hidden_when_none() {
let cfg = config_with_items(vec![TitleItem::Model, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
mgr.update(&idle_state());
assert_eq!(mgr.last_title, "kigi");
}
// --- Cwd item ---
#[test]
fn cwd_shows_last_component() {
let cfg = config_with_items(vec![TitleItem::Cwd, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let state = TitleState {
cwd: Some("/home/user/my-project"),
..idle_state()
};
mgr.update(&state);
assert_eq!(mgr.last_title, "my-project - kigi");
}
// --- TurnTimer item ---
#[test]
fn turn_timer_shown_when_above_one_second() {
let cfg = config_with_items(vec![TitleItem::TurnTimer, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let state = TitleState {
turn_elapsed: Some(std::time::Duration::from_secs(42)),
..idle_state()
};
mgr.update(&state);
assert_eq!(mgr.last_title, "42s - kigi");
}
#[test]
fn turn_timer_hidden_when_under_one_second() {
let cfg = config_with_items(vec![TitleItem::TurnTimer, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let state = TitleState {
turn_elapsed: Some(std::time::Duration::from_millis(500)),
..idle_state()
};
mgr.update(&state);
assert_eq!(mgr.last_title, "kigi");
}
// --- Truncation ---
#[test]
fn long_session_name_truncated_with_ellipsis() {
let cfg = config_with_items(vec![TitleItem::SessionName]);
let mut mgr = TitleManager::new(&cfg);
let long_name = "a".repeat(50);
let state = TitleState {
session_name: Some(&long_name),
..idle_state()
};
mgr.update(&state);
// 40 chars + ellipsis
assert_eq!(mgr.last_title.chars().count(), 41);
assert!(mgr.last_title.ends_with('\u{2026}'));
}
#[test]
fn short_session_name_not_truncated() {
let cfg = config_with_items(vec![TitleItem::SessionName]);
let mut mgr = TitleManager::new(&cfg);
let state = TitleState {
session_name: Some("short"),
..idle_state()
};
mgr.update(&state);
assert_eq!(mgr.last_title, "short");
}
// --- Reset ---
#[test]
fn reset_clears_state_and_emits_kigi() {
let cfg = config_with_items(vec![TitleItem::SessionName, TitleItem::Kigi]);
let mut mgr = TitleManager::new(&cfg);
let activity = TurnActivity::Thinking;
let state = TitleState {
session_name: Some("test"),
activity: Some(&activity),
..idle_state()
};
mgr.update(&state);
assert_ne!(mgr.last_title, "kigi");
mgr.reset();
assert_eq!(mgr.last_title, "kigi");
assert_eq!(mgr.spinner_frame, 0);
assert_eq!(mgr.tick_count, 0);
}
// --- Full default config integration ---
#[test]
fn default_config_active_turn_with_permissions() {
let cfg = default_config();
let mut mgr = TitleManager::new(&cfg);
let activity = TurnActivity::Responding;
let state = TitleState {
session_name: Some("my-session"),
activity: Some(&activity),
has_pending_permissions: true,
focused: false, // unfocused → should blink per original test
..idle_state()
};
// First tick: ActionRequired visible.
mgr.update(&state);
let t1 = mgr.last_title.clone();
// Advance through the blink divisor to reach the hidden phase.
for _ in 1..ACTION_REQUIRED_BLINK_DIVISOR {
mgr.update(&state);
}
mgr.update(&state);
let t2 = mgr.last_title.clone();
// Both should contain the persistent parts.
for t in [&t1, &t2] {
assert!(t.contains("kigi"), "title missing 'kigi': {t}");
assert!(t.contains("Responding"), "title missing 'Responding': {t}");
assert!(t.contains("my-session"), "title missing session name: {t}");
}
// One should have ActionRequired, the other should not (blinking).
let w1 = t1.contains("Action Required");
let w2 = t2.contains("Action Required");
assert_ne!(w1, w2, "expected blink toggle between t1={t1} and t2={t2}");
}
#[test]
fn default_config_idle_no_session() {
let cfg = default_config();
let mut mgr = TitleManager::new(&cfg);
mgr.update(&idle_state());
assert_eq!(mgr.last_title, "kigi");
}
// --- Multi-item combinations ---
#[test]
fn all_items_present_in_order() {
let cfg = config_with_items(vec![
TitleItem::Activity,
TitleItem::SessionName,
TitleItem::Model,
TitleItem::Cwd,
TitleItem::Kigi,
]);
let mut mgr = TitleManager::new(&cfg);
let activity = TurnActivity::Thinking;
let state = TitleState {
session_name: Some("proj"),
model: Some("kigi-3"),
activity: Some(&activity),
cwd: Some("/home/user/workspace"),
..idle_state()
};
mgr.update(&state);
assert_eq!(
mgr.last_title,
"Thinking - proj - kigi-3 - workspace - kigi"
);
}
#[test]
fn tool_title_truncated_in_activity() {
let cfg = config_with_items(vec![TitleItem::Activity]);
let mut mgr = TitleManager::new(&cfg);
let long_tool = "x".repeat(50);
let activity = TurnActivity::ToolRunning {
title: long_tool,
description: None,
};
let state = TitleState {
activity: Some(&activity),
..idle_state()
};
mgr.update(&state);
// "Running: " (9 chars) + 30 chars + ellipsis = 40 chars
assert!(mgr.last_title.starts_with("Running: "));
assert!(mgr.last_title.ends_with('\u{2026}'));
}
/// Remote-sourced title parts must not smuggle control bytes into the
/// OSC sequence: the only ESC/BEL in the output is crossterm's framing.
#[test]
fn title_escape_strips_control_characters() {
let esc = build_title_escape("evil\u{1b}]0;pwned\u{7}\r\ntitle");
let inner = esc
.strip_prefix("\u{1b}]0;")
.and_then(|s| s.strip_suffix('\u{7}'))
.expect("crossterm OSC 0 framing");
assert!(
!inner.chars().any(char::is_control),
"title payload must be control-free: {inner:?}"
);
assert_eq!(inner, "evil]0;pwnedtitle");
}
}