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:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,109 @@
//! Interactive playground for mermaid diagrams in the real scrollback path.
//!
//! Builds a [`ScrollbackState`] containing an agent message whose markdown holds
//! a mermaid code block, then renders it through the production
//! [`ScrollbackPane`] so the diagram can be eyeballed exactly as the TUI draws
//! it (including word-wrapping). Pick a sample with `MERMAID_SAMPLE=<n>`.
//!
//! Controls: arrows / PageUp / PageDown scroll, `q` / `Esc` / `Ctrl-Q` quit.
use std::io::{self, stdout};
use std::time::Duration;
use crossterm::ExecutableCommand;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use kigi_tui::scrollback::{RenderBlock, ScratchBuffer, ScrollbackPane, ScrollbackState};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::Rect;
const SAMPLES: &[(&str, &str)] = &[
(
"draw a flowchart for the deploy decision",
"Here is the deploy flow:\n\n```mermaid\nflowchart TD\n A[Start] --> B{Is it working?}\n B -->|Yes| C[Ship it]\n B -->|No| D[Debug]\n D --> B\n```\n\nLet me know if you want changes.",
),
(
"show the request path",
"Sure:\n\n```mermaid\ngraph TD\n A[Client] --> B[Load Balancer]\n B --> C[Server 1]\n B --> D[Server 2]\n C --> E[(Database)]\n D --> E\n```\n",
),
(
"a simple pipeline left to right",
"```mermaid\nflowchart LR\n A[Ingest] --> B[Transform] --> C[Validate] --> D[Store]\n```\n",
),
(
"sequence diagram please",
"```mermaid\nsequenceDiagram\n Alice->>Bob: Hello Bob\n Bob-->>Alice: Hi Alice\n```\n",
),
(
"coffee-making decision flowchart",
"Here's a coffee-making decision process:\n\n```mermaid\nflowchart TD\n A[Wake up] --> B{Need caffeine?}\n B -->|Yes| C[Go to kitchen]\n B -->|No| D[Drink water]\n C --> E{Beans available?}\n D --> F[Stay hydrated]\n E -->|Yes| G[Grind beans]\n E -->|No| H[Use instant coffee]\n G --> I[Brew espresso]\n H --> J[Boil water]\n I --> K{Add milk?}\n J --> K\n K -->|Yes| L[Make latte]\n K -->|No| M[Drink black]\n L --> N[Enjoy coffee]\n M --> N\n N --> O[Start the day]\n```\n",
),
];
fn main() -> io::Result<()> {
let sample = std::env::var("MERMAID_SAMPLE")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0)
% SAMPLES.len();
let (prompt, answer) = SAMPLES[sample];
let mut scrollback = ScrollbackState::new();
scrollback.push_block(RenderBlock::user_prompt(prompt));
scrollback.push_block(RenderBlock::agent_message(answer));
let mut scratch = ScratchBuffer::new();
terminal::enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
let mut first = true;
loop {
terminal.draw(|f| {
let area = f.area();
scrollback.prepare_layout(area.width, area.height);
if first {
scrollback.scroll_to_entry_top(0);
first = false;
}
let mut buf = ratatui::buffer::Buffer::empty(area);
let _ = ScrollbackPane::new().active(true).render_with_scratch(
Rect::new(area.x, area.y, area.width, area.height),
&mut buf,
&scrollback,
&mut scratch,
);
for y in 0..area.height {
for x in 0..area.width {
if let Some(src) = buf.cell((area.x + x, area.y + y))
&& let Some(dst) = f.buffer_mut().cell_mut((area.x + x, area.y + y))
{
*dst = src.clone();
}
}
}
})?;
if event::poll(Duration::from_millis(100))?
&& let Event::Key(KeyEvent {
code, modifiers, ..
}) = event::read()?
{
match code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => break,
KeyCode::Up => scrollback.scroll_up(2),
KeyCode::Down => scrollback.scroll_down(2),
KeyCode::PageUp => scrollback.scroll_up(15),
KeyCode::PageDown => scrollback.scroll_down(15),
_ => {}
}
}
}
stdout().execute(LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
Ok(())
}
@@ -0,0 +1,173 @@
use std::collections::VecDeque;
use std::io::{self, stdout};
use std::time::{Duration, Instant};
use crossterm::ExecutableCommand;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers,
};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
struct App {
events: VecDeque<String>,
last_mouse: Option<String>,
last_scroll_at: Option<Instant>,
scroll_intervals: VecDeque<f32>,
}
impl App {
fn new() -> Self {
Self {
events: VecDeque::new(),
last_mouse: None,
last_scroll_at: None,
scroll_intervals: VecDeque::new(),
}
}
fn push(&mut self, msg: String) {
self.last_mouse = Some(msg.clone());
self.events.push_front(msg);
while self.events.len() > 200 {
self.events.pop_back();
}
}
}
fn main() -> io::Result<()> {
terminal::enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
stdout().execute(EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
app.push("started mouse playground".to_string());
loop {
terminal.draw(|f| draw(f, &app))?;
if event::poll(Duration::from_millis(100))? {
let ev = event::read()?;
match ev {
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::CONTROL,
..
})
| Event::Key(KeyEvent {
code: KeyCode::Esc, ..
}) => break,
Event::Mouse(mouse) => {
let now = std::time::Instant::now();
let is_scroll = matches!(
mouse.kind,
event::MouseEventKind::ScrollUp | event::MouseEventKind::ScrollDown
);
let interval_str = if is_scroll {
if let Some(prev) = app.last_scroll_at {
let ms = now.duration_since(prev).as_secs_f64() * 1000.0;
app.scroll_intervals.push_back(ms as f32);
while app.scroll_intervals.len() > 20 {
app.scroll_intervals.pop_front();
}
let avg: f32 = app.scroll_intervals.iter().sum::<f32>()
/ app.scroll_intervals.len() as f32;
format!(
" dt={ms:.1}ms avg={avg:.1}ms n={}",
app.scroll_intervals.len()
)
} else {
app.scroll_intervals.clear();
" dt=- (first)".to_string()
}
} else {
if app.last_scroll_at.is_some() {
app.scroll_intervals.clear();
}
String::new()
};
if is_scroll {
app.last_scroll_at = Some(now);
}
app.push(format!(
"mouse {:?} col={} row={} mods={:?}{interval_str}",
mouse.kind, mouse.column, mouse.row, mouse.modifiers
));
}
Event::Key(key) => {
app.push(format!("key {:?}", key));
}
other => {
app.push(format!("event {:?}", other));
}
}
}
}
stdout().execute(DisableMouseCapture)?;
stdout().execute(LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
Ok(())
}
fn draw(f: &mut ratatui::Frame, app: &App) {
let area = f.area();
let chunks = Layout::vertical([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(8),
Constraint::Min(1),
])
.split(area);
let title = Paragraph::new(vec![
Line::from(Span::styled(
"mouse-events-playground",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from("Esc / Ctrl-Q to quit"),
])
.block(Block::default().borders(Borders::ALL).title("info"));
f.render_widget(title, chunks[0]);
let last = app.last_mouse.as_deref().unwrap_or("(no mouse event yet)");
let last_para = Paragraph::new(last).wrap(Wrap { trim: false }).block(
Block::default()
.borders(Borders::ALL)
.title("last mouse event"),
);
f.render_widget(last_para, chunks[1]);
let sample = vec![
Line::from(Span::styled(
"Try dragging across the text below with your trackpad.",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(
"I'm Grok Build, an interactive CLI agent built to help with software engineering tasks.",
),
Line::from(
"This sample is here so you can drag over real text while watching raw mouse events.",
),
Line::from(""),
Line::from("Expected useful events: Down(Left), Drag(Left), Up(Left), or at least Moved."),
];
let sample_para = Paragraph::new(sample)
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("sample text"));
f.render_widget(sample_para, chunks[2]);
let lines: Vec<Line<'static>> = app.events.iter().map(|e| Line::from(e.clone())).collect();
let log = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("event log"));
f.render_widget(log, chunks[3]);
}
@@ -0,0 +1,409 @@
use std::io::{self, stdout};
use std::time::Duration;
use crossterm::ExecutableCommand;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use kigi_tools::implementations::grok_build::ask_user_question::{Question, QuestionOption};
use kigi_tui::theme::Theme;
use kigi_tui::views::prompt_widget::StashedPrompt;
use kigi_tui::views::question_view::{
QUESTION_VIEW_HPAD, QuestionViewState, question_view_height, render_question_view,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
/// Hardcoded example question sets for UI playground scenarios.
fn example_scenarios() -> Vec<(&'static str, Vec<Question>)> {
vec![
(
"Commit confirmation (preview with multi-line message)",
vec![Question {
question: "Ready to commit the staged changes with this conventional commit message?".into(),
options: vec![
QuestionOption {
label: "Yes, commit now".into(),
description: "Run git commit with the message below and push to origin".into(),
preview: Some(
"fix(example-skills): resolve post-setup review findings\n\n\
- Move path resolution before vendor existence check (HIGH)\n\
- Improve awk parser to skip -b/-B args (MEDIUM)\n\n\
Addresses review-bot inline comments on PR #1001."
.into(),
),
id: None,
},
QuestionOption {
label: "Edit message first".into(),
description: "Provide a different commit message".into(),
preview: None,
id: None,
},
QuestionOption {
label: "Cancel".into(),
description: "Do not commit yet".into(),
preview: None,
id: None,
},
],
multi_select: None,
id: None,
}],
),
(
"Commit message choice (preview on multiple options)",
vec![Question {
question: "What commit message should I use? (I'll stage + commit + push after your confirmation)".into(),
options: vec![
QuestionOption {
label: "Use my suggested one (with PROJ-1234)".into(),
description: "fix(example-skills): resolve post-setup review findings (PROJ-1234)".into(),
preview: Some(
"fix(example-skills): resolve post-setup review findings\n\n\
- Move path resolution before vendor existence check (HIGH review feedback)\n\
- Improve awk parser to skip -b/-B args (MEDIUM)\n\n\
Addresses inline comments on PR #1001 for PROJ-1234."
.into(),
),
id: None,
},
QuestionOption {
label: "Simpler: fix post-setup.sh per review".into(),
description: "Just a short one".into(),
preview: None,
id: None,
},
QuestionOption {
label: "Provide custom message".into(),
description: "I'll type the full message".into(),
preview: None,
id: None,
},
],
multi_select: None,
id: None,
}],
),
(
"Multi-select with previews",
vec![Question {
question: "Which database engines should we evaluate?\n\nSelect all that apply for the backend service.".into(),
options: vec![
QuestionOption {
label: "PostgreSQL (Recommended)".into(),
description: "Battle-tested relational DB with JSONB support".into(),
preview: Some("CREATE TABLE users (\n id SERIAL PRIMARY KEY,\n email TEXT UNIQUE NOT NULL\n);".into()),
id: None,
},
QuestionOption {
label: "Redis".into(),
description: "In-memory key-value store for caching".into(),
preview: Some("SET user:1 '{\"email\":\"a@b.com\"}' EX 3600".into()),
id: None,
},
QuestionOption {
label: "Cassandra".into(),
description: "Wide-column store for large datasets".into(),
preview: None,
id: None,
},
],
multi_select: Some(true),
id: None,
}],
),
(
"Multi-tab: architecture decisions (Tab/Shift-Tab to switch)",
vec![
Question {
question: "Which database engine should we use for the backend?".into(),
options: vec![
QuestionOption {
label: "PostgreSQL (Recommended)".into(),
description: "Battle-tested relational DB with JSONB".into(),
preview: Some(
"CREATE TABLE users (\n id SERIAL PRIMARY KEY,\n email TEXT UNIQUE NOT NULL,\n created_at TIMESTAMPTZ DEFAULT NOW()\n);".into(),
),
id: None,
},
QuestionOption {
label: "SQLite".into(),
description: "Embedded, zero-config, single-file".into(),
preview: None,
id: None,
},
QuestionOption {
label: "Cassandra".into(),
description: "Wide-column store for large datasets".into(),
preview: None,
id: None,
},
],
multi_select: None,
id: None,
},
Question {
question: "Which caching strategy do you want?".into(),
options: vec![
QuestionOption {
label: "Redis".into(),
description: "In-memory key-value store, distributed".into(),
preview: Some("SET session:abc123 '{\"user_id\": 42}' EX 3600".into()),
id: None,
},
QuestionOption {
label: "In-process LRU".into(),
description: "No external dependency, per-instance cache".into(),
preview: None,
id: None,
},
],
multi_select: None,
id: None,
},
Question {
question: "Which features should be enabled at launch?\n\nSelect all that apply.".into(),
options: vec![
QuestionOption {
label: "Auth".into(),
description: "JWT-based authentication middleware".into(),
preview: None,
id: None,
},
QuestionOption {
label: "Rate limiting".into(),
description: "Token bucket per API key".into(),
preview: None,
id: None,
},
QuestionOption {
label: "Audit logging".into(),
description: "Structured logs for compliance".into(),
preview: None,
id: None,
},
QuestionOption {
label: "Metrics".into(),
description: "Prometheus /metrics endpoint".into(),
preview: None,
id: None,
},
],
multi_select: Some(true),
id: None,
},
],
),
]
}
struct App {
scenarios: Vec<(&'static str, Vec<Question>)>,
active_scenario: usize,
state: QuestionViewState,
theme: Theme,
status: String,
}
impl App {
fn new() -> Self {
let scenarios = example_scenarios();
let state = QuestionViewState::new(
"playground".into(),
scenarios[0].1.clone(),
StashedPrompt::default(),
);
Self {
scenarios,
active_scenario: 0,
state,
theme: Theme::default(),
status: "j/k navigate, Space/Enter select, n/p switch scenario, Esc quit".into(),
}
}
fn switch_scenario(&mut self, idx: usize) {
self.active_scenario = idx;
self.state = QuestionViewState::new(
"playground".into(),
self.scenarios[idx].1.clone(),
StashedPrompt::default(),
);
self.status = format!(
"Scenario {}/{}: {}",
idx + 1,
self.scenarios.len(),
self.scenarios[idx].0
);
}
}
fn format_cursor_status(state: &QuestionViewState) -> String {
let preview_str = state
.focused_preview()
.map(|p| {
let trunc: String = p.chars().take(40).collect();
format!("{trunc}...")
})
.unwrap_or_else(|| "None".into());
format!("cursor={} preview={}", state.cursor(), preview_str)
}
fn format_tab_status(state: &QuestionViewState) -> String {
let question_text = state
.questions
.get(state.active_tab)
.map(|q| q.question.as_str())
.unwrap_or("?");
format!(
"tab {}/{} - {}",
state.active_tab + 1,
state.questions.len(),
question_text,
)
}
fn main() -> io::Result<()> {
terminal::enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
app.switch_scenario(0);
loop {
terminal.draw(|f| draw(f, &mut app))?;
if event::poll(Duration::from_millis(50))? {
let ev = event::read()?;
match ev {
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::CONTROL,
..
})
| Event::Key(KeyEvent {
code: KeyCode::Esc, ..
}) => break,
Event::Key(KeyEvent {
code: KeyCode::Char('j') | KeyCode::Down,
..
}) => {
let cur = app.state.cursor();
app.state.set_cursor(cur + 1);
app.status = format_cursor_status(&app.state);
}
Event::Key(KeyEvent {
code: KeyCode::Char('k') | KeyCode::Up,
..
}) => {
let cur = app.state.cursor();
app.state.set_cursor(cur.saturating_sub(1));
app.status = format_cursor_status(&app.state);
}
Event::Key(KeyEvent {
code: KeyCode::Char(' ') | KeyCode::Enter,
..
}) => {
let tab = app.state.active_tab;
let cur = app.state.cursor();
app.state.toggle_option(tab, cur);
app.status = format!(
"toggled option {} -> selected: {:?}",
cur,
app.state.selected_labels(tab)
);
}
Event::Key(KeyEvent {
code: KeyCode::Tab,
modifiers: KeyModifiers::NONE,
..
}) => {
app.state.next_question();
app.status = format_tab_status(&app.state);
}
Event::Key(KeyEvent {
code: KeyCode::BackTab,
..
}) => {
app.state.prev_question();
app.status = format_tab_status(&app.state);
}
Event::Key(KeyEvent {
code: KeyCode::Char('n'),
..
}) => {
let next = (app.active_scenario + 1) % app.scenarios.len();
app.switch_scenario(next);
}
Event::Key(KeyEvent {
code: KeyCode::Char('p'),
..
}) => {
let prev = if app.active_scenario == 0 {
app.scenarios.len() - 1
} else {
app.active_scenario - 1
};
app.switch_scenario(prev);
}
_ => {}
}
}
}
stdout().execute(LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
Ok(())
}
fn draw(f: &mut ratatui::Frame, app: &mut App) {
let area = f.area();
// Layout: header(3) + question view (dynamic) + status(3)
let content_w = area.width.saturating_sub(QUESTION_VIEW_HPAD) as usize;
let qv_height = question_view_height(&mut app.state, area.height.saturating_sub(6), content_w);
let chunks = Layout::vertical([
Constraint::Length(3),
Constraint::Length(qv_height),
Constraint::Length(3),
Constraint::Min(0),
])
.split(area);
// Header
let scenario_label = format!(
"question-view-playground [{}/{}] {}",
app.active_scenario + 1,
app.scenarios.len(),
app.scenarios[app.active_scenario].0,
);
let header = Paragraph::new(vec![
Line::from(Span::styled(
scenario_label,
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from("j/k: navigate Space/Enter: select Tab/Shift-Tab: switch tab n/p: scenario Esc: quit"),
])
.block(Block::default().borders(Borders::ALL).title("info"));
f.render_widget(header, chunks[0]);
// Question view
let qv_area = chunks[1];
if qv_area.height > 0 && qv_area.width > 0 {
render_question_view(f.buffer_mut(), qv_area, &app.state, None, &app.theme, true);
}
// Status bar
let status = Paragraph::new(app.status.clone())
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("status"));
f.render_widget(status, chunks[2]);
}
@@ -0,0 +1,268 @@
//! Interactive playground for the scrollback search render layer.
//!
//! Drives a real [`ScrollbackSearchState`] over a sample scrollback so the
//! search bar and match highlighting can be eyeballed before the feature is
//! wired into the production input path. Type to search, `Enter` to accept,
//! `n` / `N` to step through matches (which scrolls the match into view via
//! `reveal_entry_line`), `Esc` to clear the query (or quit when already empty),
//! `Ctrl-Q` to quit.
use std::collections::VecDeque;
use std::io::{self, stdout};
use std::time::Duration;
use crossterm::ExecutableCommand;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use kigi_tui::scrollback::{
RenderBlock, ScratchBuffer, ScrollbackPane, ScrollbackSearchState, ScrollbackState,
};
use kigi_tui::theme::Theme;
use kigi_tui::views::picker::render_search_bar;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use unicode_width::UnicodeWidthStr;
struct App {
scrollback: ScrollbackState,
scratch: ScratchBuffer,
search: ScrollbackSearchState,
/// The query input buffer the user is editing (the source of truth for the
/// composing string, mirroring how the production input router will work).
query: String,
events: VecDeque<String>,
}
impl App {
fn new() -> Self {
let mut scrollback = ScrollbackState::new();
scrollback.push_block(RenderBlock::user_prompt("how does the search index work?"));
scrollback.push_block(RenderBlock::thinking(
"The search index caches each entry's source text and scans it for query matches.",
));
scrollback.push_block(RenderBlock::agent_message(
"The scrollback search index keeps one owned String per entry and re-syncs only when \
content changes. The search bar shows the live query, and every match on a visible \
row is highlighted. Search again to find more matches.",
));
scrollback.push_block(RenderBlock::agent_message(
"Press n and N to step through matches; the current match is scrolled into view.",
));
Self {
scrollback,
scratch: ScratchBuffer::new(),
search: ScrollbackSearchState::open(),
query: String::new(),
events: VecDeque::new(),
}
}
fn push(&mut self, msg: String) {
self.events.push_front(msg);
while self.events.len() > 200 {
self.events.pop_back();
}
}
/// Re-run the search for the current query buffer.
fn refresh_query(&mut self) {
self.search.update_query(&self.query, &self.scrollback);
self.reveal_current();
self.push(format!(
"query={:?} matches={}",
self.query,
self.search.match_count()
));
}
/// Scroll the current match into view (exercises the reveal path).
fn reveal_current(&mut self) {
if let Some(m) = self.search.current()
&& let Some(idx) = self.scrollback.index_of_id(m.entry_id)
{
let line = m.line_in_entry;
self.scrollback.reveal_entry_line(idx, line);
}
}
}
fn main() -> io::Result<()> {
terminal::enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
app.push("started scrollback search playground".to_string());
loop {
// Matching runs on a background thread; pick up results each iteration
// and scroll the freshly parked match into view, mirroring the per-tick
// poll in the production app.
if app.search.poll() {
app.reveal_current();
}
terminal.draw(|f| draw(f, &mut app))?;
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::CONTROL,
..
}) => break,
Event::Key(key) if handle_key(&mut app, key) => {
break;
}
_ => {}
}
}
}
stdout().execute(LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
Ok(())
}
/// Returns `true` when the app should quit.
fn handle_key(app: &mut App, key: KeyEvent) -> bool {
match key.code {
// Esc clears the query; quit only when it's already empty.
KeyCode::Esc => {
if app.query.is_empty() {
return true;
}
app.query.clear();
app.search = ScrollbackSearchState::open();
app.refresh_query();
app.push("clear".to_string());
}
KeyCode::Enter => {
app.search.accept();
app.push("accept: browsing".to_string());
}
KeyCode::Backspace => {
app.query.pop();
app.refresh_query();
}
// While browsing (accepted), `n` / `N` navigate. While composing they
// are typed into the query, matching real vim `/` behavior.
KeyCode::Char('n') if !app.search.is_composing() => {
app.search.next();
app.reveal_current();
app.push(format!("next -> {:?}", app.search.current_index()));
}
KeyCode::Char('N') if !app.search.is_composing() => {
app.search.prev();
app.reveal_current();
app.push(format!("prev -> {:?}", app.search.current_index()));
}
KeyCode::Char(c) => {
app.query.push(c);
app.refresh_query();
}
_ => {}
}
false
}
fn draw(f: &mut ratatui::Frame, app: &mut App) {
let theme = Theme::current();
let area = f.area();
let chunks = Layout::vertical([
Constraint::Length(4), // info / help
Constraint::Length(14), // scrollback + search bar
Constraint::Min(1), // event log
])
.split(area);
// -- Info / help panel --
let info = Paragraph::new(vec![
Line::from(Span::styled(
"scrollback-search-playground",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(
"Type to search | Enter accept | n/N navigate (after Enter) | Esc clear/quit | Ctrl-Q quit",
),
])
.block(Block::default().borders(Borders::ALL).title("info"));
f.render_widget(info, chunks[0]);
// -- Scrollback + search bar --
// Reserve the bottom row of the block for the search bar, exactly as the
// production draw path will when `scrollback_search.is_some()`.
let block_area = chunks[1];
let sb_area = Rect {
height: block_area.height.saturating_sub(1),
..block_area
};
let bar_y = sb_area.y + sb_area.height;
app.scrollback.prepare_layout(sb_area.width, sb_area.height);
// Highlight only when the query compiles to a usable regex.
let highlight = app.search.highlight_regex();
let mut sb_buf = Buffer::empty(block_area);
let _ = ScrollbackPane::new()
.active(true)
.with_search_highlight(highlight)
.render_with_scratch(sb_area, &mut sb_buf, &app.scrollback, &mut app.scratch);
for y in 0..sb_area.height {
for x in 0..sb_area.width {
if let Some(src) = sb_buf.cell((sb_area.x + x, sb_area.y + y))
&& let Some(dst) = f.buffer_mut().cell_mut((sb_area.x + x, sb_area.y + y))
{
*dst = src.clone();
}
}
}
// -- Search bar --
render_search_bar(
f.buffer_mut(),
block_area.x,
bar_y,
block_area.width,
&theme,
&app.query,
app.search.is_composing(),
app.query.is_empty() && app.search.is_composing(),
app.query.len(),
None,
);
// Right-aligned match counter: `m/n`, or `no matches` for a live query.
let counter = match app.search.current_index() {
Some(i) => Some(format!("{}/{}", i + 1, app.search.match_count())),
None if !app.query.is_empty() => Some("no matches".to_string()),
None => None,
};
if let Some(counter) = counter {
let w = counter.width() as u16;
if block_area.width > w {
f.buffer_mut().set_string(
block_area.x + block_area.width - w,
bar_y,
&counter,
Style::default().fg(theme.gray),
);
}
}
// -- Event log --
let lines: Vec<Line<'static>> = app.events.iter().map(|e| Line::from(e.clone())).collect();
let log = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("event log"));
f.render_widget(log, chunks[2]);
}
@@ -0,0 +1,457 @@
use std::collections::VecDeque;
use std::io::{self, stdout};
use std::time::{Duration, Instant};
use crossterm::ExecutableCommand;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers,
MouseButton, MouseEventKind,
};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use kigi_tui::scrollback::text_selection::{
PersistentTextSelection, RangeHit, ResolvedSelectionModel, SelectionEndpoint, SelectionOrigin,
configured_word_separators, render_persistent_selection_overlay, url_range_at_col,
word_boundaries_at_col,
};
use kigi_tui::scrollback::types::slice_display_cols;
use kigi_tui::scrollback::{RenderBlock, ScratchBuffer, ScrollbackPane, ScrollbackState};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
/// Maximum time (ms) between consecutive clicks to count as a multi-click.
const MULTI_CLICK_TIMEOUT_MS: u128 = 300;
/// Maximum selected text length shown in event log before truncating.
const MAX_DISPLAY_TEXT_LEN: usize = 60;
/// Playground highlight TTL (`hold`/`word_select` → 0; flash → 500ms for visibility).
fn selection_highlight_duration_ms() -> u64 {
const PLAYGROUND_FLASH_MS: u64 = 500;
if kigi_tui::appearance::cache::load_keep_text_selection().holds() {
0
} else {
PLAYGROUND_FLASH_MS
}
}
/// Whether double-click does terminal-like word/line selection (`word_select`)
/// vs. fold toggle, per the unified `keep_text_selection` setting.
fn double_click_action_label() -> &'static str {
if kigi_tui::appearance::cache::load_keep_text_selection().selects_word() {
"word_select"
} else {
"toggle_fold"
}
}
fn truncate_for_display(text: &str) -> String {
if text.len() <= MAX_DISPLAY_TEXT_LEN {
text.to_owned()
} else {
let end = text
.char_indices()
.take_while(|(i, _)| *i < MAX_DISPLAY_TEXT_LEN)
.last()
.map_or(0, |(i, c)| i + c.len_utf8());
format!("{}...", &text[..end])
}
}
struct App {
scrollback: ScrollbackState,
scratch: ScratchBuffer,
last_mouse: Option<String>,
events: VecDeque<String>,
persistent_selection: Option<PersistentTextSelection>,
/// Timestamp when the persistent selection was created (for auto-dismiss).
selection_created_at: Option<Instant>,
/// Last click for multi-click detection: (time, entry_idx, range_id, block_line_idx, count).
last_click: Option<(Instant, usize, u16, usize, u8)>,
/// Stash the last-frame selection model for click processing.
last_selection_model: ResolvedSelectionModel,
}
impl App {
fn new() -> Self {
let mut scrollback = ScrollbackState::new();
scrollback.push_block(RenderBlock::user_prompt("who are you?"));
scrollback.push_block(RenderBlock::thinking(
"I am thinking through how to answer your question before responding.",
));
scrollback.push_block(RenderBlock::agent_message(
"I'm Grok Build, an interactive CLI agent built to help with software engineering tasks like coding, debugging, refactoring, and exploring codebases.",
));
scrollback.push_block(RenderBlock::agent_message(
"Try drag-selecting text. Double-click toggles fold by default; set Text selection → Word select for double-click word / triple-click line.",
));
Self {
scrollback,
scratch: ScratchBuffer::new(),
last_mouse: None,
events: VecDeque::new(),
persistent_selection: None,
selection_created_at: None,
last_click: None,
last_selection_model: ResolvedSelectionModel::default(),
}
}
fn push(&mut self, msg: String) {
self.events.push_front(msg);
while self.events.len() > 200 {
self.events.pop_back();
}
}
/// Count multi-clicks on the same (entry, range, line).
fn count_click(&self, now: Instant, hit: &RangeHit) -> u8 {
if let Some((prev_time, eidx, rid, bli, count)) = self.last_click
&& eidx == hit.entry_idx
&& rid == hit.range_id
&& bli == hit.block_line_idx
&& now.duration_since(prev_time).as_millis() < MULTI_CLICK_TIMEOUT_MS
{
count.saturating_add(1)
} else {
1
}
}
/// Handle a mouse-up on the scrollback area: detect multi-clicks and
/// perform word/line selection.
fn handle_click(&mut self, col: u16, row: u16) {
let Some(hit) = self.last_selection_model.hit_test_text_exact(col, row) else {
return;
};
let now = Instant::now();
let click_count = self.count_click(now, &hit);
match click_count {
2 => self.select_word(&hit),
3 => self.select_line(&hit),
_ => {}
}
let next_count = if click_count >= 3 { 0 } else { click_count };
if next_count > 0 {
self.last_click = Some((
now,
hit.entry_idx,
hit.range_id,
hit.block_line_idx,
next_count,
));
} else {
self.last_click = None;
}
}
fn select_word(&mut self, hit: &RangeHit) {
let Some(line) = self.last_selection_model.line_for_hit(hit) else {
return;
};
let url_range = url_range_at_col(&line.text, hit.col_within_range);
let is_url = url_range.is_some();
let separators = configured_word_separators();
let selection_range = url_range.unwrap_or_else(|| {
word_boundaries_at_col(&line.text, hit.col_within_range, separators)
});
if selection_range.is_empty() {
return;
}
let text = slice_display_cols(&line.text, selection_range.start, selection_range.end);
let kind = if is_url { "url" } else { "word" };
self.push(format!(
"double-click {kind}: \"{}\"",
truncate_for_display(&text)
));
self.persistent_selection = Some(PersistentTextSelection {
entry_idx: hit.entry_idx,
range_id: hit.range_id,
anchor: SelectionEndpoint {
block_line_idx: hit.block_line_idx,
col_within_range: selection_range.start,
},
head: SelectionEndpoint {
block_line_idx: hit.block_line_idx,
col_within_range: selection_range.end.saturating_sub(1),
},
origin: SelectionOrigin::DoubleClick,
kind: Default::default(),
});
self.selection_created_at = Some(Instant::now());
}
fn select_line(&mut self, hit: &RangeHit) {
let Some(line) = self.last_selection_model.line_for_hit(hit) else {
return;
};
let width = line
.selectable_cols
.end
.saturating_sub(line.selectable_cols.start);
if width == 0 {
return;
}
self.push(format!(
"triple-click line: \"{}\"",
truncate_for_display(&line.text)
));
self.persistent_selection = Some(PersistentTextSelection {
entry_idx: hit.entry_idx,
range_id: hit.range_id,
anchor: SelectionEndpoint {
block_line_idx: hit.block_line_idx,
col_within_range: 0,
},
head: SelectionEndpoint {
block_line_idx: hit.block_line_idx,
col_within_range: width.saturating_sub(1),
},
origin: SelectionOrigin::TripleClick,
kind: Default::default(),
});
self.selection_created_at = Some(Instant::now());
}
}
fn main() -> io::Result<()> {
terminal::enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
stdout().execute(EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
app.push("started scrollback selection playground".to_string());
loop {
// Auto-dismiss selection highlight after timeout.
let duration = selection_highlight_duration_ms();
if duration > 0
&& let Some(created) = app.selection_created_at
&& created.elapsed().as_millis() as u64 >= duration
{
app.persistent_selection = None;
app.selection_created_at = None;
}
terminal.draw(|f| draw(f, &mut app))?;
if event::poll(Duration::from_millis(100))? {
let ev = event::read()?;
match ev {
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::CONTROL,
..
}) => break,
Event::Key(KeyEvent {
code: KeyCode::Esc, ..
}) => {
if app.persistent_selection.take().is_some() {
app.selection_created_at = None;
app.push("Escape: cleared persistent selection".to_string());
} else {
break;
}
}
Event::Mouse(mouse) => {
let msg = format!(
"mouse {:?} col={} row={} mods={:?}",
mouse.kind, mouse.column, mouse.row, mouse.modifiers
);
app.last_mouse = Some(msg.clone());
app.push(msg);
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
app.persistent_selection = None;
app.selection_created_at = None;
}
MouseEventKind::Up(MouseButton::Left) => {
app.handle_click(mouse.column, mouse.row);
}
_ => {}
}
}
Event::Key(key) => {
app.push(format!("key {:?}", key));
}
other => {
app.push(format!("event {:?}", other));
}
}
}
}
stdout().execute(DisableMouseCapture)?;
stdout().execute(LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
Ok(())
}
fn draw(f: &mut ratatui::Frame, app: &mut App) {
let area = f.area();
let chunks = Layout::vertical([
Constraint::Length(5), // info + help
Constraint::Length(3), // last mouse event
Constraint::Length(3), // selection hit test
Constraint::Length(3), // persistent selection state
Constraint::Length(14), // scrollback
Constraint::Min(1), // event log
])
.split(area);
// -- Info / help panel --
let dbl_click = double_click_action_label();
let info = Paragraph::new(vec![
Line::from(Span::styled(
"scrollback-selection-playground",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(format!(
"Ctrl-Q quit | Esc clear selection | double_click_action={dbl_click}"
)),
Line::from("Double-click: select word/URL | Triple-click: select line"),
])
.block(Block::default().borders(Borders::ALL).title("info"));
f.render_widget(info, chunks[0]);
// -- Scrollback render --
let scrollback_area = chunks[4];
app.scrollback
.prepare_layout(scrollback_area.width, scrollback_area.height);
let mut sb_buf = Buffer::empty(scrollback_area);
let sb_output = ScrollbackPane::new().active(true).render_with_scratch(
scrollback_area,
&mut sb_buf,
&app.scrollback,
&mut app.scratch,
);
// Stash the selection model for click processing next frame.
app.last_selection_model = sb_output.selection_model.clone();
for y in 0..scrollback_area.height {
for x in 0..scrollback_area.width {
if let Some(src) = sb_buf.cell((scrollback_area.x + x, scrollback_area.y + y))
&& let Some(dst) = f
.buffer_mut()
.cell_mut((scrollback_area.x + x, scrollback_area.y + y))
{
*dst = src.clone();
}
}
}
// Render selection overlays.
if let Some(sel) = &sb_output.selection_box {
sel.render(f.buffer_mut());
}
if let Some(ref ps) = app.persistent_selection {
render_persistent_selection_overlay(&app.last_selection_model, ps, None, f.buffer_mut());
}
// -- Last mouse event panel --
let last_mouse = app.last_mouse.as_deref().unwrap_or("(no mouse event yet)");
let last_mouse_para = Paragraph::new(last_mouse).wrap(Wrap { trim: false }).block(
Block::default()
.borders(Borders::ALL)
.title("last mouse event"),
);
f.render_widget(last_mouse_para, chunks[1]);
// -- Hit test panel --
let hit = app.last_mouse.as_ref().and_then(|mouse| {
let parts: Vec<&str> = mouse.split_whitespace().collect();
let col = parts
.iter()
.find_map(|p| p.strip_prefix("col=").and_then(|n| n.parse::<u16>().ok()));
let row = parts
.iter()
.find_map(|p| p.strip_prefix("row=").and_then(|n| n.parse::<u16>().ok()));
match (col, row) {
(Some(c), Some(r)) => {
let exact = sb_output
.selection_model
.hit_test_text_exact(c, r)
.map(|h| {
format!(
"exact entry={} range={} line={} col={}",
h.entry_idx, h.range_id, h.block_line_idx, h.col_within_range
)
});
let nearest = sb_output.selection_model.hit_test_selectable_range(c, r);
Some((exact, nearest))
}
_ => None,
}
});
let hit_text = match &hit {
Some((Some(exact), _)) => exact.clone(),
Some((None, Some(h))) => format!(
"nearest entry={} range={} line={} col={} (no exact)",
h.entry_idx, h.range_id, h.block_line_idx, h.col_within_range
),
Some((None, None)) => format!(
"no hit (ranges={}, blocks={})",
sb_output.selection_model.ranges.len(),
sb_output.selection_model.visible_blocks.len()
),
None => "(no mouse event yet)".to_string(),
};
let hit_para = Paragraph::new(hit_text).wrap(Wrap { trim: false }).block(
Block::default()
.borders(Borders::ALL)
.title("selection hit test"),
);
f.render_widget(hit_para, chunks[2]);
// -- Persistent selection state panel --
let sel_text = match &app.persistent_selection {
Some(ps) => {
let origin = match ps.origin {
SelectionOrigin::Drag => "Drag",
SelectionOrigin::DoubleClick => "DoubleClick",
SelectionOrigin::TripleClick => "TripleClick",
};
format!(
"entry={} range={} anchor=({},{}) head=({},{}) origin={}",
ps.entry_idx,
ps.range_id,
ps.anchor.block_line_idx,
ps.anchor.col_within_range,
ps.head.block_line_idx,
ps.head.col_within_range,
origin,
)
}
None => "(none)".to_string(),
};
let sel_para = Paragraph::new(sel_text).wrap(Wrap { trim: false }).block(
Block::default()
.borders(Borders::ALL)
.title("persistent selection"),
);
f.render_widget(sel_para, chunks[3]);
// -- Event log --
let lines: Vec<Line<'static>> = app.events.iter().map(|e| Line::from(e.clone())).collect();
let log = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("event log"));
f.render_widget(log, chunks[5]);
}
@@ -0,0 +1,231 @@
//! Interactive playground for the Ctrl+T todo pane (hide-done empty state).
//!
//! ```text
//! cargo run -p kigi-tui --bin todo-pane-playground
//! ```
//!
//! Keys: h = hide/show done (same as real pane), n/p = scenario, Esc/q = quit.
use std::io::{self, stdout};
use std::time::Duration;
use crossterm::ExecutableCommand;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use kigi_shell::tools::{TodoItem, TodoPriority, TodoStatus};
use kigi_tui::appearance::LayoutConfig;
use kigi_tui::views::todo_pane::TodoPane;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
type Scenario = (&'static str, &'static str, Vec<TodoItem>);
fn item(content: &str, status: TodoStatus) -> TodoItem {
TodoItem {
content: content.into(),
priority: TodoPriority::default(),
status,
meta: None,
}
}
fn scenarios() -> Vec<Scenario> {
vec![
(
"Repro: 5 done + 1 cancelled (press h)",
"Default show_done=true lists rows; press h → should NOT say All done.",
vec![
item("Wire vim mode into command palette", TodoStatus::Completed),
item("Wire vim mode into /model picker", TodoStatus::Completed),
item("Wire vim mode into /theme picker", TodoStatus::Completed),
item("Wire vim mode into /resume picker", TodoStatus::Completed),
item("Wire vim mode into sessions modal", TodoStatus::Completed),
item("Ship misleading All done copy", TodoStatus::Cancelled),
],
),
(
"All completed (press h → All done.)",
"Hide done with only completed items → All done.",
vec![
item("Read Slack feedback", TodoStatus::Completed),
item("Implement empty_placeholder_message", TodoStatus::Completed),
item("Add unit tests", TodoStatus::Completed),
],
),
(
"Only cancelled (press h)",
"No completed rows → N cancelled., never All done.",
vec![
item("Abandoned approach A", TodoStatus::Cancelled),
item("Abandoned approach B", TodoStatus::Cancelled),
],
),
(
"Mixed open work",
"Pending/in progress stay visible when hide done is on.",
vec![
item("Done already", TodoStatus::Completed),
item("In flight", TodoStatus::InProgress),
item("Not started", TodoStatus::Pending),
item("Dropped", TodoStatus::Cancelled),
],
),
("Empty plan", "No todos → No todo items.", vec![]),
]
}
struct App {
scenarios: Vec<Scenario>,
active: usize,
pane: TodoPane,
layout: LayoutConfig,
status: String,
}
impl App {
fn new() -> Self {
let scenarios = scenarios();
let mut pane = TodoPane::new();
pane.overlay.show();
let mut app = Self {
scenarios,
active: 0,
pane,
layout: LayoutConfig::default(),
status: String::new(),
};
app.apply_scenario();
app
}
fn apply_scenario(&mut self) {
let items = self.scenarios[self.active].2.clone();
self.pane.update_todos(items);
// Match real default: done rows visible until user presses h.
while !self.pane.show_done() {
self.pane.toggle_show_done();
}
self.pane.overlay.show();
self.refresh_status();
}
fn switch(&mut self, idx: usize) {
self.active = idx;
self.apply_scenario();
}
fn refresh_status(&mut self) {
let c = self.pane.counts();
let (_, hint, _) = &self.scenarios[self.active];
self.status = format!(
"{hint} | counts: ▶{}{}{}{} show_done={} visible_hint={}",
c.in_progress,
c.pending,
c.completed,
c.cancelled,
self.pane.show_done(),
if self.pane.show_done() {
"all rows"
} else {
"open only (or empty placeholder)"
},
);
}
}
fn main() -> io::Result<()> {
terminal::enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
let mut app = App::new();
loop {
terminal.draw(|f| draw(f, &mut app))?;
if !event::poll(Duration::from_millis(100))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != event::KeyEventKind::Press {
continue;
}
if matches!(key.code, KeyCode::Esc)
|| (key.code == KeyCode::Char('q') && key.modifiers == KeyModifiers::NONE)
|| (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL))
{
break;
}
if key.code == KeyCode::Char('n') {
let next = (app.active + 1) % app.scenarios.len();
app.switch(next);
continue;
}
if key.code == KeyCode::Char('p') {
let prev = if app.active == 0 {
app.scenarios.len() - 1
} else {
app.active - 1
};
app.switch(prev);
continue;
}
// Forward to TodoPane (h, j/k, /, …) then refresh status for show_done.
let _ = app.pane.handle_key(&key);
app.refresh_status();
}
stdout().execute(LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
Ok(())
}
fn draw(f: &mut ratatui::Frame, app: &mut App) {
let area = f.area();
let todo_h = app.pane.desired_height(area.height).clamp(3, 12);
let chunks = Layout::vertical([
Constraint::Length(4),
Constraint::Length(todo_h),
Constraint::Length(4),
Constraint::Min(0),
])
.split(area);
let (title, _, _) = &app.scenarios[app.active];
let header = Paragraph::new(vec![
Line::from(Span::styled(
format!(
"todo-pane-playground [{}/{}] {title}",
app.active + 1,
app.scenarios.len(),
),
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from("h: hide/show done n/p: scenario j/k: navigate list Esc/q: quit"),
Line::from(
"Record scenario 1: open → press h → placeholder should show counts, not All done.",
),
])
.block(Block::default().borders(Borders::ALL).title("info"));
f.render_widget(header, chunks[0]);
let todo_area = chunks[1];
if todo_area.height > 0 && todo_area.width > 0 {
let block = Block::default()
.borders(Borders::ALL)
.title("todo pane (Ctrl+T)");
let inner = block.inner(todo_area);
f.render_widget(block, todo_area);
app.pane.render(inner, f.buffer_mut(), true, &app.layout);
}
let status = Paragraph::new(app.status.clone())
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("status"));
f.render_widget(status, chunks[2]);
}