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,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]);
|
||||
}
|
||||
Reference in New Issue
Block a user