Skip to main content

kranz_cli/
planning_tui.rs

1//! Full-screen interactive planning TUI (`kranz plan` on a real terminal).
2//!
3//! Supersedes the line-mode REPL for interactive use: a ratatui alternate-
4//! screen app with a scrollback transcript, a live activity feed (tailed from
5//! `events.jsonl`), a color-coded status line, and an always-editable input
6//! line. The problems it exists to fix (all observed live with the REPL):
7//! arrow keys printed `^[[A`, `[orch]` activity lines trampled the `you>`
8//! prompt, a running turn was indistinguishable from a hang, nothing
9//! acknowledged accepted input, and type-ahead silently answered later
10//! prompts.
11//!
12//! ## Interactivity contract
13//!
14//! The input line is ALWAYS editable, including while an orchestrator turn
15//! runs. Submitting while busy echoes the message immediately (tagged
16//! `(queued)`) and sends it as the next turn when the current one finishes —
17//! never discarded, never leaking into an unrelated prompt. `/plan` renders
18//! the plan + cost estimate and enters a single-key approval mode. A
19//! successful approval commits the plan, then enters a second single-key
20//! prompt — start execution now, or exit — because approving the plan and
21//! starting the spend are separate consent steps ([`PlanningOutcome`]).
22//!
23//! ## Terminal safety
24//!
25//! [`TerminalGuard`] is an RAII guard restoring cooked mode + the main screen
26//! on every exit path, and [`install_panic_hook`] restores the terminal
27//! BEFORE the default panic hook prints — a panicked TUI must never leave the
28//! shell raw. Restoration is idempotent (guarded by [`TUI_ACTIVE`]).
29//!
30//! ## Async wiring
31//!
32//! [`MissionEngine`] is not shareable across tasks, so the engine is moved
33//! *into* the in-flight turn future and handed back with the result
34//! ([`Phase`]). The loop `select!`s between key events (a dedicated reader
35//! thread feeding a channel), a 250ms tick (activity polling via
36//! [`EventLog::read_events_after`] + spinner), and the in-flight turn future.
37//!
38//! Everything that can be tested headless is a pure, engine-free piece:
39//! [`InputEditor`], [`PendingQueue`], [`ScrollState`], [`classify_submission`],
40//! [`approval_key`], [`post_approval_key`], [`busy_status_line`],
41//! [`wrap_text`].
42
43use crate::commands::augment_limit_hint;
44use crate::output::{self, one_line};
45use crate::tail::EventRenderer;
46use anyhow::{Context, Result};
47use crossterm::event::{
48    self as ct_event, DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEventKind, KeyModifiers,
49    MouseEventKind,
50};
51use crossterm::execute;
52use crossterm::terminal::{
53    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
54};
55use kranz_engine::cost;
56use kranz_engine::error::EngineError;
57use kranz_engine::event_log::EventLog;
58use kranz_engine::orchestrator::{MissionEngine, PlanRequest};
59use kranz_engine::types::Plan;
60use ratatui::backend::CrosstermBackend;
61use ratatui::layout::{Constraint, Layout, Position, Rect};
62use ratatui::style::{Color, Modifier, Style};
63use ratatui::text::{Line, Span};
64use ratatui::widgets::Paragraph;
65use ratatui::{Frame, Terminal};
66use std::collections::VecDeque;
67use std::future::Future;
68use std::io::Stdout;
69use std::path::PathBuf;
70use std::pin::Pin;
71use std::sync::atomic::{AtomicBool, Ordering};
72use std::sync::Arc;
73use std::time::{Duration, Instant};
74use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
75
76/// Tick driving the spinner and the activity poll.
77const TICK: Duration = Duration::from_millis(250);
78
79/// Input reader poll slice; also bounds shutdown latency of the reader thread.
80const INPUT_POLL: Duration = Duration::from_millis(100);
81
82/// Mouse wheel scroll step (visual lines).
83const WHEEL_LINES: usize = 3;
84
85// ===========================================================================
86// Pure, unit-testable pieces
87// ===========================================================================
88
89// ---------------------------------------------------------------------------
90// InputEditor — the single-line editor state machine
91// ---------------------------------------------------------------------------
92
93/// Editable input buffer with cursor movement and session history.
94///
95/// History navigation (up/down) is only available while the buffer is
96/// *untouched* — empty, or holding an unedited history recall. The first
97/// edit locks navigation until the buffer is submitted or emptied again, so
98/// half-typed messages are never clobbered by an arrow key.
99#[derive(Debug, Default)]
100pub struct InputEditor {
101    buf: Vec<char>,
102    cursor: usize,
103    history: Vec<String>,
104    nav: Option<usize>,
105    edited: bool,
106}
107
108impl InputEditor {
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    /// Current buffer contents.
114    pub fn text(&self) -> String {
115        self.buf.iter().collect()
116    }
117
118    /// Cursor position in chars (0 ..= len).
119    pub fn cursor(&self) -> usize {
120        self.cursor
121    }
122
123    pub fn is_empty(&self) -> bool {
124        self.buf.is_empty()
125    }
126
127    /// True while history navigation is locked out by an edit.
128    pub fn is_edited(&self) -> bool {
129        self.edited
130    }
131
132    pub fn insert(&mut self, c: char) {
133        self.buf.insert(self.cursor, c);
134        self.cursor += 1;
135        self.touch();
136    }
137
138    pub fn backspace(&mut self) {
139        if self.cursor > 0 {
140            self.cursor -= 1;
141            self.buf.remove(self.cursor);
142            self.touch();
143        }
144    }
145
146    /// Delete the char under the cursor (forward delete).
147    pub fn delete(&mut self) {
148        if self.cursor < self.buf.len() {
149            self.buf.remove(self.cursor);
150            self.touch();
151        }
152    }
153
154    /// Ctrl-U: clear the whole line.
155    pub fn clear_line(&mut self) {
156        self.buf.clear();
157        self.cursor = 0;
158        self.touch();
159    }
160
161    pub fn left(&mut self) {
162        self.cursor = self.cursor.saturating_sub(1);
163    }
164
165    pub fn right(&mut self) {
166        if self.cursor < self.buf.len() {
167            self.cursor += 1;
168        }
169    }
170
171    pub fn home(&mut self) {
172        self.cursor = 0;
173    }
174
175    pub fn end(&mut self) {
176        self.cursor = self.buf.len();
177    }
178
179    /// Alt+Left: to the start of the previous word.
180    pub fn word_left(&mut self) {
181        while self.cursor > 0 && self.buf[self.cursor - 1].is_whitespace() {
182            self.cursor -= 1;
183        }
184        while self.cursor > 0 && !self.buf[self.cursor - 1].is_whitespace() {
185            self.cursor -= 1;
186        }
187    }
188
189    /// Alt+Right: past the end of the next word.
190    pub fn word_right(&mut self) {
191        let n = self.buf.len();
192        while self.cursor < n && self.buf[self.cursor].is_whitespace() {
193            self.cursor += 1;
194        }
195        while self.cursor < n && !self.buf[self.cursor].is_whitespace() {
196            self.cursor += 1;
197        }
198    }
199
200    /// Up arrow: recall the previous history entry (untouched buffers only).
201    pub fn history_up(&mut self) {
202        if self.edited || self.history.is_empty() {
203            return;
204        }
205        let next = match self.nav {
206            None => self.history.len() - 1,
207            Some(0) => 0,
208            Some(i) => i - 1,
209        };
210        self.recall(next);
211    }
212
213    /// Down arrow: towards newer entries; past the newest clears the buffer.
214    pub fn history_down(&mut self) {
215        if self.edited {
216            return;
217        }
218        match self.nav {
219            None => {}
220            Some(i) if i + 1 < self.history.len() => self.recall(i + 1),
221            Some(_) => {
222                self.nav = None;
223                self.buf.clear();
224                self.cursor = 0;
225            }
226        }
227    }
228
229    /// Enter: take the trimmed buffer (recorded in history when non-empty)
230    /// and reset the editor. `None` for a blank line.
231    pub fn submit(&mut self) -> Option<String> {
232        let text = self.text().trim().to_string();
233        self.buf.clear();
234        self.cursor = 0;
235        self.nav = None;
236        self.edited = false;
237        if text.is_empty() {
238            return None;
239        }
240        if self.history.last() != Some(&text) {
241            self.history.push(text.clone());
242        }
243        Some(text)
244    }
245
246    fn recall(&mut self, index: usize) {
247        self.nav = Some(index);
248        self.buf = self.history[index].chars().collect();
249        self.cursor = self.buf.len();
250    }
251
252    /// Any text mutation locks history navigation — except that an emptied
253    /// buffer counts as untouched again (nothing left to clobber).
254    fn touch(&mut self) {
255        self.nav = None;
256        self.edited = !self.buf.is_empty();
257    }
258}
259
260// ---------------------------------------------------------------------------
261// PendingQueue — submit-while-busy semantics
262// ---------------------------------------------------------------------------
263
264/// FIFO of messages submitted while a turn was in flight. Each is echoed in
265/// the transcript at submit time (tagged `(queued)`) and dispatched, oldest
266/// first, whenever the engine returns to idle.
267#[derive(Debug, Default)]
268pub struct PendingQueue {
269    items: VecDeque<String>,
270}
271
272impl PendingQueue {
273    pub fn new() -> Self {
274        Self::default()
275    }
276
277    pub fn push(&mut self, line: String) {
278        self.items.push_back(line);
279    }
280
281    pub fn pop(&mut self) -> Option<String> {
282        self.items.pop_front()
283    }
284
285    pub fn depth(&self) -> usize {
286        self.items.len()
287    }
288
289    pub fn is_empty(&self) -> bool {
290        self.items.is_empty()
291    }
292}
293
294// ---------------------------------------------------------------------------
295// Submission routing
296// ---------------------------------------------------------------------------
297
298/// Where a submitted line goes given the current engine business.
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub enum SubmitDisposition {
301    /// Exit planning — acts immediately, even while a turn runs.
302    Quit,
303    /// Run `request_plan` now (engine idle).
304    RequestPlan,
305    /// Run a conversational turn now (engine idle).
306    Turn(String),
307    /// Engine busy: echo as `(queued)` and dispatch when idle.
308    Queued(String),
309    /// Unknown slash command — notice only, never queued.
310    Unknown(String),
311}
312
313/// Route one submitted line. `/quit` always acts immediately; unknown
314/// commands are rejected immediately; `/plan` and plain text queue while
315/// `busy` and run otherwise.
316pub fn classify_submission(line: &str, busy: bool) -> SubmitDisposition {
317    let line = line.trim();
318    if line == "/quit" {
319        return SubmitDisposition::Quit;
320    }
321    if line.starts_with('/') && line != "/plan" {
322        return SubmitDisposition::Unknown(line.to_string());
323    }
324    if busy {
325        return SubmitDisposition::Queued(line.to_string());
326    }
327    if line == "/plan" {
328        SubmitDisposition::RequestPlan
329    } else {
330        SubmitDisposition::Turn(line.to_string())
331    }
332}
333
334// ---------------------------------------------------------------------------
335// ScrollState — transcript auto-follow vs detached scrollback
336// ---------------------------------------------------------------------------
337
338/// Transcript scroll bookkeeping over *visual* (wrapped) lines.
339///
340/// Follows the bottom by default (new output stays in view). Scrolling up
341/// detaches at a fixed top line; scrolling back to the bottom (or End)
342/// re-attaches.
343#[derive(Debug)]
344pub struct ScrollState {
345    follow: bool,
346    top: usize,
347}
348
349impl Default for ScrollState {
350    fn default() -> Self {
351        ScrollState {
352            follow: true,
353            top: 0,
354        }
355    }
356}
357
358impl ScrollState {
359    pub fn new() -> Self {
360        Self::default()
361    }
362
363    pub fn is_detached(&self) -> bool {
364        !self.follow
365    }
366
367    /// Index of the first visible visual line for a viewport of `height`
368    /// over `total` lines.
369    pub fn top(&self, total: usize, height: usize) -> usize {
370        let max_top = total.saturating_sub(height);
371        if self.follow {
372            max_top
373        } else {
374            self.top.min(max_top)
375        }
376    }
377
378    pub fn scroll_up(&mut self, n: usize, total: usize, height: usize) {
379        if total <= height {
380            self.follow = true; // nothing to scroll: stay attached
381            return;
382        }
383        self.top = self.top(total, height).saturating_sub(n);
384        self.follow = false;
385    }
386
387    pub fn scroll_down(&mut self, n: usize, total: usize, height: usize) {
388        let max_top = total.saturating_sub(height);
389        let new_top = self.top(total, height).saturating_add(n).min(max_top);
390        self.top = new_top;
391        self.follow = new_top >= max_top;
392    }
393
394    /// End: jump to the bottom and re-attach.
395    pub fn to_follow(&mut self) {
396        self.follow = true;
397    }
398}
399
400// ---------------------------------------------------------------------------
401// Approval-mode key filtering
402// ---------------------------------------------------------------------------
403
404/// Outcome of one keypress in approval mode.
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum ApprovalKey {
407    Approve,
408    Reject,
409    Ignore,
410}
411
412/// Single-key approval filter: `y`/`Y` approves, `n`/`N` goes back to the
413/// conversation, everything else is ignored.
414pub fn approval_key(code: KeyCode) -> ApprovalKey {
415    match code {
416        KeyCode::Char('y') | KeyCode::Char('Y') => ApprovalKey::Approve,
417        KeyCode::Char('n') | KeyCode::Char('N') => ApprovalKey::Reject,
418        _ => ApprovalKey::Ignore,
419    }
420}
421
422// ---------------------------------------------------------------------------
423// Post-approval mode (plan committed — start execution now?)
424// ---------------------------------------------------------------------------
425
426/// How planning ended. Returned by [`run`] after the TUI has torn down, so
427/// the caller can chain straight into execution instead of telling the user
428/// to quit and type `kranz run`.
429#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub enum PlanningOutcome {
431    /// Plan approved + committed; the user chose to start execution now.
432    ApprovedRun,
433    /// Plan approved + committed; the user exits (execute via `kranz run`).
434    ApprovedExit,
435    /// Planning ended without an approved plan.
436    NotApproved,
437}
438
439/// Single-key filter for the post-approval "start execution now?" prompt:
440/// `y`/`Y` starts the run, `n`/`N` exits, everything else is ignored —
441/// starting spend must be an explicit keypress, never type-ahead.
442pub fn post_approval_key(code: KeyCode) -> Option<PlanningOutcome> {
443    match code {
444        KeyCode::Char('y') | KeyCode::Char('Y') => Some(PlanningOutcome::ApprovedRun),
445        KeyCode::Char('n') | KeyCode::Char('N') => Some(PlanningOutcome::ApprovedExit),
446        _ => None,
447    }
448}
449
450// ---------------------------------------------------------------------------
451// Status line formatting
452// ---------------------------------------------------------------------------
453
454/// What the engine is busy doing (mirrors the in-flight future's kind).
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum BusyKind {
457    Turn,
458    PlanRequest,
459}
460
461/// Spinner frames for the busy status line.
462pub const SPINNER_FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
463
464/// Idle status line.
465pub const IDLE_STATUS: &str = "● ready — type a message; /plan to request the plan; /quit to exit";
466
467/// Approval-mode bar (replaces the status line).
468pub const APPROVAL_BAR: &str = "approve this plan? [y] approve & commit   [n] back to conversation";
469
470/// Post-approval bar: the plan is committed; starting execution (spend) is
471/// its own explicit consent step.
472pub const POST_APPROVAL_BAR: &str = "plan committed — start execution now? [y] run   [n] exit";
473
474/// Detached-scroll marker shown on the transcript's bottom row.
475pub const DETACHED_MARKER: &str = "▼ new output below — End to follow";
476
477/// Notice shown when `/plan` resolves to [`PlanRequest::NotReady`]: the
478/// orchestrator wants answers before emitting — a normal conversational
479/// state, rendered as a plain notice (no error styling, no approval mode).
480pub const PLAN_NOT_READY_NOTICE: &str = "not ready to emit — answer above, then /plan again";
481
482/// Notice shown when `/plan` resolves to [`PlanRequest::WrongPlan`]: the
483/// planner CAN plan but judges the plan likely wrong — a planner-initiated
484/// escalation, rendered like the not-ready notice (no error styling, no
485/// approval mode).
486pub const PLAN_WRONG_PLAN_NOTICE: &str =
487    "planner escalated: the plan is likely wrong — reframe the goal above, then /plan again";
488
489/// Busy status line: spinner frame + activity + elapsed + queue depth.
490pub fn busy_status_line(
491    kind: BusyKind,
492    elapsed_secs: u64,
493    spinner_frame: usize,
494    queued: usize,
495) -> String {
496    let frame = SPINNER_FRAMES[spinner_frame % SPINNER_FRAMES.len()];
497    let doing = match kind {
498        BusyKind::Turn => "orchestrator working…",
499        BusyKind::PlanRequest => "requesting plan…",
500    };
501    let queue = match queued {
502        0 => String::new(),
503        1 => " — 1 message queued, sends when this turn finishes".to_string(),
504        n => format!(" — {n} messages queued, send in order when this turn finishes"),
505    };
506    format!("{frame} {doing} {elapsed_secs}s — typing is safe, Enter queues your message{queue}")
507}
508
509// ---------------------------------------------------------------------------
510// Word wrapping (char-based; the transcript is ASCII-dominant)
511// ---------------------------------------------------------------------------
512
513/// Greedy word wrap to `width` chars per line, preferring space breaks and
514/// hard-breaking longer-than-width words. Preserves explicit newlines.
515pub fn wrap_text(text: &str, width: usize) -> Vec<String> {
516    if width == 0 {
517        return text.split('\n').map(str::to_string).collect();
518    }
519    let mut out = Vec::new();
520    for raw in text.split('\n') {
521        let chars: Vec<char> = raw.chars().collect();
522        if chars.is_empty() {
523            out.push(String::new());
524            continue;
525        }
526        let mut start = 0;
527        while start < chars.len() {
528            let hard_end = (start + width).min(chars.len());
529            let end = if hard_end < chars.len() {
530                match chars[start..hard_end].iter().rposition(|c| *c == ' ') {
531                    Some(p) if p > 0 => start + p,
532                    _ => hard_end,
533                }
534            } else {
535                hard_end
536            };
537            let line: String = chars[start..end].iter().collect();
538            out.push(line.trim_end().to_string());
539            start = end;
540            while start < chars.len() && chars[start] == ' ' {
541                start += 1;
542            }
543        }
544    }
545    out
546}
547
548// ---------------------------------------------------------------------------
549// Transcript
550// ---------------------------------------------------------------------------
551
552/// One semantic transcript entry (styling and wrapping happen at render).
553#[derive(Debug, Clone, PartialEq, Eq)]
554pub enum TranscriptEntry {
555    /// A user message, echoed at submit time; `queued` while a turn ran.
556    User { text: String, queued: bool },
557    /// A full orchestrator reply (possibly multi-line).
558    Orch(String),
559    /// One activity line from the event tail (tool use, results, denials).
560    Activity(String),
561    /// Preformatted block: plan render, cost estimate.
562    Block(String),
563    /// Informational notice.
564    Notice(String),
565    /// Error line.
566    Error(String),
567}
568
569/// Render one entry into styled visual lines of at most `width` chars.
570fn entry_lines(entry: &TranscriptEntry, width: usize, out: &mut Vec<Line<'static>>) {
571    let dim = Style::new().fg(Color::DarkGray);
572    match entry {
573        TranscriptEntry::User { text, queued } => {
574            let prefix = "you> ";
575            let body = if *queued {
576                format!("{text} (queued)")
577            } else {
578                text.clone()
579            };
580            let body_width = width.saturating_sub(prefix.len()).max(1);
581            for (i, line) in wrap_text(&body, body_width).into_iter().enumerate() {
582                let lead = if i == 0 {
583                    prefix.to_string()
584                } else {
585                    " ".repeat(prefix.len())
586                };
587                out.push(Line::from(vec![
588                    Span::styled(
589                        lead,
590                        Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
591                    ),
592                    Span::styled(line, Style::new().add_modifier(Modifier::BOLD)),
593                ]));
594            }
595        }
596        TranscriptEntry::Orch(text) => {
597            let prefix = "orch> ";
598            let body_width = width.saturating_sub(prefix.len()).max(1);
599            for (i, line) in wrap_text(text, body_width).into_iter().enumerate() {
600                let lead = if i == 0 {
601                    prefix.to_string()
602                } else {
603                    " ".repeat(prefix.len())
604                };
605                out.push(Line::from(vec![Span::styled(lead, dim), Span::raw(line)]));
606            }
607        }
608        TranscriptEntry::Activity(text) => {
609            for (i, line) in wrap_text(text, width.saturating_sub(2).max(1))
610                .into_iter()
611                .enumerate()
612            {
613                let lead = if i == 0 { "" } else { "  " };
614                out.push(Line::from(Span::styled(format!("{lead}{line}"), dim)));
615            }
616        }
617        TranscriptEntry::Block(text) => {
618            for line in wrap_text(text, width.max(1)) {
619                out.push(Line::from(Span::raw(line)));
620            }
621        }
622        TranscriptEntry::Notice(text) => {
623            for line in wrap_text(text, width.max(1)) {
624                out.push(Line::from(Span::styled(line, dim)));
625            }
626        }
627        TranscriptEntry::Error(text) => {
628            for line in wrap_text(text, width.max(1)) {
629                out.push(Line::from(Span::styled(line, Style::new().fg(Color::Red))));
630            }
631        }
632    }
633}
634
635/// Total visual lines of a transcript at `width` (scroll math helper).
636fn total_visual_lines(entries: &[TranscriptEntry], width: usize) -> usize {
637    let mut lines = Vec::new();
638    for entry in entries {
639        entry_lines(entry, width, &mut lines);
640    }
641    lines.len()
642}
643
644// ===========================================================================
645// App state (owns the pure pieces; no engine, no futures)
646// ===========================================================================
647
648struct App {
649    transcript: Vec<TranscriptEntry>,
650    editor: InputEditor,
651    queue: PendingQueue,
652    scroll: ScrollState,
653    /// Last turn error, shown red on the idle status line until the next key.
654    error: Option<String>,
655    spinner: usize,
656}
657
658impl App {
659    fn new() -> Self {
660        App {
661            transcript: Vec::new(),
662            editor: InputEditor::new(),
663            queue: PendingQueue::new(),
664            scroll: ScrollState::new(),
665            error: None,
666            spinner: 0,
667        }
668    }
669
670    fn push(&mut self, entry: TranscriptEntry) {
671        self.transcript.push(entry);
672    }
673}
674
675// ===========================================================================
676// Phase — engine ownership through the async loop
677// ===========================================================================
678
679/// A turn future owns the engine and hands it back with the result, so the
680/// borrow checker never sees the engine borrowed across loop iterations.
681type TurnFut = Pin<Box<dyn Future<Output = (Box<MissionEngine>, TurnOutput)>>>;
682
683enum TurnOutput {
684    Reply(std::result::Result<String, EngineError>),
685    Plan(std::result::Result<PlanRequest, EngineError>),
686}
687
688enum Phase {
689    /// Engine idle, conversation mode.
690    Idle(Box<MissionEngine>),
691    /// A turn or plan request in flight (engine inside the future).
692    Busy {
693        kind: BusyKind,
694        started: Instant,
695        fut: TurnFut,
696    },
697    /// Plan rendered; waiting for a single y/n key.
698    Approval {
699        engine: Box<MissionEngine>,
700        plan: Plan,
701    },
702    /// Plan committed; waiting for the single-key run-now/exit choice. The
703    /// engine is never used again but must stay alive until teardown (it
704    /// flushes buffered events and releases the mission lock on drop).
705    PostApproval { _engine: Box<MissionEngine> },
706    /// Transient placeholder during phase swaps; never observed by draw.
707    Transitioning,
708}
709
710/// Copyable view of the phase for rendering.
711enum PhaseView {
712    Idle,
713    Busy { kind: BusyKind, elapsed_secs: u64 },
714    Approval,
715    PostApproval,
716}
717
718impl PhaseView {
719    fn of(phase: &Phase) -> Self {
720        match phase {
721            Phase::Busy { kind, started, .. } => PhaseView::Busy {
722                kind: *kind,
723                elapsed_secs: started.elapsed().as_secs(),
724            },
725            Phase::Approval { .. } => PhaseView::Approval,
726            Phase::PostApproval { .. } => PhaseView::PostApproval,
727            Phase::Idle(_) | Phase::Transitioning => PhaseView::Idle,
728        }
729    }
730}
731
732fn start_turn(mut engine: Box<MissionEngine>, text: String) -> Phase {
733    Phase::Busy {
734        kind: BusyKind::Turn,
735        started: Instant::now(),
736        fut: Box::pin(async move {
737            let out = engine.planning_turn(&text).await;
738            (engine, TurnOutput::Reply(out))
739        }),
740    }
741}
742
743fn start_plan_request(mut engine: Box<MissionEngine>) -> Phase {
744    Phase::Busy {
745        kind: BusyKind::PlanRequest,
746        started: Instant::now(),
747        fut: Box::pin(async move {
748            let out = engine.request_plan().await;
749            (engine, TurnOutput::Plan(out))
750        }),
751    }
752}
753
754// ===========================================================================
755// Terminal safety
756// ===========================================================================
757
758/// Set while the alternate screen + raw mode are active, so restoration is
759/// idempotent and the panic hook only fires teardown when it matters.
760static TUI_ACTIVE: AtomicBool = AtomicBool::new(false);
761
762/// Restore cooked mode + main screen (idempotent; safe from any thread).
763fn restore_terminal() {
764    if TUI_ACTIVE.swap(false, Ordering::SeqCst) {
765        let _ = disable_raw_mode();
766        let _ = execute!(
767            std::io::stdout(),
768            DisableMouseCapture,
769            LeaveAlternateScreen,
770            crossterm::cursor::Show
771        );
772    }
773}
774
775/// RAII: terminal restored when dropped, on every path out of [`run`].
776struct TerminalGuard;
777
778impl Drop for TerminalGuard {
779    fn drop(&mut self) {
780        restore_terminal();
781    }
782}
783
784fn enter_terminal() -> Result<TerminalGuard> {
785    enable_raw_mode().context("enabling raw mode")?;
786    TUI_ACTIVE.store(true, Ordering::SeqCst);
787    if let Err(e) = execute!(std::io::stdout(), EnterAlternateScreen, EnableMouseCapture) {
788        restore_terminal();
789        return Err(anyhow::Error::new(e).context("entering the alternate screen"));
790    }
791    Ok(TerminalGuard)
792}
793
794/// Chain a terminal-restoring panic hook in front of the default one, so a
795/// panicking TUI prints its message on a sane screen instead of a raw one.
796/// Installed once per process; a no-op while the TUI is not active.
797fn install_panic_hook() {
798    static HOOK: std::sync::Once = std::sync::Once::new();
799    HOOK.call_once(|| {
800        let previous = std::panic::take_hook();
801        std::panic::set_hook(Box::new(move |info| {
802            restore_terminal();
803            previous(info);
804        }));
805    });
806}
807
808// ===========================================================================
809// Input reader thread
810// ===========================================================================
811
812/// Crossterm event reader on a dedicated thread (crossterm's blocking API;
813/// the workspace crossterm has no `event-stream` feature). Bounded shutdown:
814/// the thread re-checks `stop` every [`INPUT_POLL`].
815struct InputThread {
816    stop: Arc<AtomicBool>,
817    handle: Option<std::thread::JoinHandle<()>>,
818}
819
820impl InputThread {
821    fn spawn() -> (Self, UnboundedReceiver<ct_event::Event>) {
822        let stop = Arc::new(AtomicBool::new(false));
823        let flag = Arc::clone(&stop);
824        let (tx, rx): (UnboundedSender<ct_event::Event>, _) =
825            tokio::sync::mpsc::unbounded_channel();
826        let handle = std::thread::spawn(move || {
827            while !flag.load(Ordering::Relaxed) {
828                match ct_event::poll(INPUT_POLL) {
829                    Ok(true) => match ct_event::read() {
830                        Ok(event) => {
831                            if tx.send(event).is_err() {
832                                return;
833                            }
834                        }
835                        Err(_) => return,
836                    },
837                    Ok(false) => {}
838                    Err(_) => return,
839                }
840            }
841        });
842        (
843            InputThread {
844                stop,
845                handle: Some(handle),
846            },
847            rx,
848        )
849    }
850
851    /// Stop the thread and wait for it (≤ [`INPUT_POLL`]) so it can never
852    /// swallow keystrokes meant for the restored shell.
853    async fn shutdown(mut self) {
854        self.stop.store(true, Ordering::Relaxed);
855        if let Some(handle) = self.handle.take() {
856            let _ = tokio::task::spawn_blocking(move || {
857                let _ = handle.join();
858            })
859            .await;
860        }
861    }
862}
863
864// ===========================================================================
865// The TUI loop
866// ===========================================================================
867
868type Tui = Terminal<CrosstermBackend<Stdout>>;
869
870enum LoopEvent {
871    Term(ct_event::Event),
872    Tick,
873    Done(Box<MissionEngine>, TurnOutput),
874    InputClosed,
875}
876
877struct TuiRun {
878    app: App,
879    phase: Phase,
880    renderer: EventRenderer,
881    events_path: PathBuf,
882    last_seq: u64,
883    title: String,
884    /// (width, height) of the transcript pane as of the last draw.
885    dims: (u16, u16),
886    quit: bool,
887    /// Set on successful approval: the mission branch for the exit message.
888    approved_branch: Option<String>,
889    /// Set in post-approval mode when the user picks `y`: start execution
890    /// immediately after teardown instead of exiting.
891    run_now: bool,
892}
893
894/// Run the full-screen planning TUI to completion. Owns the whole
895/// interaction: conversation turns, live activity, `/plan` + approval, the
896/// post-approval "start execution now?" prompt, and the exit hints printed
897/// AFTER the terminal is restored. Returns how planning ended — on
898/// [`PlanningOutcome::ApprovedRun`] the caller starts execution on a fully
899/// restored terminal (engine dropped, mission lock released).
900pub async fn run(engine: MissionEngine, intro: String) -> Result<PlanningOutcome> {
901    let mission_id = engine.mission_id().to_string();
902    let title = format!(
903        "KRANZ PLANNING — {} — {}",
904        mission_id,
905        engine.state().mission.goal
906    );
907    let events_path = engine.paths().events_file();
908    let last_seq = engine.state().last_seq;
909    // Color off: activity lines are styled by the TUI, not by ANSI codes.
910    let renderer = EventRenderer::planning(engine.state(), false);
911
912    install_panic_hook();
913    let guard = enter_terminal()?;
914    let terminal_result = Terminal::new(CrosstermBackend::new(std::io::stdout()));
915    let mut terminal = match terminal_result {
916        Ok(t) => t,
917        Err(e) => {
918            drop(guard);
919            return Err(anyhow::Error::new(e).context("initializing the terminal"));
920        }
921    };
922    let (input_thread, mut keys) = InputThread::spawn();
923
924    let mut app = App::new();
925    app.push(TranscriptEntry::Notice(intro));
926
927    let mut state = TuiRun {
928        app,
929        phase: Phase::Idle(Box::new(engine)),
930        renderer,
931        events_path,
932        last_seq,
933        title,
934        dims: (80, 20),
935        quit: false,
936        approved_branch: None,
937        run_now: false,
938    };
939
940    let loop_result = state.run_loop(&mut terminal, &mut keys).await;
941
942    // Teardown order matters: drop the in-flight/idle engine first (flushes
943    // buffered events, kills any live session, releases the lock), then
944    // restore the terminal, then reap the input thread — and only then print
945    // the exit hints onto the restored main screen. A run-now choice starts
946    // execution only after all of this: the mission lock is free again and
947    // the live event feed prints onto the main screen, never the TUI's.
948    let approved_branch = state.approved_branch.take();
949    let run_now = state.run_now;
950    let leftover: Vec<String> = std::iter::from_fn(|| state.app.queue.pop()).collect();
951    drop(state); // drops Phase (and the engine, wherever it lives)
952    drop(terminal);
953    drop(guard);
954    input_thread.shutdown().await;
955
956    loop_result?;
957    let outcome = match (&approved_branch, run_now) {
958        (None, _) => PlanningOutcome::NotApproved,
959        (Some(_), true) => PlanningOutcome::ApprovedRun,
960        (Some(_), false) => PlanningOutcome::ApprovedExit,
961    };
962    match &approved_branch {
963        Some(branch) if run_now => {
964            println!("plan approved and committed on {branch}.");
965        }
966        Some(branch) => {
967            println!("plan approved and committed on {branch}. run 'kranz run' to execute.");
968        }
969        None => {
970            println!(
971                "leaving planning; mission {mission_id} was not approved. \
972                 Resume anytime with `kranz plan`."
973            );
974        }
975    }
976    // Queued-but-unsent messages must never vanish silently (the queue's
977    // whole contract): planning ended before their turn came, so hand them
978    // back to the user.
979    if !leftover.is_empty() {
980        println!(
981            "note: {} queued message(s) were never sent (planning ended first):",
982            leftover.len()
983        );
984        for message in &leftover {
985            println!("  - {message}");
986        }
987    }
988    Ok(outcome)
989}
990
991impl TuiRun {
992    async fn run_loop(
993        &mut self,
994        terminal: &mut Tui,
995        keys: &mut UnboundedReceiver<ct_event::Event>,
996    ) -> Result<()> {
997        let mut tick = tokio::time::interval(TICK);
998        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
999        loop {
1000            let view = PhaseView::of(&self.phase);
1001            terminal
1002                .draw(|frame| self.dims = draw_ui(frame, &self.app, &view, &self.title))
1003                .context("drawing the planning TUI")?;
1004
1005            let event = match &mut self.phase {
1006                Phase::Busy { fut, .. } => tokio::select! {
1007                    maybe = keys.recv() => match maybe {
1008                        Some(e) => LoopEvent::Term(e),
1009                        None => LoopEvent::InputClosed,
1010                    },
1011                    _ = tick.tick() => LoopEvent::Tick,
1012                    (engine, out) = fut.as_mut() => LoopEvent::Done(engine, out),
1013                },
1014                _ => tokio::select! {
1015                    maybe = keys.recv() => match maybe {
1016                        Some(e) => LoopEvent::Term(e),
1017                        None => LoopEvent::InputClosed,
1018                    },
1019                    _ = tick.tick() => LoopEvent::Tick,
1020                },
1021            };
1022
1023            match event {
1024                LoopEvent::InputClosed => self.quit = true,
1025                LoopEvent::Tick => self.on_tick(),
1026                LoopEvent::Term(e) => self.on_term_event(e),
1027                LoopEvent::Done(engine, out) => self.on_turn_done(engine, out),
1028            }
1029
1030            if self.quit {
1031                return Ok(());
1032            }
1033        }
1034    }
1035
1036    /// 250ms tick: advance the spinner and pull new activity lines from the
1037    /// event log (read errors are transient — the engine may be mid-write).
1038    fn on_tick(&mut self) {
1039        self.app.spinner = self.app.spinner.wrapping_add(1);
1040        if let Ok(events) = EventLog::read_events_after(&self.events_path, self.last_seq) {
1041            for event in &events {
1042                self.last_seq = event.seq;
1043                let line = self.renderer.render(event);
1044                if !line.is_empty() {
1045                    self.app.push(TranscriptEntry::Activity(line));
1046                }
1047            }
1048        }
1049    }
1050
1051    fn on_term_event(&mut self, event: ct_event::Event) {
1052        match event {
1053            ct_event::Event::Key(key)
1054                if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) =>
1055            {
1056                self.on_key(key.code, key.modifiers);
1057            }
1058            ct_event::Event::Mouse(mouse) => match mouse.kind {
1059                MouseEventKind::ScrollUp => self.scroll_up(WHEEL_LINES),
1060                MouseEventKind::ScrollDown => self.scroll_down(WHEEL_LINES),
1061                _ => {}
1062            },
1063            _ => {}
1064        }
1065    }
1066
1067    fn on_key(&mut self, code: KeyCode, mods: KeyModifiers) {
1068        // Ctrl-C: the same clean exit path as /quit, in every mode. In
1069        // post-approval mode the plan stays committed — Ctrl-C just declines
1070        // the run-now offer.
1071        if code == KeyCode::Char('c') && mods.contains(KeyModifiers::CONTROL) {
1072            self.quit = true;
1073            return;
1074        }
1075        if matches!(self.phase, Phase::Approval { .. }) {
1076            self.on_approval_key(code);
1077            return;
1078        }
1079        if matches!(self.phase, Phase::PostApproval { .. }) {
1080            if let Some(outcome) = post_approval_key(code) {
1081                self.run_now = outcome == PlanningOutcome::ApprovedRun;
1082                self.quit = true;
1083            }
1084            return;
1085        }
1086        self.app.error = None;
1087        match (code, mods) {
1088            (KeyCode::Char('d'), m) if m.contains(KeyModifiers::CONTROL) => {
1089                if self.app.editor.is_empty() {
1090                    self.quit = true; // Ctrl-D on an empty line = /quit
1091                } else {
1092                    self.app.editor.delete();
1093                }
1094            }
1095            (KeyCode::Char('a'), m) if m.contains(KeyModifiers::CONTROL) => self.app.editor.home(),
1096            (KeyCode::Char('e'), m) if m.contains(KeyModifiers::CONTROL) => self.app.editor.end(),
1097            (KeyCode::Char('u'), m) if m.contains(KeyModifiers::CONTROL) => {
1098                self.app.editor.clear_line()
1099            }
1100            (KeyCode::Left, m) if m.contains(KeyModifiers::ALT) => self.app.editor.word_left(),
1101            (KeyCode::Right, m) if m.contains(KeyModifiers::ALT) => self.app.editor.word_right(),
1102            (KeyCode::Left, _) => self.app.editor.left(),
1103            (KeyCode::Right, _) => self.app.editor.right(),
1104            (KeyCode::Home, _) => self.app.editor.home(),
1105            (KeyCode::End, _) => {
1106                // End re-attaches a detached transcript; otherwise it is the
1107                // editor's end-of-line.
1108                if self.app.scroll.is_detached() {
1109                    self.app.scroll.to_follow();
1110                } else {
1111                    self.app.editor.end();
1112                }
1113            }
1114            (KeyCode::Up, _) => self.app.editor.history_up(),
1115            (KeyCode::Down, _) => self.app.editor.history_down(),
1116            (KeyCode::PageUp, _) => self.scroll_up(self.page()),
1117            (KeyCode::PageDown, _) => self.scroll_down(self.page()),
1118            (KeyCode::Backspace, _) => self.app.editor.backspace(),
1119            (KeyCode::Delete, _) => self.app.editor.delete(),
1120            (KeyCode::Enter, _) => self.on_submit(),
1121            (KeyCode::Char(c), m)
1122                if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1123            {
1124                self.app.editor.insert(c);
1125            }
1126            _ => {}
1127        }
1128    }
1129
1130    fn on_submit(&mut self) {
1131        let Some(line) = self.app.editor.submit() else {
1132            return;
1133        };
1134        let busy = matches!(self.phase, Phase::Busy { .. });
1135        match classify_submission(&line, busy) {
1136            SubmitDisposition::Quit => self.quit = true,
1137            SubmitDisposition::Unknown(cmd) => {
1138                self.app.push(TranscriptEntry::Notice(format!(
1139                    "unknown command {cmd}; use /plan or /quit"
1140                )));
1141            }
1142            SubmitDisposition::Queued(text) => {
1143                self.app.push(TranscriptEntry::User {
1144                    text: text.clone(),
1145                    queued: true,
1146                });
1147                self.app.queue.push(text);
1148            }
1149            SubmitDisposition::RequestPlan => {
1150                self.app.push(TranscriptEntry::User {
1151                    text: line,
1152                    queued: false,
1153                });
1154                self.phase = match std::mem::replace(&mut self.phase, Phase::Transitioning) {
1155                    Phase::Idle(engine) => start_plan_request(engine),
1156                    other => other,
1157                };
1158            }
1159            SubmitDisposition::Turn(text) => {
1160                self.app.push(TranscriptEntry::User {
1161                    text: text.clone(),
1162                    queued: false,
1163                });
1164                self.phase = match std::mem::replace(&mut self.phase, Phase::Transitioning) {
1165                    Phase::Idle(engine) => start_turn(engine, text),
1166                    other => other,
1167                };
1168            }
1169        }
1170    }
1171
1172    fn on_turn_done(&mut self, mut engine: Box<MissionEngine>, out: TurnOutput) {
1173        // A seed turn may have run inside this turn (fresh session, resume
1174        // ack, or re-seed). Its reply — often the orchestrator's scoping
1175        // questions — happened first in the conversation, so it enters the
1176        // transcript before the turn's own output.
1177        if let Some(seed) = engine.take_seed_reply() {
1178            self.app.push(TranscriptEntry::Orch(seed));
1179        }
1180        match out {
1181            TurnOutput::Reply(Ok(text)) => {
1182                self.app.push(TranscriptEntry::Orch(text));
1183                self.phase = Phase::Idle(engine);
1184            }
1185            TurnOutput::Reply(Err(e)) => {
1186                let message = format!("{:#}", augment_limit_hint(e.into()));
1187                self.app.push(TranscriptEntry::Error(format!(
1188                    "orchestrator turn failed: {message}"
1189                )));
1190                self.app.error = Some(format!("orchestrator turn failed: {message}"));
1191                self.phase = Phase::Idle(engine);
1192            }
1193            TurnOutput::Plan(Ok(PlanRequest::NotReady(text))) => {
1194                // Conversational, not an error: show what the orchestrator
1195                // said and return to idle — no approval mode, no red.
1196                self.app.push(TranscriptEntry::Orch(text));
1197                self.app
1198                    .push(TranscriptEntry::Notice(PLAN_NOT_READY_NOTICE.to_string()));
1199                self.phase = Phase::Idle(engine);
1200            }
1201            TurnOutput::Plan(Ok(PlanRequest::WrongPlan { reason })) => {
1202                // Planner-initiated escalation, also not an error: show the
1203                // reason and return to idle — no approval mode, no red.
1204                self.app.push(TranscriptEntry::Orch(reason));
1205                self.app
1206                    .push(TranscriptEntry::Notice(PLAN_WRONG_PLAN_NOTICE.to_string()));
1207                self.phase = Phase::Idle(engine);
1208            }
1209            TurnOutput::Plan(Ok(PlanRequest::Ready(plan))) => {
1210                self.app.push(TranscriptEntry::Block(
1211                    output::render_plan(&plan).trim_end().to_string(),
1212                ));
1213                // Estimate with params calibrated from this repo's completed
1214                // missions (built-in defaults when there are none yet).
1215                let calibration = cost::calibrate(&engine.paths().repo_root);
1216                let estimate = cost::estimate(&plan, &engine.state().config, &calibration.params);
1217                let estimate = cost::apply_shape(estimate, &plan, &calibration);
1218                self.app
1219                    .push(TranscriptEntry::Block(output::render_cost_estimate(
1220                        &estimate,
1221                        calibration.missions_used,
1222                    )));
1223                self.phase = Phase::Approval { engine, plan };
1224                return; // queued messages wait for the approval decision
1225            }
1226            TurnOutput::Plan(Err(e)) => {
1227                let message = format!("{:#}", augment_limit_hint(e.into()));
1228                self.app.push(TranscriptEntry::Error(format!(
1229                    "plan request failed: {message}"
1230                )));
1231                self.app.error = Some(format!("plan request failed: {message}"));
1232                self.phase = Phase::Idle(engine);
1233            }
1234        }
1235        self.dispatch_queued();
1236    }
1237
1238    fn on_approval_key(&mut self, code: KeyCode) {
1239        match approval_key(code) {
1240            ApprovalKey::Ignore => {}
1241            ApprovalKey::Approve => {
1242                self.phase = match std::mem::replace(&mut self.phase, Phase::Transitioning) {
1243                    Phase::Approval { mut engine, plan } => {
1244                        match engine.approve_plan(plan.clone()) {
1245                            Ok(()) => {
1246                                let branch = engine.state().mission.mission_branch.clone();
1247                                self.app.push(TranscriptEntry::Notice(format!(
1248                                    "plan approved and committed on {branch}."
1249                                )));
1250                                self.approved_branch = Some(branch);
1251                                // The commit is done; whether to start the
1252                                // spend is a separate explicit consent step.
1253                                Phase::PostApproval { _engine: engine }
1254                            }
1255                            Err(e) => {
1256                                self.app.push(TranscriptEntry::Error(format!(
1257                                    "plan approval failed: {e}"
1258                                )));
1259                                self.app.push(TranscriptEntry::Notice(
1260                                    "back to the conversation.".into(),
1261                                ));
1262                                Phase::Idle(engine)
1263                            }
1264                        }
1265                    }
1266                    other => other,
1267                };
1268                if self.approved_branch.is_none() {
1269                    self.dispatch_queued();
1270                }
1271            }
1272            ApprovalKey::Reject => {
1273                self.app.push(TranscriptEntry::Notice(
1274                    "not approved — back to the conversation.".into(),
1275                ));
1276                self.phase = match std::mem::replace(&mut self.phase, Phase::Transitioning) {
1277                    Phase::Approval { engine, .. } => Phase::Idle(engine),
1278                    other => other,
1279                };
1280                self.dispatch_queued();
1281            }
1282        }
1283    }
1284
1285    /// Idle again: send the oldest queued message as the next turn (FIFO).
1286    fn dispatch_queued(&mut self) {
1287        if !matches!(self.phase, Phase::Idle(_)) {
1288            return;
1289        }
1290        while let Some(line) = self.app.queue.pop() {
1291            match classify_submission(&line, false) {
1292                SubmitDisposition::Turn(text) => {
1293                    self.phase = match std::mem::replace(&mut self.phase, Phase::Transitioning) {
1294                        Phase::Idle(engine) => start_turn(engine, text),
1295                        other => other,
1296                    };
1297                    return;
1298                }
1299                SubmitDisposition::RequestPlan => {
1300                    self.phase = match std::mem::replace(&mut self.phase, Phase::Transitioning) {
1301                        Phase::Idle(engine) => start_plan_request(engine),
1302                        other => other,
1303                    };
1304                    return;
1305                }
1306                // /quit and unknown commands never enter the queue; skip
1307                // defensively rather than wedge the drain.
1308                _ => continue,
1309            }
1310        }
1311    }
1312
1313    /// One page = the transcript pane height (minus one line of overlap).
1314    fn page(&self) -> usize {
1315        (self.dims.1 as usize).saturating_sub(1).max(1)
1316    }
1317
1318    fn scroll_up(&mut self, n: usize) {
1319        let (total, height) = self.scroll_geometry();
1320        self.app.scroll.scroll_up(n, total, height);
1321    }
1322
1323    fn scroll_down(&mut self, n: usize) {
1324        let (total, height) = self.scroll_geometry();
1325        self.app.scroll.scroll_down(n, total, height);
1326    }
1327
1328    fn scroll_geometry(&self) -> (usize, usize) {
1329        let width = (self.dims.0 as usize).max(1);
1330        let total = total_visual_lines(&self.app.transcript, width);
1331        (total, self.dims.1 as usize)
1332    }
1333}
1334
1335// ===========================================================================
1336// Rendering
1337// ===========================================================================
1338
1339/// Draw the four rows: title bar, transcript, status line, input line.
1340/// Returns the transcript pane's (width, height) for scroll math.
1341fn draw_ui(frame: &mut Frame, app: &App, view: &PhaseView, title: &str) -> (u16, u16) {
1342    let chunks = Layout::vertical([
1343        Constraint::Length(1),
1344        Constraint::Min(1),
1345        Constraint::Length(1),
1346        Constraint::Length(1),
1347    ])
1348    .split(frame.area());
1349
1350    draw_title(frame, chunks[0], title);
1351    draw_transcript(frame, chunks[1], app);
1352    draw_status(frame, chunks[2], app, view);
1353    draw_input(frame, chunks[3], app, view);
1354
1355    (chunks[1].width, chunks[1].height)
1356}
1357
1358fn draw_title(frame: &mut Frame, area: Rect, title: &str) {
1359    let text = one_line(title, area.width as usize);
1360    frame.render_widget(
1361        Paragraph::new(Span::styled(
1362            text,
1363            Style::new().add_modifier(Modifier::BOLD),
1364        ))
1365        .style(Style::new().bg(Color::DarkGray).fg(Color::White)),
1366        area,
1367    );
1368}
1369
1370fn draw_transcript(frame: &mut Frame, area: Rect, app: &App) {
1371    let width = (area.width as usize).max(1);
1372    let height = area.height as usize;
1373    let mut lines: Vec<Line<'static>> = Vec::new();
1374    for entry in &app.transcript {
1375        entry_lines(entry, width, &mut lines);
1376    }
1377    let total = lines.len();
1378    let top = app.scroll.top(total, height);
1379    let bottom = (top + height).min(total);
1380    let visible: Vec<Line<'static>> = lines[top..bottom].to_vec();
1381    frame.render_widget(Paragraph::new(visible), area);
1382
1383    // Detached scrollback: a dim marker on the bottom row shows the way back.
1384    if app.scroll.is_detached() && bottom < total && height > 0 {
1385        let marker = Rect {
1386            x: area.x,
1387            y: area.y + area.height - 1,
1388            width: area.width,
1389            height: 1,
1390        };
1391        frame.render_widget(
1392            Paragraph::new(Span::styled(
1393                DETACHED_MARKER,
1394                Style::new().fg(Color::DarkGray),
1395            )),
1396            marker,
1397        );
1398    }
1399}
1400
1401fn draw_status(frame: &mut Frame, area: Rect, app: &App, view: &PhaseView) {
1402    let paragraph = match view {
1403        PhaseView::Approval => Paragraph::new(Span::styled(
1404            APPROVAL_BAR,
1405            Style::new().add_modifier(Modifier::BOLD),
1406        ))
1407        .style(Style::new().bg(Color::Yellow).fg(Color::Black)),
1408        PhaseView::PostApproval => Paragraph::new(Span::styled(
1409            POST_APPROVAL_BAR,
1410            Style::new().add_modifier(Modifier::BOLD),
1411        ))
1412        .style(Style::new().bg(Color::Yellow).fg(Color::Black)),
1413        PhaseView::Busy { kind, elapsed_secs } => Paragraph::new(Span::styled(
1414            busy_status_line(*kind, *elapsed_secs, app.spinner, app.queue.depth()),
1415            Style::new().fg(Color::Yellow),
1416        )),
1417        PhaseView::Idle => match &app.error {
1418            Some(error) => Paragraph::new(Span::styled(
1419                format!(
1420                    "✖ {}",
1421                    one_line(error, (area.width as usize).saturating_sub(2))
1422                ),
1423                Style::new().fg(Color::Red),
1424            )),
1425            None => Paragraph::new(Span::styled(IDLE_STATUS, Style::new().fg(Color::Green))),
1426        },
1427    };
1428    frame.render_widget(paragraph, area);
1429}
1430
1431fn draw_input(frame: &mut Frame, area: Rect, app: &App, view: &PhaseView) {
1432    if matches!(view, PhaseView::Approval | PhaseView::PostApproval) {
1433        frame.render_widget(
1434            Paragraph::new(Span::styled(
1435                "(input paused — press y or n)",
1436                Style::new().fg(Color::DarkGray),
1437            )),
1438            area,
1439        );
1440        return; // no cursor: the input line is inactive in these modes
1441    }
1442    let prompt = "> ";
1443    let window = (area.width as usize).saturating_sub(prompt.len()).max(1);
1444    let chars: Vec<char> = app.editor.text().chars().collect();
1445    let cursor = app.editor.cursor();
1446    let start = if cursor >= window {
1447        cursor + 1 - window
1448    } else {
1449        0
1450    };
1451    let end = (start + window).min(chars.len());
1452    let visible: String = chars[start.min(chars.len())..end].iter().collect();
1453    frame.render_widget(
1454        Paragraph::new(Line::from(vec![
1455            Span::styled(prompt, Style::new().add_modifier(Modifier::BOLD)),
1456            Span::raw(visible),
1457        ])),
1458        area,
1459    );
1460    let x = area.x + (prompt.len() + (cursor - start)) as u16;
1461    frame.set_cursor_position(Position::new(x.min(area.right().saturating_sub(1)), area.y));
1462}