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,638 @@
|
||||
//! Read/render surface consumed by the `kigi-pager-minimal` crate.
|
||||
//!
|
||||
//! **If you don't work on the minimal (scrollback-native) render mode, you can
|
||||
//! ignore this file.** It is the *single* seam through which `minimal` reaches
|
||||
//! into this crate's view model. Its whole reason to exist is to keep every
|
||||
//! other file's internals `pub(crate)`: minimal lives in a sibling crate, so
|
||||
//! anything it touches would otherwise have to be widened to `pub` and scattered
|
||||
//! across the core structs (`AgentView`, the `views::*` widgets, …). Instead we
|
||||
//! keep those `pub(crate)` and expose exactly what minimal needs as thin `pub`
|
||||
//! accessors/wrappers *here*.
|
||||
//!
|
||||
//! Note: this is the *minimal → pager* direction (minimal reading the pager).
|
||||
//! The reverse direction (pager dispatching into minimal's renderer) is the
|
||||
//! fn-pointer seam in [`crate::minimal_hook`], installed by the composition-root
|
||||
//! binary.
|
||||
//!
|
||||
//! Conventions:
|
||||
//! - Getters take `&AgentView` / `&PromptWidget` and return `Option<&T>` or a
|
||||
//! `Copy` value. Mutating access is a `*_mut` accessor or an explicit setter,
|
||||
//! added only where minimal actually mutates.
|
||||
//! - `pub use` cannot re-export a `pub(crate)` item at wider visibility (E0365),
|
||||
//! so free helpers are re-exposed as thin `pub fn` wrappers, not re-exports.
|
||||
//! - Purely-internal DTOs (`DropdownChrome`, `McpServersPickerRows`) are never
|
||||
//! named across the crate boundary — the wrappers return their extracted data
|
||||
//! (a `Rect`, a tuple of `Vec`s) so those types stay `pub(crate)`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
|
||||
use crate::acp::tracker::TurnActivity;
|
||||
// Only the test-only setters below reference `AgentSession`.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
use crate::app::agent::AgentSession;
|
||||
use crate::app::agent_view::{AgentView, McpInitProgress};
|
||||
use crate::app::app_view::{ActiveView, AppView, SessionPickerEntry};
|
||||
use crate::appearance::LayoutConfig;
|
||||
use crate::scrollback::entry::{EntryId, ScrollbackEntry};
|
||||
use crate::scrollback::state::ScrollbackState;
|
||||
use crate::theme::Theme;
|
||||
use crate::views::extensions_modal::{ExtensionsModalState, StatusFilter};
|
||||
use crate::views::mcps_modal::{McpServerDisplayStatus, McpServerInfo};
|
||||
use crate::views::modal::CancelTurnViewState;
|
||||
use crate::views::picker::{PickerEntry, PickerField, PickerState};
|
||||
use crate::views::plan_approval_view::PlanApprovalViewState;
|
||||
use crate::views::prompt_widget::PromptWidget;
|
||||
use crate::views::question_view::QuestionViewState;
|
||||
use crate::views::rewind::RewindState;
|
||||
use crate::views::session_picker::{SessionEntryData, SourceFilter};
|
||||
use crate::views::suggestion_controller::SuggestionController;
|
||||
|
||||
// ── Consolidated minimal-mode state (AppView::minimal_state) ─────────────────
|
||||
//
|
||||
// Minimal's private per-session state, consolidated into a single field on the
|
||||
// central `AppView` instead of several loose `pub` fields. Default-empty and
|
||||
// inert outside `--minimal`.
|
||||
|
||||
/// In-progress incremental `/transcript` build (minimal mode).
|
||||
///
|
||||
/// The full-fidelity ANSI transcript is a layout + syntax-highlight pass over
|
||||
/// the whole session; building it in one shot froze the event loop, and the
|
||||
/// block model is `!Send` (syntect's resumable highlighter state lives inside
|
||||
/// markdown blocks) so it cannot move to a worker. Instead the minimal draw
|
||||
/// loop renders a **time-budgeted slice per frame**
|
||||
/// (`kigi-pager-minimal::full_view::pump_transcript`) — the same
|
||||
/// amortization the reference scrollback TUIs use for transcript-scale work —
|
||||
/// and arms `pending_pager_path` when done.
|
||||
pub struct TranscriptBuild {
|
||||
/// The agent whose conversation this build snapshots. The pump resolves
|
||||
/// entries against THIS agent — never the active view: `EntryId`s are
|
||||
/// per-`ScrollbackState` counters (every state starts at 1), so resolving
|
||||
/// the snapshot against whichever agent happens to be active after a
|
||||
/// session switch would silently stitch the transcript from another
|
||||
/// session's blocks. Keying by owner also keeps the build alive (and the
|
||||
/// pager opening) when the user tabs away mid-build.
|
||||
pub agent: crate::app::agent::AgentId,
|
||||
/// Snapshot of the entry IDs to render, in conversation order. IDs are
|
||||
/// re-resolved per slice, so entries removed mid-build (rewind / clear)
|
||||
/// are skipped instead of skewing positions.
|
||||
pub ids: Vec<EntryId>,
|
||||
/// Next index into `ids` to render.
|
||||
pub next: usize,
|
||||
/// Accumulated ANSI output.
|
||||
pub out: String,
|
||||
}
|
||||
|
||||
/// Minimal-mode-only state held on [`AppView::minimal_state`].
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MinimalState {
|
||||
/// Pin the todo panel visible (minimal reuses Ctrl+T for this).
|
||||
pub(crate) show_todos: bool,
|
||||
/// A welcome card is queued to commit into native scrollback next draw.
|
||||
pub(crate) welcome_pending: bool,
|
||||
/// Entry IDs queued by Ctrl+E / `/expand` to re-print fully expanded (K10).
|
||||
pub(crate) pending_expand: Vec<EntryId>,
|
||||
/// In-progress `/transcript` build, pumped one slice per frame.
|
||||
pub(crate) transcript: Option<TranscriptBuild>,
|
||||
/// `tool_call_id` of the plan already emitted into native scrollback. Minimal
|
||||
/// prints the whole plan as a normal committed conversation block (rather than
|
||||
/// rendering it under the prompt), so this de-dupes the per-frame push — and,
|
||||
/// because each revision is a fresh ExitPlanMode with a new id, still commits
|
||||
/// every revised plan as its own block.
|
||||
pub(crate) committed_plan_tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
/// `AppView::minimal_state.show_todos`.
|
||||
pub fn minimal_show_todos(app: &AppView) -> bool {
|
||||
app.minimal_state.show_todos
|
||||
}
|
||||
|
||||
/// `AppView::minimal_state.welcome_pending`.
|
||||
pub fn minimal_welcome_pending(app: &AppView) -> bool {
|
||||
app.minimal_state.welcome_pending
|
||||
}
|
||||
|
||||
/// `AppView::minimal_state.welcome_pending` (write).
|
||||
pub fn set_minimal_welcome_pending(app: &mut AppView, on: bool) {
|
||||
app.minimal_state.welcome_pending = on;
|
||||
}
|
||||
|
||||
/// `AppView::minimal_state.pending_expand` (read).
|
||||
pub fn minimal_pending_expand(app: &AppView) -> &[EntryId] {
|
||||
&app.minimal_state.pending_expand
|
||||
}
|
||||
|
||||
/// Drain `AppView::minimal_state.pending_expand` (Ctrl+E / `/expand` queue).
|
||||
pub fn take_minimal_pending_expand(app: &mut AppView) -> Vec<EntryId> {
|
||||
std::mem::take(&mut app.minimal_state.pending_expand)
|
||||
}
|
||||
|
||||
/// Put drained expand IDs back at the FRONT of the queue (a terminal write
|
||||
/// failed mid-drain): they retry next frame, ahead of any newly queued Ctrl+E.
|
||||
pub fn requeue_minimal_pending_expand(app: &mut AppView, mut ids: Vec<EntryId>) {
|
||||
ids.extend(std::mem::take(&mut app.minimal_state.pending_expand));
|
||||
app.minimal_state.pending_expand = ids;
|
||||
}
|
||||
|
||||
// ── Incremental /transcript build ────────────────────────────────────────────
|
||||
|
||||
/// Arm the incremental minimal `/transcript` build from the active agent's
|
||||
/// conversation. No-op when a build is already running (the in-flight one
|
||||
/// wins) — and pushes the "nothing to show" system block when the conversation
|
||||
/// is empty. The minimal draw loop pumps the build a slice per frame and arms
|
||||
/// `pending_pager_path` on completion.
|
||||
pub fn request_minimal_transcript(app: &mut AppView) {
|
||||
if app.minimal_state.transcript.is_some() {
|
||||
return;
|
||||
}
|
||||
let ActiveView::Agent(id) = &app.active_view else {
|
||||
return;
|
||||
};
|
||||
let id = *id;
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return;
|
||||
};
|
||||
let sb = &agent.scrollback;
|
||||
let ids: Vec<EntryId> = (0..sb.len())
|
||||
.filter_map(|i| sb.entry(i).map(|e| e.id))
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(crate::scrollback::block::RenderBlock::system(
|
||||
"No conversation transcript to view yet",
|
||||
));
|
||||
return;
|
||||
}
|
||||
app.minimal_state.transcript = Some(TranscriptBuild {
|
||||
agent: id,
|
||||
ids,
|
||||
next: 0,
|
||||
out: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Take the in-progress transcript build out of the state for one pump slice
|
||||
/// (the pump needs `&AgentView` and the build simultaneously; taking avoids a
|
||||
/// double `&mut AppView` borrow). Put it back via [`set_minimal_transcript`]
|
||||
/// unless the slice finished it.
|
||||
pub fn take_minimal_transcript(app: &mut AppView) -> Option<TranscriptBuild> {
|
||||
app.minimal_state.transcript.take()
|
||||
}
|
||||
|
||||
/// Store the (still unfinished) transcript build back after a pump slice.
|
||||
pub fn set_minimal_transcript(app: &mut AppView, build: Option<TranscriptBuild>) {
|
||||
app.minimal_state.transcript = build;
|
||||
}
|
||||
|
||||
/// Progress of the in-flight transcript build (`rendered`, `total`), for the
|
||||
/// status row. `None` when no build is running.
|
||||
pub fn minimal_transcript_progress(app: &AppView) -> Option<(usize, usize)> {
|
||||
app.minimal_state
|
||||
.transcript
|
||||
.as_ref()
|
||||
.map(|b| (b.next, b.ids.len()))
|
||||
}
|
||||
|
||||
/// `AppView::minimal_state.committed_plan_tool_call_id` (read).
|
||||
pub fn minimal_committed_plan_id(app: &AppView) -> Option<&str> {
|
||||
app.minimal_state.committed_plan_tool_call_id.as_deref()
|
||||
}
|
||||
|
||||
/// Whether minimal's Ctrl+O remap opens the full-transcript pager *right now*.
|
||||
///
|
||||
/// Minimal remaps Ctrl+O to `Action::OpenTranscriptPager` — **except** when
|
||||
/// Ctrl+O is bound to interject (Apple Terminal: the kitty keyboard protocol is
|
||||
/// unavailable, so Ctrl+Enter doesn't arrive and Ctrl+I aliases to Tab —
|
||||
/// Ctrl+O is the only interject chord left) AND an interject would actually
|
||||
/// consume the press:
|
||||
///
|
||||
/// - editing a queued row (the interject key saves / interjects the edit), or
|
||||
/// - a turn is running with a non-empty composer, or
|
||||
/// - a turn is running with an empty composer **and** a visible queued
|
||||
/// follow-up (prompt-path force-send of the top queue row — same as full TUI)
|
||||
///
|
||||
/// Outside those states the interject path is a documented silent no-op
|
||||
/// (idle / empty composer with no queue → `InputOutcome::Changed`), which made
|
||||
/// Ctrl+O appear dead on Apple Terminal — so the remap takes the key and opens
|
||||
/// the transcript instead. `AppView::minimal_key_intercept` gates on this same
|
||||
/// predicate, and minimal's info-row hint re-evaluates it every frame, so the
|
||||
/// advertised key ("ctrl+o transcript" vs the `/transcript` fallback) always
|
||||
/// matches what the press would do.
|
||||
pub fn minimal_ctrl_o_opens_transcript(app: &AppView) -> bool {
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
let ctrl_o = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL);
|
||||
if !app
|
||||
.registry
|
||||
.matches_id(crate::actions::ActionId::InterjectPrompt, &ctrl_o)
|
||||
{
|
||||
// Ctrl+O is not the interject chord (everything but Apple Terminal):
|
||||
// the remap always owns the key.
|
||||
return true;
|
||||
}
|
||||
let ActiveView::Agent(id) = &app.active_view else {
|
||||
return true;
|
||||
};
|
||||
let Some(agent) = app.agents.get(id) else {
|
||||
return true;
|
||||
};
|
||||
// Editing a queued row: the interject key saves (idle) or interjects
|
||||
// (running) the edited text — never steal it mid-edit.
|
||||
if matches!(
|
||||
agent.prompt_mode,
|
||||
crate::app::agent_view::PromptMode::EditingQueued { .. }
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Matches prompt-path send-now: non-empty composer text *or* a visible
|
||||
// queued follow-up (empty-composer force-send of the top row). Exclude the
|
||||
// in-flight shared-queue entry when it is the running turn (same rule as
|
||||
// `AgentView::visible_queue_is_empty`).
|
||||
let running = agent.session.current_prompt_id.as_deref();
|
||||
let has_queued_follow_up = !agent.session.pending_prompts.is_empty()
|
||||
|| agent
|
||||
.shared_queue
|
||||
.iter()
|
||||
.any(|e| Some(e.id.as_str()) != running);
|
||||
let has_payload = !agent.prompt.text().trim().is_empty() || has_queued_follow_up;
|
||||
!crate::actions::ActionRegistry::interjection_possible(
|
||||
agent.session.state.is_turn_running(),
|
||||
has_payload,
|
||||
)
|
||||
}
|
||||
|
||||
/// `AppView::minimal_state.committed_plan_tool_call_id` (write).
|
||||
pub fn set_minimal_committed_plan_id(app: &mut AppView, id: Option<String>) {
|
||||
app.minimal_state.committed_plan_tool_call_id = id;
|
||||
}
|
||||
|
||||
// ── AgentView field accessors ────────────────────────────────────────────────
|
||||
|
||||
/// `AgentView::last_activity` (read).
|
||||
pub fn last_activity(v: &AgentView) -> Option<&TurnActivity> {
|
||||
v.last_activity.as_ref()
|
||||
}
|
||||
|
||||
/// `AgentView::last_activity` (write).
|
||||
pub fn set_last_activity(v: &mut AgentView, val: Option<TurnActivity>) {
|
||||
v.last_activity = val;
|
||||
}
|
||||
|
||||
/// `AgentView::extensions_modal`.
|
||||
pub fn extensions_modal(v: &AgentView) -> Option<&ExtensionsModalState> {
|
||||
v.extensions_modal.as_ref()
|
||||
}
|
||||
|
||||
/// `AgentView::extensions_modal` (mutable — minimal reuses the full-TUI modal
|
||||
/// renderer, which takes `&mut ExtensionsModalState`, and updates render-stored
|
||||
/// picker row state).
|
||||
pub fn extensions_modal_mut(v: &mut AgentView) -> Option<&mut ExtensionsModalState> {
|
||||
v.extensions_modal.as_mut()
|
||||
}
|
||||
|
||||
/// `AgentView::question_view`.
|
||||
pub fn question_view(v: &AgentView) -> Option<&QuestionViewState> {
|
||||
v.question_view.as_ref()
|
||||
}
|
||||
|
||||
/// `AgentView::question_view` (mutable — minimal clamps the scroll offset).
|
||||
pub fn question_view_mut(v: &mut AgentView) -> Option<&mut QuestionViewState> {
|
||||
v.question_view.as_mut()
|
||||
}
|
||||
|
||||
/// `AgentView::hovered_question_item`.
|
||||
pub fn hovered_question_item(v: &AgentView) -> Option<usize> {
|
||||
v.hovered_question_item
|
||||
}
|
||||
|
||||
/// `AgentView::hovered_permission_item`.
|
||||
pub fn hovered_permission_item(v: &AgentView) -> Option<usize> {
|
||||
v.hovered_permission_item
|
||||
}
|
||||
|
||||
/// `AgentView::plan_mode_active`.
|
||||
pub fn plan_mode_active(v: &AgentView) -> bool {
|
||||
v.plan_mode_active
|
||||
}
|
||||
|
||||
/// `AgentView::plan_mode_pending`.
|
||||
pub fn plan_mode_pending(v: &AgentView) -> Option<bool> {
|
||||
v.plan_mode_pending
|
||||
}
|
||||
|
||||
/// `AgentView::mcp_init_progress`.
|
||||
pub fn mcp_init_progress(v: &AgentView) -> Option<&McpInitProgress> {
|
||||
v.mcp_init_progress.as_ref()
|
||||
}
|
||||
|
||||
/// `AgentView::plan_approval_view`.
|
||||
pub fn plan_approval_view(v: &AgentView) -> Option<&PlanApprovalViewState> {
|
||||
v.plan_approval_view.as_ref()
|
||||
}
|
||||
|
||||
/// `AgentView::cancel_turn_view`.
|
||||
pub fn cancel_turn_view(v: &AgentView) -> Option<&CancelTurnViewState> {
|
||||
v.cancel_turn_view.as_ref()
|
||||
}
|
||||
|
||||
/// `AgentView::cancel_turn_buttons` (mutable — the renderer fills the hit-test
|
||||
/// rects).
|
||||
pub fn cancel_turn_buttons_mut(v: &mut AgentView) -> &mut Vec<Rect> {
|
||||
&mut v.cancel_turn_buttons
|
||||
}
|
||||
|
||||
/// `AgentView::rewind_state`.
|
||||
pub fn rewind_state(v: &AgentView) -> Option<&RewindState> {
|
||||
v.rewind_state.as_ref()
|
||||
}
|
||||
|
||||
// ── AgentView method wrappers ────────────────────────────────────────────────
|
||||
|
||||
/// [`AgentView::resolve_turn_activity`].
|
||||
pub fn resolve_turn_activity(v: &AgentView) -> Option<TurnActivity> {
|
||||
v.resolve_turn_activity()
|
||||
}
|
||||
|
||||
/// [`AgentView::renders_parked`] — minimal renders the idle hint (not the
|
||||
/// turn-status row) while the parked-wait marker's turn is parked, mirroring
|
||||
/// the full TUI. The marker itself is pushed by the shared ACP notification
|
||||
/// path, so minimal's scrollback carries it too.
|
||||
pub fn renders_parked(v: &AgentView) -> bool {
|
||||
v.renders_parked()
|
||||
}
|
||||
|
||||
/// [`AgentView::held_queue_count`].
|
||||
pub fn held_queue_count(v: &AgentView) -> usize {
|
||||
v.held_queue_count()
|
||||
}
|
||||
|
||||
/// [`AgentView::held_queue_top_sendable`].
|
||||
pub fn held_queue_top_sendable(v: &AgentView) -> bool {
|
||||
v.held_queue_top_sendable()
|
||||
}
|
||||
|
||||
/// [`AgentView::sync_pending_user_input_marks`].
|
||||
pub fn sync_pending_user_input_marks(v: &mut AgentView) {
|
||||
v.sync_pending_user_input_marks();
|
||||
}
|
||||
|
||||
/// [`AgentView::draw_active_modal`] — minimal reuses the full-TUI modal renderer.
|
||||
pub fn draw_active_modal(
|
||||
v: &mut AgentView,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
theme: Theme,
|
||||
compact: bool,
|
||||
) {
|
||||
v.draw_active_modal(area, buf, theme, compact);
|
||||
}
|
||||
|
||||
/// [`AgentView::drain_blocked`].
|
||||
pub fn drain_blocked(v: &AgentView) -> bool {
|
||||
v.drain_blocked()
|
||||
}
|
||||
|
||||
// ── PromptWidget accessors ───────────────────────────────────────────────────
|
||||
|
||||
/// `PromptWidget::suggestions`.
|
||||
pub fn prompt_suggestions(pw: &PromptWidget) -> &SuggestionController {
|
||||
&pw.suggestions
|
||||
}
|
||||
|
||||
// ── Dropdown chrome ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Lay out the inline dropdown chrome and return the item area rect
|
||||
/// (`DropdownChrome::items`), or `None` when it doesn't fit. Wraps
|
||||
/// [`crate::app::agent_view::render_dropdown_chrome`]; the `DropdownChrome` DTO
|
||||
/// itself stays crate-internal.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn dropdown_chrome_items(
|
||||
buf: &mut Buffer,
|
||||
item_count: usize,
|
||||
item_rows: u16,
|
||||
inline_prompt_area: Option<Rect>,
|
||||
layout_prompt: Rect,
|
||||
area: Rect,
|
||||
layout_cfg: &LayoutConfig,
|
||||
compact: bool,
|
||||
below: bool,
|
||||
theme: &Theme,
|
||||
) -> Option<Rect> {
|
||||
crate::app::agent_view::render_dropdown_chrome(
|
||||
buf,
|
||||
item_count,
|
||||
item_rows,
|
||||
inline_prompt_area,
|
||||
layout_prompt,
|
||||
area,
|
||||
layout_cfg,
|
||||
compact,
|
||||
below,
|
||||
theme,
|
||||
)
|
||||
.map(|chrome| chrome.items)
|
||||
}
|
||||
|
||||
// ── MCP picker rows ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Build the MCP-servers picker rows, returning `(labels, group_keys,
|
||||
/// data_indices)`. Wraps [`crate::views::extensions_modal::build_mcp_servers_picker_rows`];
|
||||
/// the `McpServersPickerRows` DTO stays crate-internal.
|
||||
pub fn build_mcp_picker_rows(
|
||||
servers: &[McpServerInfo],
|
||||
query: &str,
|
||||
filter: StatusFilter,
|
||||
collapsed_sections: &HashSet<String>,
|
||||
tools_expanded: &HashSet<usize>,
|
||||
) -> (Vec<String>, Vec<Option<String>>, Vec<Option<usize>>) {
|
||||
let rows = crate::views::extensions_modal::build_mcp_servers_picker_rows(
|
||||
servers,
|
||||
query,
|
||||
filter,
|
||||
collapsed_sections,
|
||||
tools_expanded,
|
||||
);
|
||||
(rows.labels, rows.group_keys, rows.data_indices)
|
||||
}
|
||||
|
||||
/// [`crate::views::extensions_modal::mcp_section_children_hidden`].
|
||||
pub fn mcp_section_children_hidden(
|
||||
collapsed_sections: &HashSet<String>,
|
||||
section_key: &str,
|
||||
searching: bool,
|
||||
) -> bool {
|
||||
crate::views::extensions_modal::mcp_section_children_hidden(
|
||||
collapsed_sections,
|
||||
section_key,
|
||||
searching,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`McpServerDisplayStatus::theme_color`].
|
||||
pub fn mcp_status_theme_color(status: &McpServerDisplayStatus, theme: &Theme) -> Color {
|
||||
status.theme_color(theme)
|
||||
}
|
||||
|
||||
/// [`McpServerDisplayStatus::label`].
|
||||
pub fn mcp_status_label(status: &McpServerDisplayStatus) -> &'static str {
|
||||
status.label()
|
||||
}
|
||||
|
||||
// ── Session picker builders ──────────────────────────────────────────────────
|
||||
|
||||
/// [`crate::views::session_picker::repo_name_from_cwd`].
|
||||
pub fn repo_name_from_cwd(cwd: &str) -> String {
|
||||
crate::views::session_picker::repo_name_from_cwd(cwd)
|
||||
}
|
||||
|
||||
/// [`crate::views::session_picker::filter_session_entries`].
|
||||
pub fn filter_session_entries(
|
||||
entries: Option<&[SessionPickerEntry]>,
|
||||
query: &str,
|
||||
source_filter: SourceFilter,
|
||||
) -> Vec<usize> {
|
||||
crate::views::session_picker::filter_session_entries(entries, query, source_filter)
|
||||
}
|
||||
|
||||
/// [`crate::views::session_picker::build_session_entry_data`].
|
||||
pub fn build_session_entry_data(
|
||||
entries_data: &[SessionPickerEntry],
|
||||
filtered_indices: &[usize],
|
||||
state: &PickerState,
|
||||
content_width: u16,
|
||||
) -> Vec<SessionEntryData> {
|
||||
crate::views::session_picker::build_session_entry_data(
|
||||
entries_data,
|
||||
filtered_indices,
|
||||
state,
|
||||
content_width,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`crate::views::session_picker::build_grouped_picker_entries`].
|
||||
pub fn build_grouped_picker_entries<'a>(
|
||||
entries_data: &'a [SessionPickerEntry],
|
||||
filtered_indices: &[usize],
|
||||
built: &'a [SessionEntryData],
|
||||
fields_vecs: &'a [Vec<PickerField<'a>>],
|
||||
state: &PickerState,
|
||||
current_repo: Option<&str>,
|
||||
) -> (Vec<PickerEntry<'a>>, Vec<bool>) {
|
||||
crate::views::session_picker::build_grouped_picker_entries(
|
||||
entries_data,
|
||||
filtered_indices,
|
||||
built,
|
||||
fields_vecs,
|
||||
state,
|
||||
current_repo,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Welcome logo ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// [`crate::views::welcome::logo::compact_logo_line_count`].
|
||||
pub fn compact_logo_line_count() -> u16 {
|
||||
crate::views::welcome::logo::compact_logo_line_count()
|
||||
}
|
||||
|
||||
/// [`crate::views::welcome::logo::render_compact_logo`].
|
||||
pub fn render_compact_logo(area: Rect, buf: &mut Buffer, theme: &Theme) {
|
||||
crate::views::welcome::logo::render_compact_logo(area, buf, theme);
|
||||
}
|
||||
|
||||
// ── Scrollback committed frontier (minimal-mode commit bookkeeping) ──────────
|
||||
//
|
||||
// The `committed` marker lives on `ScrollbackEntry` so it survives
|
||||
// `shift_remove`/`remove_from`; the scan cursor + expand ring
|
||||
// live on `ScrollbackState`. Only minimal drives these — they are `pub(crate)`
|
||||
// in `scrollback/*` and reached exclusively through the wrappers below.
|
||||
|
||||
/// Whether `entry` was already emitted to the terminal's native scrollback.
|
||||
///
|
||||
/// The committed frontier lives as an `EntryId` set on [`ScrollbackState`]
|
||||
/// (survives entry reordering for free), so this looks the entry up by id.
|
||||
pub fn is_committed(sb: &ScrollbackState, entry: &ScrollbackEntry) -> bool {
|
||||
sb.is_committed(entry.id)
|
||||
}
|
||||
|
||||
/// [`ScrollbackState::commit_scan_cursor`].
|
||||
pub fn commit_scan_cursor(sb: &ScrollbackState) -> usize {
|
||||
sb.commit_scan_cursor()
|
||||
}
|
||||
|
||||
/// [`ScrollbackState::set_commit_scan_cursor`].
|
||||
pub fn set_commit_scan_cursor(sb: &mut ScrollbackState, cursor: usize) {
|
||||
sb.set_commit_scan_cursor(cursor);
|
||||
}
|
||||
|
||||
/// [`ScrollbackState::mark_committed`].
|
||||
pub fn mark_committed(sb: &mut ScrollbackState, index: usize) {
|
||||
sb.mark_committed(index);
|
||||
}
|
||||
|
||||
/// [`ScrollbackState::record_committed_for_expand`].
|
||||
pub fn record_committed_for_expand(sb: &mut ScrollbackState, id: EntryId) {
|
||||
sb.record_committed_for_expand(id);
|
||||
}
|
||||
|
||||
// ── Test-only surface (minimal's unit tests, via the test-only helpers) ──
|
||||
|
||||
/// [`crate::app::agent_view::test_agent_view`].
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn test_agent_view(session_id: Option<&str>, cwd: std::path::PathBuf) -> AgentView {
|
||||
crate::app::agent_view::test_agent_view(session_id, cwd)
|
||||
}
|
||||
|
||||
/// Test-only setter for `AgentView::extensions_modal`.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn set_extensions_modal(v: &mut AgentView, val: Option<ExtensionsModalState>) {
|
||||
v.extensions_modal = val;
|
||||
}
|
||||
|
||||
/// Test-only setter for `AgentView::question_view`.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn set_question_view(v: &mut AgentView, val: Option<QuestionViewState>) {
|
||||
v.question_view = val;
|
||||
}
|
||||
|
||||
/// Test-only setter for `AgentView::plan_mode_active`.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn set_plan_mode_active(v: &mut AgentView, on: bool) {
|
||||
v.plan_mode_active = on;
|
||||
}
|
||||
|
||||
/// Test-only setter for `AgentView::plan_mode_pending`.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn set_plan_mode_pending(v: &mut AgentView, val: Option<bool>) {
|
||||
v.plan_mode_pending = val;
|
||||
}
|
||||
|
||||
/// Test-only mutable access to `PromptWidget::suggestions`.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn prompt_suggestions_mut(pw: &mut PromptWidget) -> &mut SuggestionController {
|
||||
&mut pw.suggestions
|
||||
}
|
||||
|
||||
/// Test-only setter for `AgentSession`'s yolo mode.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn set_yolo_mode_for_test(session: &mut AgentSession, on: bool) {
|
||||
session.set_yolo_mode_for_test(on);
|
||||
}
|
||||
|
||||
/// Test-only setter for `AgentSession`'s auto mode.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn set_auto_mode_for_test(session: &mut AgentSession, on: bool) {
|
||||
session.set_auto_mode_for_test(on);
|
||||
}
|
||||
|
||||
/// Test-only setter for the thread-local `show_thinking_blocks` appearance
|
||||
/// toggle. Thinking blocks render zero rows when this is off (the default), so
|
||||
/// minimal's commit-height tests must force it on to exercise a thinking
|
||||
/// block's committed height instead of getting an order-dependent 0.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn set_show_thinking_blocks(enabled: bool) {
|
||||
crate::appearance::cache::set_show_thinking_blocks(enabled);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Inversion-of-control seam for the optional minimal (scrollback-native)
|
||||
//! render mode.
|
||||
//!
|
||||
//! `kigi-tui` cannot depend on `kigi-pager-minimal`: that crate reads
|
||||
//! this crate's view model (`AppView`, the `views::*` widgets, `scrollback`),
|
||||
//! so a direct dependency would form a cargo cycle. Instead the minimal crate
|
||||
//! registers its entry points here via [`install`], and this crate dispatches
|
||||
//! into them through the stored function pointers.
|
||||
//!
|
||||
//! The composition-root binary (`kigi-bin`) wires it up once at
|
||||
//! startup by calling `kigi_pager_minimal::install()`. When nothing has
|
||||
//! installed the seam, the pager's `ScreenMode::Minimal` branches are inert
|
||||
//! (`draw` is a no-op, `/transcript` falls back to the empty case) — the
|
||||
//! default full-screen and inline render paths never touch this module.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::app::PagerTerminal;
|
||||
use crate::app::app_view::AppView;
|
||||
|
||||
/// Per-frame minimal render entry point
|
||||
/// (`kigi_pager_minimal::draw`).
|
||||
pub type MinimalDrawFn = fn(&mut AppView, &mut PagerTerminal);
|
||||
|
||||
/// The set of hooks the minimal crate installs.
|
||||
///
|
||||
/// Draw-only: minimal's `/transcript` used to hook in here too, but the
|
||||
/// transcript is now a *state-driven* incremental build
|
||||
/// (`minimal_api::request_minimal_transcript` arms it; the minimal draw loop
|
||||
/// pumps a time-budgeted slice per frame), so no second entry point is needed.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct MinimalHooks {
|
||||
/// Renders one frame of minimal mode. Called from `AppView::draw`.
|
||||
pub draw: MinimalDrawFn,
|
||||
}
|
||||
|
||||
static HOOKS: OnceLock<MinimalHooks> = OnceLock::new();
|
||||
|
||||
/// Install the minimal-mode hooks. Idempotent — the first call wins; later
|
||||
/// calls are ignored.
|
||||
pub fn install(hooks: MinimalHooks) {
|
||||
let _ = HOOKS.set(hooks);
|
||||
}
|
||||
|
||||
/// The installed hooks, if the minimal crate has been wired in.
|
||||
pub fn hooks() -> Option<&'static MinimalHooks> {
|
||||
HOOKS.get()
|
||||
}
|
||||
Reference in New Issue
Block a user