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).
925 lines
35 KiB
Rust
925 lines
35 KiB
Rust
//! Minimal-mode live region: the small pinned viewport holding the running-turn
|
|
//! tail (model B), a one-line status indicator, and the always-focused prompt.
|
|
//!
|
|
//! Layout (top → bottom): live tail · status · prompt. The tail shows the
|
|
//! bottom of the uncommitted run (streaming message / running tool) so output
|
|
//! is visible as it generates; finished blocks scroll up into native scrollback
|
|
//! via [`super::commit`]. When idle the tail is empty and only status + prompt
|
|
//! show.
|
|
use kigi_tui::app::PagerTerminal;
|
|
use kigi_tui::app::app_view::{ActiveView, AppView};
|
|
use kigi_tui::minimal_api;
|
|
use kigi_tui::render::Renderable;
|
|
use kigi_tui::scrollback::state::ScrollbackState;
|
|
use kigi_tui::scrollback::wrappers::EntryRenderer;
|
|
use kigi_tui::theme::Theme;
|
|
use kigi_tui::views::prompt_widget::PromptStyle;
|
|
use kigi_tui::views::turn_status;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::{Color, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Clear, Widget};
|
|
/// Left inset (columns) for every auxiliary live-region row: the status row,
|
|
/// the info bar, the exit hint, and the todo panel — and the prompt's
|
|
/// `chrome_pad_left`.
|
|
///
|
|
/// Minimal is flush-left: committed/tail blocks zero block pads via
|
|
/// [`super::commit::committed_appearance`] and reclaim the accent column via
|
|
/// `hide_accent`, so content glyphs (`◆` / `$` / message text) start at column
|
|
/// 0, matching the welcome card's outer edge. The prompt and auxiliary rows
|
|
/// share that left edge (no chrome pad) so nothing sits ragged against the
|
|
/// welcome box.
|
|
pub(super) fn live_left_inset(_appearance: &kigi_tui::appearance::AppearanceConfig) -> u16 {
|
|
0
|
|
}
|
|
/// Shrink `area` from the left by `inset` columns (clamped to the width).
|
|
fn inset_left(area: Rect, inset: u16) -> Rect {
|
|
let dx = inset.min(area.width);
|
|
Rect {
|
|
x: area.x + dx,
|
|
width: area.width - dx,
|
|
..area
|
|
}
|
|
}
|
|
/// The prompt style used by the minimal live region.
|
|
///
|
|
/// Shared with [`super::overlay::sync_viewport`] so viewport sizing measures the
|
|
/// prompt's height exactly as the live region will draw it.
|
|
pub(super) fn prompt_style(appearance: &kigi_tui::appearance::AppearanceConfig) -> PromptStyle {
|
|
PromptStyle {
|
|
focused: true,
|
|
show_prefix: appearance.prompt.show_prefix,
|
|
vpad_top: 0,
|
|
compact: appearance.prompt.compact,
|
|
chrome: true,
|
|
chrome_pad_left: live_left_inset(appearance),
|
|
chrome_pad_right: 0,
|
|
bg_override: Some(Color::Reset),
|
|
accent_color_override: None,
|
|
border_color_override: None,
|
|
prefix_override: None,
|
|
placeholder_override: None,
|
|
show_accent_line: false,
|
|
show_borders: false,
|
|
title: None,
|
|
image_preview: true,
|
|
}
|
|
}
|
|
/// Draw the pinned live region (tail + status + prompt) into the inline viewport.
|
|
pub fn draw_live(app: &mut AppView, terminal: &mut PagerTerminal) {
|
|
let force_todos = minimal_api::minimal_show_todos(app);
|
|
let auth_hint = crate::auth::minimal_auth_hint(&app.auth_state);
|
|
let pending_hint = minimal_pending_hint(&app.pending_action);
|
|
let transcript_hint = if minimal_api::minimal_ctrl_o_opens_transcript(app) {
|
|
"ctrl+o transcript"
|
|
} else {
|
|
"/transcript"
|
|
};
|
|
let transcript_progress = minimal_api::minimal_transcript_progress(app);
|
|
let AppView {
|
|
cursor,
|
|
agents,
|
|
active_view,
|
|
appearance,
|
|
..
|
|
} = app;
|
|
let agent_id = match active_view {
|
|
ActiveView::Agent(id) => Some(*id),
|
|
_ => None,
|
|
};
|
|
let theme = Theme::current();
|
|
let commit_app = super::commit::committed_appearance(appearance);
|
|
let compact = appearance.prompt.compact;
|
|
let style = prompt_style(appearance);
|
|
let row_inset = live_left_inset(appearance);
|
|
let layout_cfg = &appearance.scrollback.layout;
|
|
let term_h = terminal.last_known_area().height;
|
|
kigi_tui::render::draw::draw_frame(terminal, cursor, |frame, _link_spans| {
|
|
let area = frame.area();
|
|
if area.height == 0 || area.width < 4 {
|
|
return (None, None);
|
|
}
|
|
Clear.render(area, frame.buffer_mut());
|
|
let agent = agent_id.and_then(|id| agents.get_mut(&id));
|
|
let Some(agent) = agent else {
|
|
crate::auth::render_auth(frame.buffer_mut(), area, &theme, &auth_hint);
|
|
return (None, None);
|
|
};
|
|
let status_activity = minimal_advance_phase_timer(agent);
|
|
let show_todos = crate::todo::todo_panel_visible(agent, force_todos);
|
|
let queued = agent.session.pending_prompts.len() + agent.shared_queue.len();
|
|
if let Some(kind) = super::panel::active(agent) {
|
|
let cursor = super::panel::render(frame.buffer_mut(), area, agent, kind, &theme);
|
|
return (cursor, None);
|
|
}
|
|
if super::overlay::app_modal_active(agent) {
|
|
super::overlay::render_app_modal(frame.buffer_mut(), area, agent, compact);
|
|
return (None, None);
|
|
}
|
|
if minimal_api::extensions_modal(agent).is_some() {
|
|
let tick = (now_millis() / 100) as u64;
|
|
if let Some(state) = minimal_api::extensions_modal_mut(agent) {
|
|
kigi_tui::views::extensions_modal::render_extensions_modal(
|
|
frame.buffer_mut(),
|
|
area,
|
|
state,
|
|
None,
|
|
compact,
|
|
tick,
|
|
);
|
|
}
|
|
return (None, None);
|
|
}
|
|
if let Some(modal) = super::overlay::active_modal(agent) {
|
|
let status_h = 1u16.min(area.height);
|
|
let content_w = area.width as usize;
|
|
let modal_h = super::overlay::modal_height(modal, agent, term_h, content_w)
|
|
.min(area.height.saturating_sub(status_h))
|
|
.max(1);
|
|
let tail_h = area.height.saturating_sub(status_h + modal_h);
|
|
let tick = (now_millis() / 100) as u64;
|
|
if tail_h > 0 {
|
|
let turn_running = agent.session.state.is_turn_running();
|
|
draw_tail(
|
|
frame.buffer_mut(),
|
|
Rect {
|
|
x: area.x,
|
|
y: area.y,
|
|
width: area.width,
|
|
height: tail_h,
|
|
},
|
|
&agent.scrollback,
|
|
turn_running,
|
|
&theme,
|
|
&commit_app,
|
|
&agent.session.cwd,
|
|
tick,
|
|
);
|
|
}
|
|
render_minimal_status(
|
|
frame.buffer_mut(),
|
|
inset_left(
|
|
Rect {
|
|
x: area.x,
|
|
y: area.y + tail_h,
|
|
width: area.width,
|
|
height: status_h,
|
|
},
|
|
row_inset,
|
|
),
|
|
agent,
|
|
&status_activity,
|
|
transcript_progress,
|
|
&theme,
|
|
);
|
|
let modal_area = Rect {
|
|
x: area.x,
|
|
y: area.y + tail_h + status_h,
|
|
width: area.width,
|
|
height: modal_h,
|
|
};
|
|
let cursor = super::overlay::render_modal(
|
|
frame.buffer_mut(),
|
|
modal_area,
|
|
modal,
|
|
agent,
|
|
&theme,
|
|
term_h,
|
|
);
|
|
return (cursor, None);
|
|
}
|
|
let status_h = 1u16.min(area.height);
|
|
let overlay_h = super::overlay::overlay_rows(&agent.prompt, area.width)
|
|
.min(area.height.saturating_sub(status_h + 1));
|
|
let info_h = if overlay_h == 0 {
|
|
1u16.min(area.height.saturating_sub(status_h + 1))
|
|
} else {
|
|
0
|
|
};
|
|
let below_h = overlay_h + info_h;
|
|
let avail = area.height.saturating_sub(status_h + below_h);
|
|
let prompt_h = agent
|
|
.prompt
|
|
.desired_height(area.width, &style, false, avail)
|
|
.min(avail)
|
|
.max(1);
|
|
let rest = avail.saturating_sub(prompt_h);
|
|
let todos_cap = if force_todos {
|
|
rest
|
|
} else {
|
|
rest.min(crate::todo::MAX_TODO_ROWS)
|
|
};
|
|
let todo_lines = if show_todos {
|
|
crate::todo::todo_panel_lines(agent, todos_cap, force_todos)
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let todos_h = (todo_lines.len() as u16).min(rest);
|
|
let tail_h = rest.saturating_sub(todos_h);
|
|
let tick = (now_millis() / 100) as u64;
|
|
if tail_h > 0 {
|
|
let tail_area = Rect {
|
|
x: area.x,
|
|
y: area.y,
|
|
width: area.width,
|
|
height: tail_h,
|
|
};
|
|
let turn_running = agent.session.state.is_turn_running();
|
|
draw_tail(
|
|
frame.buffer_mut(),
|
|
tail_area,
|
|
&agent.scrollback,
|
|
turn_running,
|
|
&theme,
|
|
&commit_app,
|
|
&agent.session.cwd,
|
|
tick,
|
|
);
|
|
}
|
|
if todos_h > 0 {
|
|
crate::todo::render_todo_panel(
|
|
frame.buffer_mut(),
|
|
inset_left(
|
|
Rect {
|
|
x: area.x,
|
|
y: area.y + tail_h,
|
|
width: area.width,
|
|
height: todos_h,
|
|
},
|
|
row_inset,
|
|
),
|
|
&theme,
|
|
&todo_lines,
|
|
);
|
|
}
|
|
let status_area = inset_left(
|
|
Rect {
|
|
x: area.x,
|
|
y: area.y + tail_h + todos_h,
|
|
width: area.width,
|
|
height: status_h,
|
|
},
|
|
row_inset,
|
|
);
|
|
render_minimal_status(
|
|
frame.buffer_mut(),
|
|
status_area,
|
|
agent,
|
|
&status_activity,
|
|
transcript_progress,
|
|
&theme,
|
|
);
|
|
let prompt_area = Rect {
|
|
x: area.x,
|
|
y: area.y + tail_h + todos_h + status_h,
|
|
width: area.width,
|
|
height: prompt_h,
|
|
};
|
|
if overlay_h > 0 {
|
|
super::overlay::render(
|
|
frame.buffer_mut(),
|
|
area,
|
|
prompt_area,
|
|
&mut agent.prompt,
|
|
layout_cfg,
|
|
compact,
|
|
&theme,
|
|
);
|
|
} else if info_h > 0 {
|
|
let info_area = inset_left(
|
|
Rect {
|
|
x: area.x,
|
|
y: prompt_area.y + prompt_h,
|
|
width: area.width,
|
|
height: info_h,
|
|
},
|
|
row_inset,
|
|
);
|
|
if let Some(hint) = &pending_hint {
|
|
render_exit_hint(frame.buffer_mut(), info_area, &theme, hint);
|
|
} else {
|
|
render_prompt_info(
|
|
frame.buffer_mut(),
|
|
info_area,
|
|
agent,
|
|
queued,
|
|
transcript_hint,
|
|
&theme,
|
|
);
|
|
}
|
|
}
|
|
let result = agent
|
|
.prompt
|
|
.draw(frame.buffer_mut(), prompt_area, None, &style, None);
|
|
(
|
|
result.cursor_pos,
|
|
result
|
|
.post_flush_escapes
|
|
.map(kigi_tui::terminal::overlay::PostFlush::from),
|
|
)
|
|
});
|
|
}
|
|
fn live_tail_renderer<'a>(
|
|
entry: &'a kigi_tui::scrollback::entry::ScrollbackEntry,
|
|
theme: &'a Theme,
|
|
appearance: &kigi_tui::appearance::AppearanceConfig,
|
|
cwd: &'a std::path::Path,
|
|
tick: u64,
|
|
) -> EntryRenderer<'a> {
|
|
EntryRenderer::new(entry, theme)
|
|
.with_appearance(appearance.clone())
|
|
.with_cwd(Some(cwd))
|
|
.with_tick(tick)
|
|
.with_flat_background(true)
|
|
.with_hide_accent(true)
|
|
}
|
|
/// Render the uncommitted tail (entries past the commit frontier), bottom-anchored
|
|
/// so the most recent output is always visible; the topmost visible entry is
|
|
/// clipped via `with_skip_rows` when the run is taller than the tail area.
|
|
///
|
|
/// Starts at the shared [`super::commit::scan_frontier`] stop point so it renders
|
|
/// exactly the entries [`tail_height`] measured (the viewport was sized to that —
|
|
/// any disagreement makes the prompt jump on commit).
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn draw_tail(
|
|
buf: &mut Buffer,
|
|
area: Rect,
|
|
sb: &ScrollbackState,
|
|
turn_running: bool,
|
|
theme: &Theme,
|
|
appearance: &kigi_tui::appearance::AppearanceConfig,
|
|
cwd: &std::path::Path,
|
|
tick: u64,
|
|
) {
|
|
if area.height == 0 {
|
|
return;
|
|
}
|
|
let width = area.width;
|
|
let renderer = |e| live_tail_renderer(e, theme, appearance, cwd, tick);
|
|
let mut entries = Vec::new();
|
|
let mut i = super::commit::scan_frontier(sb, turn_running).tail_start;
|
|
while let Some(e) = sb.get(i) {
|
|
entries.push(e);
|
|
i += 1;
|
|
}
|
|
if entries.is_empty() {
|
|
return;
|
|
}
|
|
let gap = super::commit::MINIMAL_BLOCK_GAP;
|
|
let heights: Vec<u16> = entries
|
|
.iter()
|
|
.map(|e| renderer(*e).desired_height(width))
|
|
.collect();
|
|
let total: u16 = heights
|
|
.iter()
|
|
.fold(0u16, |acc, &h| acc.saturating_add(h).saturating_add(gap));
|
|
let mut skip_top = total.saturating_sub(area.height);
|
|
let mut y = area.y;
|
|
let bottom = area.y + area.height;
|
|
for (e, &content_h) in entries.iter().zip(&heights) {
|
|
let slot_h = content_h.saturating_add(gap);
|
|
if skip_top >= slot_h {
|
|
skip_top -= slot_h;
|
|
continue;
|
|
}
|
|
let slot_skip = skip_top;
|
|
skip_top = 0;
|
|
let entry_skip = slot_skip.min(content_h);
|
|
let visible_content = content_h.saturating_sub(entry_skip);
|
|
if visible_content > 0 {
|
|
let draw_h = visible_content.min(bottom.saturating_sub(y));
|
|
if draw_h == 0 {
|
|
break;
|
|
}
|
|
let rect = Rect {
|
|
x: area.x,
|
|
y,
|
|
width,
|
|
height: draw_h,
|
|
};
|
|
renderer(*e).with_skip_rows(entry_skip).render(rect, buf);
|
|
y += draw_h;
|
|
if y >= bottom {
|
|
break;
|
|
}
|
|
}
|
|
let gap_skipped = slot_skip.saturating_sub(entry_skip);
|
|
let gap_visible = gap
|
|
.saturating_sub(gap_skipped)
|
|
.min(bottom.saturating_sub(y));
|
|
y += gap_visible;
|
|
if y >= bottom {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
/// Count idle-surviving "watchers" — running monitors, active scheduled
|
|
/// `/loop` tasks, and running (background) subagents — so the shared turn-status
|
|
/// widget can show the persistent "watching · N monitors · M loops · K
|
|
/// subagents" cue while the agent is idle. Mirrors the full-TUI computation in
|
|
/// `AgentView::draw` (which minimal bypasses).
|
|
fn minimal_watchers(agent: &kigi_tui::app::agent_view::AgentView) -> turn_status::Watchers {
|
|
turn_status::Watchers {
|
|
monitors: agent
|
|
.session
|
|
.bg_tasks
|
|
.values()
|
|
.filter(|t| t.is_monitor && t.status == kigi_tui::app::agent::BgTaskStatus::Running)
|
|
.count(),
|
|
loops: agent.session.scheduled_tasks.len(),
|
|
subagents: agent
|
|
.subagent_sessions
|
|
.values()
|
|
.filter(|s| s.is_running())
|
|
.count(),
|
|
}
|
|
}
|
|
/// Resolve the current turn activity and advance the phase timer when it
|
|
/// changes. The full TUI runs this inside its own `draw` (reset
|
|
/// `activity_started_at` on every phase transition); minimal has a separate
|
|
/// draw path, so it must drive the same logic or the phase timer would never
|
|
/// reset. Returns the resolved activity for [`render_minimal_status`].
|
|
fn minimal_advance_phase_timer(
|
|
agent: &mut kigi_tui::app::agent_view::AgentView,
|
|
) -> Option<kigi_tui::acp::tracker::TurnActivity> {
|
|
let activity = minimal_api::resolve_turn_activity(agent);
|
|
if activity.as_ref() != minimal_api::last_activity(agent) {
|
|
agent.activity_started_at = Some(std::time::Instant::now());
|
|
minimal_api::set_last_activity(agent, activity.clone());
|
|
}
|
|
activity
|
|
}
|
|
/// Render the one-line minimal status indicator above the prompt.
|
|
///
|
|
/// Reuses the full-TUI [`turn_status::render_turn_status`] widget so minimal
|
|
/// surfaces the same rich activity detail (`Run …` / `Thinking…` /
|
|
/// `Waiting on subagent…` / `Retrying (attempt N)…` / `Cancelling…`), the
|
|
/// per-phase + turn timers, and the idle "watching · …" cue (running monitors /
|
|
/// loops / background subagents) — instead of collapsing everything to
|
|
/// "working…". Keyboard-only, so the mouse `[stop]` / `[↓]` buttons are
|
|
/// suppressed (`None`), and `flat_background` keeps the row transparent like the
|
|
/// rest of the live region. When the widget would draw nothing (plain idle, no
|
|
/// watchers) a small `minimal · /help` hint is shown instead.
|
|
fn render_minimal_status(
|
|
buf: &mut Buffer,
|
|
area: Rect,
|
|
agent: &kigi_tui::app::agent_view::AgentView,
|
|
activity: &Option<kigi_tui::acp::tracker::TurnActivity>,
|
|
transcript_progress: Option<(usize, usize)>,
|
|
theme: &Theme,
|
|
) {
|
|
if area.height == 0 || area.width == 0 {
|
|
return;
|
|
}
|
|
if let Some((done, total)) = transcript_progress {
|
|
let style = theme.primary().bg(Color::Reset);
|
|
buf.set_style(area, style);
|
|
buf.set_span(
|
|
area.x,
|
|
area.y,
|
|
&Span::styled(format!("rendering transcript… {done}/{total}"), style),
|
|
area.width,
|
|
);
|
|
return;
|
|
}
|
|
let watchers = minimal_watchers(agent);
|
|
let drain_blocked = minimal_api::drain_blocked(agent);
|
|
if minimal_api::renders_parked(agent)
|
|
|| !turn_status::should_show(
|
|
&agent.session.state,
|
|
drain_blocked,
|
|
minimal_api::mcp_init_progress(agent),
|
|
watchers,
|
|
)
|
|
{
|
|
render_idle_hint(buf, area, theme);
|
|
return;
|
|
}
|
|
let is_pending_user_input =
|
|
!agent.permission_queue.is_empty() || minimal_api::question_view(agent).is_some();
|
|
let goal_verifying = agent
|
|
.goal_state
|
|
.as_ref()
|
|
.is_some_and(|g| g.verifying_completion);
|
|
turn_status::render_turn_status(
|
|
buf,
|
|
area,
|
|
&agent.session.state,
|
|
activity,
|
|
agent.turn_elapsed(),
|
|
agent.activity_started_at,
|
|
agent.scrollback.animation_tick(),
|
|
drain_blocked,
|
|
None,
|
|
false,
|
|
agent.context_state.as_ref().map(|c| c.used),
|
|
minimal_api::mcp_init_progress(agent),
|
|
agent.bash_turn,
|
|
is_pending_user_input,
|
|
goal_verifying,
|
|
watchers,
|
|
true,
|
|
minimal_api::held_queue_count(agent),
|
|
minimal_api::held_queue_top_sendable(agent),
|
|
);
|
|
}
|
|
/// Idle status: `minimal · [/fullscreen to go back ·] /help` (+ auto-set note).
|
|
fn render_idle_hint(buf: &mut Buffer, area: Rect, theme: &Theme) {
|
|
let style = theme.dim().bg(Color::Reset);
|
|
buf.set_style(area, style);
|
|
let auto = kigi_tui::app::minimal_auto_set_for_mouse_leak();
|
|
let switch_back = kigi_tui::app::minimal_show_switch_back_to_fullscreen();
|
|
let hint = match (auto, switch_back) {
|
|
(true, true) => {
|
|
"minimal · auto-set on JetBrains/Windows due to JetBrains mouse reporting issues \
|
|
· /fullscreen to go back · /help"
|
|
}
|
|
(true, false) => {
|
|
"minimal · auto-set on JetBrains/Windows due to JetBrains mouse reporting issues · /help"
|
|
}
|
|
(false, true) => "minimal · /fullscreen to go back · /help",
|
|
(false, false) => "minimal · /help",
|
|
};
|
|
buf.set_span(area.x, area.y, &Span::styled(hint, style), area.width);
|
|
}
|
|
/// Render the one-line info bar directly below the prompt: the selected model,
|
|
/// the active session mode (the Shift+Tab cycle: plan / always-approve / auto),
|
|
/// context usage (absolute + percentage), an `N queued` count when prompts
|
|
/// are waiting behind a running turn, and the full-transcript shortcut hint
|
|
/// (`transcript_hint`: "ctrl+o transcript", or "/transcript" where Ctrl+O is
|
|
/// the interject chord — Apple Terminal). Mirrors the regular TUI's model
|
|
/// label, mode flags, and context bar; the transcript hint stands in for the
|
|
/// full TUI's shortcuts bar, which minimal never renders — without it the
|
|
/// folded conversation has no visible way back to the full view. The mode flag
|
|
/// keeps its accent color so the Shift+Tab cycle — otherwise invisible in
|
|
/// minimal mode — is always shown. Drawn only when no menu/dropdown owns the
|
|
/// band below the prompt (the caller gates on that). The elapsed-time / token
|
|
/// count lives in the turn-status row above the prompt (see
|
|
/// [`render_minimal_status`]), so it is not repeated here.
|
|
fn render_prompt_info(
|
|
buf: &mut Buffer,
|
|
area: Rect,
|
|
agent: &kigi_tui::app::agent_view::AgentView,
|
|
queued: usize,
|
|
transcript_hint: &str,
|
|
theme: &Theme,
|
|
) {
|
|
use kigi_tui::views::context_bar::fmt_tokens;
|
|
let base = theme.primary().bg(Color::Reset);
|
|
let sep = theme.dim().bg(Color::Reset);
|
|
let mut segs: Vec<(String, Style)> = Vec::new();
|
|
if let Some(model) = agent.session.models.current_model_name() {
|
|
let label = match agent.session.models.reasoning_effort {
|
|
Some(eff) => format!("{model} ({eff})"),
|
|
None => model,
|
|
};
|
|
segs.push((label, base));
|
|
}
|
|
let effective_plan =
|
|
minimal_api::plan_mode_pending(agent).unwrap_or(minimal_api::plan_mode_active(agent));
|
|
let mode_flag: Option<(&str, Color)> = if effective_plan {
|
|
Some(("plan", theme.accent_plan))
|
|
} else if agent.session.is_yolo() {
|
|
Some(("always-approve", theme.warning))
|
|
} else if agent.session.is_auto() {
|
|
Some(("auto", theme.accent_system))
|
|
} else {
|
|
None
|
|
};
|
|
if let Some((label, color)) = mode_flag {
|
|
segs.push((label.to_string(), base.fg(color)));
|
|
}
|
|
let used = agent.context_state.as_ref().map(|c| c.used);
|
|
let total = agent
|
|
.context_state
|
|
.as_ref()
|
|
.and_then(|c| (c.total > 0).then_some(c.total))
|
|
.or_else(|| agent.session.models.get_context_window());
|
|
if let (Some(used), Some(total)) = (used, total)
|
|
&& total > 0
|
|
{
|
|
let pct = kigi_token_estimation::usage_percentage(used, total);
|
|
segs.push((
|
|
format!("{} / {} ({:.0}%)", fmt_tokens(used), fmt_tokens(total), pct),
|
|
base,
|
|
));
|
|
}
|
|
if queued > 0 {
|
|
segs.push((format!("{queued} queued"), base));
|
|
segs.push(("/queue".to_string(), base));
|
|
}
|
|
segs.push((transcript_hint.to_string(), base));
|
|
if segs.is_empty() {
|
|
return;
|
|
}
|
|
buf.set_style(area, base);
|
|
let mut spans: Vec<Span<'static>> = Vec::new();
|
|
for (i, (text, style)) in segs.into_iter().enumerate() {
|
|
if i > 0 {
|
|
spans.push(Span::styled(" · ", sep));
|
|
}
|
|
spans.push(Span::styled(text, style));
|
|
}
|
|
buf.set_line(area.x, area.y, &Line::from(spans), area.width);
|
|
}
|
|
/// The double-press confirmation hint to show under the prompt (e.g. "press
|
|
/// Ctrl+q again to quit"), or `None` when nothing is armed / it has expired or
|
|
/// is a silent arm (no label). Mirrors the full-TUI shortcuts-bar `PendingHint`,
|
|
/// which minimal does not render.
|
|
fn minimal_pending_hint(
|
|
pending: &Option<kigi_tui::app::app_view::PendingAction>,
|
|
) -> Option<String> {
|
|
let pending = pending.as_ref()?;
|
|
if pending.expired() {
|
|
return None;
|
|
}
|
|
let label = pending.label?;
|
|
Some(format!(
|
|
"press {} again to {label}",
|
|
pending.shortcut.display()
|
|
))
|
|
}
|
|
/// Render the one-line double-press confirmation hint under the prompt, in the
|
|
/// warning color so it stands out from the model/context info row.
|
|
fn render_exit_hint(buf: &mut Buffer, area: Rect, theme: &Theme, hint: &str) {
|
|
let style = Style::default().fg(theme.warning).bg(Color::Reset);
|
|
buf.set_style(area, style);
|
|
buf.set_span(
|
|
area.x,
|
|
area.y,
|
|
&Span::styled(hint.to_string(), style),
|
|
area.width,
|
|
);
|
|
}
|
|
/// Height (rows) of the tail that will REMAIN after this frame's commit pass —
|
|
/// i.e. the entries `commit_active` will NOT consume, from the first
|
|
/// non-committable entry (past the scan cursor) onward.
|
|
///
|
|
/// The overlay host sizes the live viewport to this *post-commit* tail so the
|
|
/// prompt sits right after the streaming output (no fixed gap while a turn is
|
|
/// "thinking" with nothing streamed yet). Sizing to the post-commit tail
|
|
/// (rather than the current tail) is load-bearing: because `sync_viewport` runs
|
|
/// just *before* `commit_active`, the viewport is already at its post-commit
|
|
/// height when the commit's `insert_before` prints finalized blocks — so it can
|
|
/// reposition the correctly-sized viewport to sit directly after them
|
|
/// (content-anchored). Sizing to the tall streaming tail instead left the
|
|
/// viewport oversized at commit time, and the following collapse stranded the
|
|
/// prompt at the top of the screen (the "snaps to top" bug).
|
|
pub(super) fn tail_height(
|
|
agent: &kigi_tui::app::agent_view::AgentView,
|
|
width: u16,
|
|
appearance: &kigi_tui::appearance::AppearanceConfig,
|
|
) -> u16 {
|
|
let theme = Theme::current();
|
|
let sb = &agent.scrollback;
|
|
let turn_running = agent.session.state.is_turn_running();
|
|
let gap = super::commit::MINIMAL_BLOCK_GAP;
|
|
let mut i = super::commit::scan_frontier(sb, turn_running).tail_start;
|
|
let mut total = 0u16;
|
|
while let Some(e) = sb.get(i) {
|
|
let h =
|
|
live_tail_renderer(e, &theme, appearance, &agent.session.cwd, 0).desired_height(width);
|
|
total = total.saturating_add(h).saturating_add(gap);
|
|
i += 1;
|
|
}
|
|
total
|
|
}
|
|
fn now_millis() -> u128 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis())
|
|
.unwrap_or(0)
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
fn agent() -> kigi_tui::app::agent_view::AgentView {
|
|
minimal_api::test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"))
|
|
}
|
|
#[test]
|
|
fn tail_height_uses_owning_session_cwd_for_tool_paths() {
|
|
use kigi_tui::app::agent::AgentState;
|
|
use kigi_tui::scrollback::RenderBlock;
|
|
use kigi_tui::scrollback::entry::ScrollbackEntry;
|
|
use kigi_tui::scrollback::types::DisplayMode;
|
|
let cwd = std::path::PathBuf::from("/alternate/worktree");
|
|
let mut agent = minimal_api::test_agent_view(Some("s1"), cwd.clone());
|
|
agent.session.state = AgentState::TurnRunning;
|
|
let mut entry = ScrollbackEntry::running(RenderBlock::edit(
|
|
"/alternate/worktree/src/components/really_long_file_name.rs",
|
|
None,
|
|
));
|
|
entry.set_display_mode(DisplayMode::Expanded);
|
|
agent.scrollback.push(entry);
|
|
let appearance = super::super::commit::committed_appearance(
|
|
&kigi_tui::appearance::AppearanceConfig::default(),
|
|
);
|
|
let theme = Theme::current();
|
|
let entry = agent.scrollback.get(0).unwrap();
|
|
let (width, painted_height, visible_accent_height) = (10..=40)
|
|
.find_map(|width| {
|
|
let painted =
|
|
live_tail_renderer(entry, &theme, &appearance, &cwd, 0).desired_height(width);
|
|
let visible_accent = EntryRenderer::new(entry, &theme)
|
|
.with_appearance(appearance.clone())
|
|
.with_cwd(Some(&cwd))
|
|
.with_tick(0)
|
|
.with_flat_background(true)
|
|
.desired_height(width);
|
|
(painted != visible_accent).then_some((width, painted, visible_accent))
|
|
})
|
|
.expect("fixture must wrap differently when the accent column is reclaimed");
|
|
assert_ne!(painted_height, visible_accent_height);
|
|
assert_eq!(
|
|
tail_height(&agent, width, &appearance),
|
|
painted_height.saturating_add(super::super::commit::MINIMAL_BLOCK_GAP)
|
|
);
|
|
}
|
|
#[test]
|
|
fn minimal_status_shows_rich_activity_and_idle_hint() {
|
|
use kigi_tui::acp::tracker::TurnActivity;
|
|
use kigi_tui::app::agent::AgentState;
|
|
let theme = Theme::current();
|
|
let area = Rect::new(0, 0, 60, 1);
|
|
let read = |buf: &Buffer| -> String {
|
|
(0..area.width)
|
|
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
|
|
.collect()
|
|
};
|
|
kigi_tui::app::set_minimal_show_switch_back_to_fullscreen_for_test(false);
|
|
let a = agent();
|
|
let mut buf = Buffer::empty(area);
|
|
render_minimal_status(&mut buf, area, &a, &None, None, &theme);
|
|
let idle = read(&buf);
|
|
assert!(idle.contains("/help"), "idle hint: {idle:?}");
|
|
assert!(
|
|
!idle.contains("/fullscreen"),
|
|
"cold start must not show switch-back: {idle:?}"
|
|
);
|
|
kigi_tui::app::set_minimal_show_switch_back_to_fullscreen_for_test(true);
|
|
let mut buf = Buffer::empty(area);
|
|
render_minimal_status(&mut buf, area, &a, &None, None, &theme);
|
|
let switched = read(&buf);
|
|
assert!(
|
|
switched.contains("/fullscreen to go back"),
|
|
"relaunch into minimal must show switch-back: {switched:?}"
|
|
);
|
|
kigi_tui::app::set_minimal_show_switch_back_to_fullscreen_for_test(false);
|
|
let mut a = agent();
|
|
a.session.state = AgentState::TurnRunning;
|
|
let mut buf = Buffer::empty(area);
|
|
render_minimal_status(
|
|
&mut buf,
|
|
area,
|
|
&a,
|
|
&Some(TurnActivity::Responding),
|
|
None,
|
|
&theme,
|
|
);
|
|
let text = read(&buf);
|
|
assert!(text.contains("Responding"), "rich activity: {text:?}");
|
|
let mut buf = Buffer::empty(area);
|
|
render_minimal_status(
|
|
&mut buf,
|
|
area,
|
|
&a,
|
|
&Some(TurnActivity::Retrying {
|
|
attempt: 2,
|
|
max_retries: 3,
|
|
reason: "transient error".to_string(),
|
|
}),
|
|
None,
|
|
&theme,
|
|
);
|
|
assert!(read(&buf).contains("Retrying"), "retry: {:?}", read(&buf));
|
|
}
|
|
#[test]
|
|
fn minimal_status_shows_idle_watching_cue() {
|
|
use kigi_tui::app::agent::AgentState;
|
|
let theme = Theme::current();
|
|
let area = Rect::new(0, 0, 60, 1);
|
|
let read = |buf: &Buffer| -> String {
|
|
(0..area.width)
|
|
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
|
|
.collect()
|
|
};
|
|
let mut a = agent();
|
|
a.session.state = AgentState::Idle;
|
|
a.session.scheduled_tasks.insert(
|
|
"loop-1".to_string(),
|
|
kigi_tui::app::agent::ScheduledTaskInfo {
|
|
task_id: "loop-1".to_string(),
|
|
prompt: "do the thing".to_string(),
|
|
human_schedule: "every 5m".to_string(),
|
|
created_at: std::time::Instant::now(),
|
|
next_fire_at: None,
|
|
tag: "loop".to_string(),
|
|
},
|
|
);
|
|
assert_eq!(minimal_watchers(&a).loops, 1);
|
|
let mut buf = Buffer::empty(area);
|
|
render_minimal_status(&mut buf, area, &a, &None, None, &theme);
|
|
let text = read(&buf);
|
|
assert!(text.contains("watching"), "watching cue: {text:?}");
|
|
assert!(!text.contains("/help"), "not the idle hint: {text:?}");
|
|
}
|
|
#[test]
|
|
fn prompt_info_renders_model_context_and_queued() {
|
|
let mut a = agent();
|
|
a.context_state = Some(kigi_shell::session::ContextInfo {
|
|
used: 276_000,
|
|
total: 2_000_000,
|
|
..Default::default()
|
|
});
|
|
let theme = Theme::current();
|
|
let area = Rect::new(0, 0, 80, 1);
|
|
let mut buf = Buffer::empty(area);
|
|
render_prompt_info(&mut buf, area, &a, 3, "ctrl+o transcript", &theme);
|
|
let text: String = (0..area.width)
|
|
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
|
|
.collect();
|
|
assert!(text.contains("276K"), "absolute used tokens: {text:?}");
|
|
assert!(text.contains("2.0M"), "total context window: {text:?}");
|
|
assert!(text.contains('%'), "percentage: {text:?}");
|
|
assert!(text.contains("3 queued"), "queued count: {text:?}");
|
|
assert!(
|
|
text.trim_end().ends_with("ctrl+o transcript"),
|
|
"trailing transcript hint: {text:?}"
|
|
);
|
|
}
|
|
/// Where Ctrl+O is the interject chord (Apple Terminal) the caller passes
|
|
/// the `/transcript` fallback, and the info row advertises that instead.
|
|
#[test]
|
|
fn prompt_info_shows_slash_transcript_fallback_hint() {
|
|
let a = agent();
|
|
let theme = Theme::current();
|
|
let area = Rect::new(0, 0, 80, 1);
|
|
let mut buf = Buffer::empty(area);
|
|
render_prompt_info(&mut buf, area, &a, 0, "/transcript", &theme);
|
|
let text: String = (0..area.width)
|
|
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
|
|
.collect();
|
|
assert!(text.contains("/transcript"), "fallback hint: {text:?}");
|
|
assert!(!text.contains("ctrl+o"), "no dead chord: {text:?}");
|
|
}
|
|
#[test]
|
|
fn prompt_info_shows_session_mode_flag() {
|
|
let theme = Theme::current();
|
|
let area = Rect::new(0, 0, 80, 1);
|
|
let read = |buf: &Buffer| -> String {
|
|
(0..area.width)
|
|
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
|
|
.collect()
|
|
};
|
|
let render = |a: &kigi_tui::app::agent_view::AgentView| -> String {
|
|
let mut buf = Buffer::empty(area);
|
|
render_prompt_info(&mut buf, area, a, 0, "ctrl+o transcript", &theme);
|
|
read(&buf)
|
|
};
|
|
let mut a = agent();
|
|
let text = render(&a);
|
|
assert!(!text.contains("plan"), "normal shows no flag: {text:?}");
|
|
assert!(!text.contains("always-approve"), "normal: {text:?}");
|
|
minimal_api::set_plan_mode_pending(&mut a, Some(true));
|
|
assert!(render(&a).contains("plan"), "plan flag: {:?}", render(&a));
|
|
minimal_api::set_plan_mode_pending(&mut a, None);
|
|
minimal_api::set_plan_mode_active(&mut a, false);
|
|
minimal_api::set_yolo_mode_for_test(&mut a.session, true);
|
|
minimal_api::set_auto_mode_for_test(&mut a.session, true);
|
|
let text = render(&a);
|
|
assert!(text.contains("always-approve"), "yolo flag: {text:?}");
|
|
minimal_api::set_yolo_mode_for_test(&mut a.session, false);
|
|
let text = render(&a);
|
|
assert!(text.contains("auto"), "auto flag: {text:?}");
|
|
}
|
|
#[test]
|
|
fn pending_hint_formats_press_again() {
|
|
use crossterm::event::{KeyCode, KeyModifiers};
|
|
use kigi_tui::app::actions::Action;
|
|
use kigi_tui::app::app_view::PendingAction;
|
|
use kigi_tui::input::key::KeyShortcut;
|
|
assert!(minimal_pending_hint(&None).is_none());
|
|
let shortcut = KeyShortcut::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
|
|
let pending = Some(PendingAction::new(Action::Quit, shortcut, "quit"));
|
|
assert_eq!(
|
|
minimal_pending_hint(&pending).as_deref(),
|
|
Some("press Ctrl+q again to quit")
|
|
);
|
|
let silent = Some(PendingAction::with_ttl(
|
|
Action::Quit,
|
|
shortcut,
|
|
None,
|
|
std::time::Duration::from_secs(1),
|
|
));
|
|
assert!(minimal_pending_hint(&silent).is_none());
|
|
let expired = Some(PendingAction::with_ttl(
|
|
Action::Quit,
|
|
shortcut,
|
|
Some("quit"),
|
|
std::time::Duration::ZERO,
|
|
));
|
|
assert!(minimal_pending_hint(&expired).is_none());
|
|
}
|
|
}
|