Files
kebab/crates/kebab-tui/src/run.rs
altair823 2c10cb7e7a feat(kebab-tui): p9-fb-09 external editor return — terminal restore
Search `g` 키 (citation jump) 후 TUI 화면이 깨지는 버그 수정. 도그푸딩
item 7 — `g` 로 vim 띄우고 `:q` 후 복귀하면 이전 frame 의 잔상이 새
draw 위에 겹쳐 보였음.

## 핵심 변경

- **`kebab-tui::editor::with_external_program(&mut TuiTerminal, Command)`**
  helper 추가. suspend / spawn / restore 시퀀스를 RAII guard 로 atomic
  하게 묶어 panic 발생해도 raw mode + alt screen 복구 보장:
    1. LeaveAlternateScreen + Show cursor + disable_raw_mode
    2. Command::status() 로 child 실행
    3. enable_raw_mode + EnterAlternateScreen + Hide cursor +
       `terminal.clear()` ← 이 한 줄이 핵심 fix
- **`App.pending_editor: Option<EditorRequest>`** 추가. 키 핸들러
  (현재 `kebab-tui::search::handle_key_search` 의 `g`) 가 직접 spawn
  하는 대신 EditorRequest 를 enqueue, 실제 spawn 은 run loop 가
  `TuiTerminal` 핸들 in scope 일 때 처리.
- **`App.force_redraw: bool`** ratchet. with_external_program 종료 후
  set, run loop draw 직전 check → terminal.clear() 후 reset. editor
  외 다른 향후 use case (config reload, theme change 등) 도 같은 hook
  사용 가능.

## 가시성 정리

`with_external_program` / `jump_to_citation` 은 `pub(crate)` 로 좁혀짐
— `TuiTerminal` 자체가 module-private (raw mode + alt screen 의 안전
한 lifecycle 은 `Drop` 만 보장) 이므로 외부 caller 는 `App.pending_
editor` enqueue 패턴으로만 spawn 요청 가능. 외부 surface (`build_jump_
command`, `handle_key_search`, `render_search`) 는 그대로.

## 테스트

- `unspawnable_program_surfaces_program_name_in_error` — helper 의 spawn
  실패 경로 (ENOENT) error context 검증
- `g_key_enqueues_pending_editor_request` — `g` on hit → EditorRequest
  enqueue, citation 정보 보존
- `g_key_with_no_hits_does_not_enqueue` — empty hits → no-op
- 기존 17 개 search 테스트 + 14 lib + 18 ask + 12 inspect + 10 library
  모두 통과
- `cargo clippy -p kebab-tui --all-targets -- -D warnings` clean

## 문서

- README: `kebab tui` 행에 Search `g` 동작 + 자동 redraw 안내
- HANDOFF: 2026-05-03 머지 후 발견 entry
- spec status: `planned` → `in_progress`

후속 task (p9-fb-20 의 citation jump in TUI Ask 등) 가 같은
`pending_editor` queue + `with_external_program` helper 위에 build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 02:32:01 +00:00

304 lines
12 KiB
Rust

//! Run loop — owns the event poll + render cycle. Pane-specific
//! key handlers are dispatched on focus.
use anyhow::Result;
use crossterm::event::{self, Event, KeyEventKind};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use std::time::Duration;
use crate::app::{App, AskState, InspectState, KeyOutcome, Pane, SearchState};
use crate::ask::{drain_stream, handle_key_ask, poll_worker, render_ask};
use crate::error_popup::{ErrorOverlay, render_error_overlay};
use crate::inspect::{handle_key_inspect, refresh_inspect, render_inspect};
use crate::library::{handle_key_library, refresh_docs, render_library};
use crate::search::{
debounce_due, fire_search, handle_key_search, refresh_preview, render_search,
};
use crate::terminal::TuiTerminal;
/// Poll interval for crossterm's `event::poll`. Short enough that a
/// pending data refresh shows up promptly, long enough that an idle
/// app doesn't spin the CPU.
const POLL_INTERVAL: Duration = Duration::from_millis(150);
pub(crate) fn run_loop(app: &mut App) -> Result<()> {
let mut terminal = TuiTerminal::enter()?;
while !app.should_quit {
// p9-fb-03: ingest progress is pane-independent. Drain
// freshly-arrived events every tick + clear the slot a few
// seconds after the run terminated so the user has time to
// read the final line.
crate::ingest_progress::drain_progress(app);
let clear_now = app
.ingest_state
.as_ref()
.map(crate::ingest_progress::ready_to_clear)
.unwrap_or(false);
if clear_now {
if let Some(mut state) = app.ingest_state.take() {
// Reap the worker thread now that the user has seen
// the final status line; ignore the join result —
// `IngestReport` was already mirrored into the status
// bar via `Completed { counts }`.
if let Some(handle) = state.thread.take() {
let _ = handle.join();
}
}
// Library may show stale doc list; queue a refresh so the
// next idle tick picks up the just-ingested rows.
app.library.inner.needs_refresh = true;
}
// Per-pane idle work BEFORE rendering so the frame reflects
// freshly-loaded state.
if app.error_overlay.is_none() {
match app.focus {
Pane::Library => {
if app.library.inner.needs_refresh {
if let Err(e) = refresh_docs(app) {
app.error_overlay = Some(ErrorOverlay::from_anyhow(&e));
}
}
}
Pane::Search => {
let due = app
.search
.as_ref()
.map(debounce_due)
.unwrap_or(false);
if due {
if let Err(e) = fire_search(app) {
app.error_overlay = Some(ErrorOverlay::from_anyhow(&e));
}
}
// Lazy preview fetch when selection lacks one.
let needs_preview = app
.search
.as_ref()
.map(|s| s.preview.is_none() && !s.hits.is_empty())
.unwrap_or(false);
if needs_preview {
if let Err(e) = refresh_preview(app) {
app.error_overlay = Some(ErrorOverlay::from_anyhow(&e));
}
}
}
Pane::Ask => {
// Token stream + worker completion polled every
// tick so the answer area updates without
// blocking the event loop.
drain_stream(app);
poll_worker(app);
}
Pane::Inspect => {
let due = app
.inspect
.as_ref()
.map(|s| s.needs_fetch)
.unwrap_or(false);
if due {
if let Err(e) = refresh_inspect(app) {
app.error_overlay = Some(ErrorOverlay::from_anyhow(&e));
}
}
}
_ => {}
}
}
// p9-fb-09: any code path (editor return, future reset
// helper, …) that toggled `force_redraw` gets a fresh
// framebuffer for this draw — without it, residual content
// from before the suspension would layer through Ratatui's
// diff and produce a corrupted-looking screen.
if app.force_redraw {
terminal.inner.clear()?;
app.force_redraw = false;
}
terminal.inner.draw(|f| render_root(f, app))?;
if event::poll(POLL_INTERVAL)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
let outcome = match app.focus {
Pane::Library => handle_key_library(app, key),
Pane::Search => handle_key_search(app, key),
Pane::Ask => handle_key_ask(app, key),
Pane::Inspect => handle_key_inspect(app, key),
// p9-5 (Jobs) plugs its handler here when it
// lands. Until then, accepts only `q` / `Esc`.
Pane::Jobs => handle_key_unimplemented_pane(app, key),
};
match outcome {
KeyOutcome::Quit => app.should_quit = true,
KeyOutcome::SwitchPane(p) => {
app.focus = p;
// Lazy-init pane state on first switch.
if p == Pane::Search && app.search.is_none() {
app.search = Some(SearchState::default());
}
if p == Pane::Ask && app.ask.is_none() {
app.ask = Some(AskState::default());
}
if p == Pane::Inspect && app.inspect.is_none() {
app.inspect = Some(InspectState::default());
}
}
KeyOutcome::Refresh => {
// Library uses needs_refresh; Search uses
// input_dirty_at — pane-specific. The next
// loop iteration's idle pass services it.
}
KeyOutcome::Continue => {}
}
}
_ => {}
}
}
// p9-fb-09: drain any pending external-program request that
// a key handler enqueued. The actual suspend / spawn /
// restore needs the `TuiTerminal` handle, which is only in
// scope here. After return, `force_redraw` is set so the
// next iteration's draw paints from a clean canvas.
if let Some(req) = app.pending_editor.take() {
let result = crate::search::jump_to_citation(
&mut terminal,
&req.citation,
&req.editor_env,
&req.workspace_root,
);
app.force_redraw = true;
if let Err(e) = result {
app.error_overlay = Some(ErrorOverlay::from_anyhow(&e));
}
}
}
Ok(())
}
/// Stub key handler for panes whose authoring task has not landed
/// yet. `q` / `Esc` returns to Library; everything else is a no-op.
fn handle_key_unimplemented_pane(
app: &mut App,
key: crossterm::event::KeyEvent,
) -> KeyOutcome {
use crossterm::event::KeyCode;
if app.error_overlay.is_some() {
app.error_overlay = None;
return KeyOutcome::Continue;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => KeyOutcome::SwitchPane(Pane::Library),
_ => KeyOutcome::Continue,
}
}
fn render_root(f: &mut Frame, app: &App) {
// p9-fb-03: insert a 1-line status bar above the footer when an
// ingest is in flight (or its terminal line is still on hold).
let has_ingest = app.ingest_state.is_some();
let constraints: Vec<Constraint> = if has_ingest {
vec![
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1), // ingest status bar
Constraint::Length(1), // existing footer hints
]
} else {
vec![
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
]
};
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(f.area());
render_header(f, outer[0], app);
match app.focus {
Pane::Library => render_library(f, outer[1], app),
Pane::Search => render_search(f, outer[1], app),
Pane::Ask => render_ask(f, outer[1], app),
Pane::Inspect => render_inspect(f, outer[1], app),
// p9-5 Jobs not yet rendered; Library placeholder.
Pane::Jobs => render_library(f, outer[1], app),
}
if has_ingest {
render_ingest_status(f, outer[2], app);
render_footer(f, outer[3], app);
} else {
render_footer(f, outer[2], app);
}
if let Some(err) = &app.error_overlay {
render_error_overlay(f, f.area(), err);
}
}
fn render_ingest_status(f: &mut Frame, area: Rect, app: &App) {
let Some(state) = app.ingest_state.as_ref() else {
return;
};
let line = crate::ingest_progress::status_line(state);
let style = if state.aborted {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(line, style))),
area,
);
}
fn render_header(f: &mut Frame, area: Rect, app: &App) {
let pane_label = match app.focus {
Pane::Library => "Library",
Pane::Search => "Search",
Pane::Ask => "Ask",
Pane::Inspect => "Inspect",
Pane::Jobs => "Jobs",
};
let line = Line::from(vec![
Span::styled(
"kebab",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(" / "),
Span::raw(pane_label),
]);
f.render_widget(Paragraph::new(line), area);
}
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
let hints = match app.focus {
Pane::Library => {
if app.library.inner.filter_edit.is_some() {
"Tab=field Enter=apply Esc=cancel"
} else {
"j/k=move gg=top G=bottom f=filter /=search ?=ask Enter=inspect r=ingest q=quit"
}
}
Pane::Search => "type=query Tab=mode Enter=search j/k=move g=open in $EDITOR Esc=back",
Pane::Ask => "type=question Enter=submit e=explain (when input empty) j/k=scroll (when input empty) Esc=back",
Pane::Inspect => "j/k=scroll PgUp/PgDn=page scroll c=collapse/expand sections Esc/q=back",
Pane::Jobs => "Jobs pane not yet implemented — q to return",
};
let line = Line::from(Span::styled(
hints,
Style::default().add_modifier(Modifier::DIM),
));
f.render_widget(
Paragraph::new(line).block(Block::default().borders(Borders::TOP)),
area,
);
}