Skip to main content

defect_cli/
repl.rs

1//! In-process minimal REPL — `defect --repl`.
2//!
3//! Does not use ACP or TUI: reads one line from stdin as a prompt, runs one turn, and
4//! prints the session event stream to stdout as plain colored text. Its purpose is a
5//! convenient entry point for "hand-crafting prompts during development to quickly test
6//! agent behavior", not a polished frontend for end users.
7//!
8//! The entire module is gated by the `repl` feature (see `Cargo.toml`) — when the feature
9//! is disabled, this code is not compiled and `owo-colors` / `crossterm` are not pulled
10//! in.
11//!
12//! ## Why line editing is done manually
13//!
14//! Initially we relied on the terminal's canonical (cooked) mode for line editing, which
15//! had two bugs: backspace could erase the prompt, and deleting Chinese characters
16//! removed bytes instead of whole Unicode chars. So we switch to raw mode during line
17//! reading and handle it ourselves: maintain a `String` buffer (where `pop()` naturally
18//! deletes by `char`), and on each key press redraw by "carriage return + clear line +
19//! redraw prompt+buffer" — the prompt is redrawn and thus cannot be erased, and CJK wide
20//! characters work correctly because the terminal advances the cursor by display width.
21//! Raw mode is only active during line reading; event rendering during a turn still runs
22//! in cooked mode, so `\n` works normally.
23//!
24//! We use [`crossterm`] for raw mode and key event parsing (consistent across Linux /
25//! macOS / Windows) — its `event::read()` returns already-parsed [`KeyEvent`] values
26//! (multi-byte chars are delivered directly, no need to manually assemble UTF-8), and raw
27//! mode switching is cross-platform.
28//!
29//! ## Relationship with the ACP path
30//!
31//! Reuses the same [`AgentCore`]: creates a session with
32//! [`Frontend::Cli`](defect_agent::session::Frontend::Cli), and uses local
33//! `LocalFsBackend` / `LocalShellBackend` (the REPL runs on the local machine, files and
34//! commands are executed directly, no delegation). The event stream consumption logic is
35//! a minimal version of the `defect-acp` event pump — that one translates events into
36//! wire notifications, while this one translates them into terminal text.
37
38use std::io::IsTerminal;
39use std::path::PathBuf;
40use std::sync::Arc;
41use std::time::Duration;
42
43use agent_client_protocol_schema::{ContentBlock, SessionId, StopReason, TextContent};
44use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
45use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
46use defect_agent::event::AgentEvent;
47use defect_agent::llm::{Message, MessageContent, Role};
48use defect_agent::session::{AgentCore, TurnError};
49use futures::{FutureExt, StreamExt};
50use owo_colors::OwoColorize;
51use tokio::io::{AsyncWriteExt, Stdout};
52use tokio::sync::mpsc;
53
54use crate::session_open::{LocalSessionOpts, open_local_session};
55
56/// The user input prompt. Shared by the live input line and history replay so that a
57/// replayed user message looks identical to one freshly typed (the two used to diverge:
58/// live input showed `› …` while replay showed `user> …`).
59const USER_PROMPT: &str = "› ";
60
61/// Run an interactive REPL until stdin EOF (Ctrl-D) or until `:q` / `:quit` / `:exit` is
62/// read.
63///
64/// `cwd` is the session working directory (the root of the local filesystem / shell
65/// backend). When `resume = Some(id)`, the session is resumed (replaying history to the
66/// terminal) instead of creating a new one.
67pub async fn run(
68    agent: Arc<dyn AgentCore>,
69    cwd: PathBuf,
70    resume: Option<SessionId>,
71    shell_output_max_bytes: usize,
72) -> anyhow::Result<()> {
73    let mut out = tokio::io::stdout();
74
75    let session = open_local_session(
76        &agent,
77        &cwd,
78        LocalSessionOpts {
79            resume,
80            shell_output_max_bytes,
81        },
82    )
83    .await?;
84
85    let banner = format!(
86        "defect repl — {} @ {}\n\
87         type a prompt and hit enter; :q or Ctrl-D to quit.\n",
88        session.current_model(),
89        cwd.display(),
90    );
91    write(&mut out, &banner.dimmed().to_string()).await?;
92
93    // Resume: replay the restored history transcript to the terminal so the user can see
94    // the context.
95    let history = session.history_snapshot();
96    if !history.is_empty() {
97        write(
98            &mut out,
99            &format!("— resumed {} message(s) —\n", history.len())
100                .dimmed()
101                .to_string(),
102        )
103        .await?;
104        for message in &history {
105            render_history_message(&mut out, message).await?;
106        }
107    }
108
109    // Persistent subscription: subscribe **once** outside the loop, draining across all
110    // turns — including the session driver's autonomous continuation turn (the round
111    // where the background subagent digests results after completion). This is the key
112    // isomorphism with ACP `spawn_session_pump`: the event consumption lifetime equals
113    // the session lifetime.
114    let mut events = session.subscribe();
115
116    // Input reading: a dedicated blocking thread runs raw mode + crossterm key reading,
117    // forwarding keypresses to the main task via a channel. **Key point**: all stdout
118    // writes happen only in the main task (line redraw + event rendering); the blocking
119    // thread never writes a single byte. This avoids stdout lock contention (previously,
120    // `read_line` holding the lock for long periods blocked background turn events,
121    // causing idle silence) and allows cleanly "erasing the input line → showing the
122    // event → redrawing the input line" when events arrive, so input and output no longer
123    // interleave.
124    let (key_tx, mut key_rx) = mpsc::channel::<KeyMsg>(64);
125    let _input = InputReader::spawn(key_tx);
126
127    let mut editor = LineEditor::new(USER_PROMPT.cyan().bold().to_string());
128    editor.redraw(&mut out).await?;
129
130    // Prompts entered while a turn is in progress are queued here — the same session
131    // cannot run concurrent turns, so they are processed sequentially after the current
132    // turn finishes. FIFO.
133    let mut pending: std::collections::VecDeque<String> = std::collections::VecDeque::new();
134
135    'session: loop {
136        // Fetch the next prompt: first consume lines queued during the previous turn;
137        // otherwise enter the input phase to read a new line.
138        let line = if let Some(queued) = pending.pop_front() {
139            editor.echo_submitted(&mut out, &queued).await?; // Echo so the user sees which line is about to be run.
140            queued
141        } else {
142            // Input phase: collect keystrokes to build a line while rendering events in
143            // real time, until a complete line is submitted or the session exits.
144            let mut submitted: Option<String> = None;
145            while submitted.is_none() {
146                tokio::select! {
147                    key = key_rx.recv() => match key {
148                        Some(KeyMsg::Line(text)) => submitted = Some(text),          // Non-TTY line-by-line input
149                        Some(KeyMsg::Edit(key)) => submitted = editor.on_key(key, &mut out).await?,
150                        Some(KeyMsg::Interrupt) => editor.clear_line(&mut out).await?, // Ctrl-C discards the current line
151                        Some(KeyMsg::Eof) | None => break 'session,                   // Ctrl-D / input closed
152                    },
153                    ev = events.next() => {
154                        if let Some(ev) = ev {
155                            editor.render_event(&mut out, ev).await?;
156                        }
157                    }
158                }
159            }
160            submitted.expect("loop exits only when submitted is Some")
161        };
162
163        let prompt_text = line.trim();
164        if prompt_text.is_empty() {
165            editor.redraw(&mut out).await?;
166            continue;
167        }
168        if matches!(prompt_text, ":q" | ":quit" | ":exit") {
169            break;
170        }
171
172        // Run the turn: the future returns only the final `StopReason`; events for this
173        // turn are pushed via a persistent subscription. During the turn, we must still
174        // drain the event stream for rendering **and continue consuming key presses** (so
175        // the user can edit or queue the next prompt). The turn slot may be occupied by
176        // an auto-advancing background turn → `TurnInProgress` backoff retry (similar to
177        // ACP), rather than failing immediately.
178        let (stop, queued) = run_user_turn(
179            session.as_ref(),
180            prompt_text.to_owned(),
181            &mut events,
182            &mut key_rx,
183            &mut editor,
184            &mut out,
185        )
186        .await?;
187        // Lines submitted by the user during this turn are queued and processed after the
188        // turn ends.
189        pending.extend(queued);
190
191        // On the success path, the turn's `TurnEnded` event already drove `end_streaming`
192        // (cleanup + prompt redraw), so no status line is needed here. Only a fatal error
193        // (which does not emit `TurnEnded`) requires explicit screen flush + return to
194        // prompt.
195        match stop {
196            Ok(_) => {
197                // Fallback: ensure we return to the prompt even when `TurnEnded` was not
198                // received (e.g. early exit on an empty prompt).
199                editor.ensure_idle(&mut out).await?;
200            }
201            Err(e) => {
202                editor
203                    .print_error(&mut out, &format!("{} {e}", "turn error:".red().bold()))
204                    .await?;
205            }
206        }
207    }
208
209    write(&mut out, &"\r\nbye.\r\n".dimmed().to_string()).await?;
210    Ok(())
211}
212
213/// Runs a user turn, during which:
214/// - Continuously drains the event stream for rendering.
215/// - **Continuously consumes key presses** — while the turn is running, the user can edit
216///   the next prompt (into the buffer; silently in streaming mode, shown when the turn
217///   ends and redraws). Pressing Enter **queues** that line (the same session cannot run
218///   concurrent turns, so it waits until this turn finishes).
219///
220/// On encountering [`TurnError::TurnInProgress`] (a background auto-renew turn is
221/// occupying the slot), backs off and retries.
222///
223/// Returns `(turn final result, lines queued during this turn)`. `Eof` (Ctrl-D) is also
224/// treated as "input closed": it does not forcibly interrupt the running turn, but
225/// records it, leaving the caller to decide after the turn (here it is simply ignored;
226/// when the turn ends and the input loop resumes, reading EOF again will exit naturally).
227async fn run_user_turn(
228    session: &dyn defect_agent::session::Session,
229    prompt_text: String,
230    events: &mut defect_agent::session::EventStream,
231    key_rx: &mut mpsc::Receiver<KeyMsg>,
232    editor: &mut LineEditor,
233    out: &mut Stdout,
234) -> anyhow::Result<(Result<StopReason, TurnError>, Vec<String>)> {
235    // Backoff parameters match those in ACP `run_prompt_turn`: self-renewing turns are
236    // usually short, so a few retries suffice to acquire a slot.
237    const MAX_RETRIES: u32 = 100;
238    const BACKOFF: Duration = Duration::from_millis(20);
239
240    // Lines submitted by the user while the turn was in progress; returned to the caller
241    // for queued execution after the turn finishes.
242    let mut queued: Vec<String> = Vec::new();
243    // Whether the key channel is still open. Once closed (EOF on the cooked path / user
244    // closes input), we **must stop** selecting on it — otherwise `recv()` keeps
245    // returning `None` immediately, turning the select into a busy-spin that starves the
246    // turn future (a subtle infinite loop: the process appears hung, CPU at 100%).
247    let mut keys_open = true;
248    let mut attempt = 0u32;
249    let result = loop {
250        let prompt_blocks = vec![ContentBlock::Text(TextContent::new(prompt_text.clone()))];
251        let turn = session.run_turn(prompt_blocks);
252        tokio::pin!(turn);
253
254        let result = loop {
255            tokio::select! {
256                // Drain events before checking whether the turn has finished — the turn
257                // future and the event stream may become ready simultaneously (the turn
258                // finishes quickly, with `AssistantText`/`TurnEnded` already buffered).
259                // If `select` randomly picks the turn branch and breaks first, trailing
260                // events in the buffer would be missed. Using `biased;` and polling
261                // events first ensures no events are lost.
262                biased;
263                ev = events.next() => {
264                    if let Some(ev) = ev {
265                        editor.render_event(out, ev).await?;
266                    }
267                }
268                // During a turn, key events are still consumed: editing the next prompt
269                // or queuing Enter. Once the channel is closed, this arm is disabled (see
270                // the `keys_open` comment).
271                key = key_rx.recv(), if keys_open => {
272                    match key {
273                        None => keys_open = false,
274                        Some(msg) => {
275                            if let Some(line) = handle_key_during_turn(session, msg, editor, out).await? {
276                                queued.push(line);
277                            }
278                        }
279                    }
280                }
281                r = &mut turn => break r,
282            }
283        };
284
285        // The turn has ended, but the buffer may still contain tail events (TurnEnded,
286        // etc.) that were just sent and not yet polled. Drain all immediately ready
287        // events without dropping any.
288        while let Some(Some(ev)) = events.next().now_or_never() {
289            editor.render_event(out, ev).await?;
290        }
291
292        match result {
293            Err(TurnError::TurnInProgress) if attempt < MAX_RETRIES => {
294                attempt += 1;
295                // During backoff, continue draining events and consuming key presses —
296                // the auto-renewing turn that holds the slot is still producing output.
297                let sleep = tokio::time::sleep(BACKOFF);
298                tokio::pin!(sleep);
299                loop {
300                    tokio::select! {
301                        () = &mut sleep => break,
302                        ev = events.next() => {
303                            if let Some(ev) = ev {
304                                editor.render_event(out, ev).await?;
305                            }
306                        }
307                        key = key_rx.recv(), if keys_open => {
308                            match key {
309                                None => keys_open = false,
310                                Some(msg) => {
311                                    if let Some(line) = handle_key_during_turn(session, msg, editor, out).await? {
312                                        queued.push(line);
313                                    }
314                                }
315                            }
316                        }
317                    }
318                }
319            }
320            other => break other,
321        }
322    };
323    Ok((result, queued))
324}
325
326/// Handles a key event while a turn is in progress.
327/// Edit actions update the buffer (silently in streaming mode; the display is updated
328/// when the turn ends).
329/// Enter returns `Some(line)` for the caller to enqueue.
330/// Ctrl-C **interrupts the running turn** — calls
331/// [`Session::cancel_turn`](defect_agent::session::Session::cancel_turn) (idempotent);
332/// the turn loop exits at the next checkpoint and emits `TurnEnded{Cancelled}`, which the
333/// event renderer handles; the current edit line is also cleared.
334/// Ctrl-D does not interrupt during a turn; it is ignored (when the turn ends and the
335/// input loop resumes, the same Ctrl-D will be read again and cause exit).
336/// Channel closure (`None`) is handled by the caller's select guard and does not reach
337/// this function.
338async fn handle_key_during_turn(
339    session: &dyn defect_agent::session::Session,
340    msg: KeyMsg,
341    editor: &mut LineEditor,
342    out: &mut Stdout,
343) -> anyhow::Result<Option<String>> {
344    match msg {
345        KeyMsg::Line(text) => Ok(Some(text)),
346        KeyMsg::Edit(key) => editor.on_key(key, out).await,
347        KeyMsg::Interrupt => {
348            // Interrupts the running turn: the underlying `CancellationToken` is
349            // cancelled, and the turn exits at the next checkpoint (LLM stream drain,
350            // main loop, or permission wait). The turn future then returns `Cancelled`,
351            // and the event stream's `TurnEnded` handles cleanup and redrawing — this
352            // does not directly manipulate the screen.
353            session.cancel_turn();
354            editor.clear_line(out).await?;
355            Ok(None)
356        }
357        KeyMsg::Eof => Ok(None),
358    }
359}
360
361/// Messages sent from the input-reading thread to the main task.
362enum KeyMsg {
363    /// A single editing action (printable character or backspace); the main task updates
364    /// the buffer and redraws accordingly.
365    Edit(KeyEvent),
366    /// The user submitted a full line (Enter in TTY mode, or one line of text in non-TTY
367    /// line-by-line reading).
368    Line(String),
369    /// Ctrl-C: abort the current input line.
370    Interrupt,
371    /// Ctrl-D (empty buffer), stdin EOF, or input closed.
372    Eof,
373}
374
375/// Input reading thread: runs a crossterm key-reading loop in raw mode and sends keys to
376/// the main task via a channel.
377/// **Does not write to stdout** — all display is handled by the main task (see module
378/// docs "Why line editing is done ourselves").
379///
380/// When stdin is not a TTY (pipe / redirect), falls back to line-by-line reading, sending
381/// one [`KeyMsg::Line`] per line.
382///
383/// **Raw mode is held by this struct (on the main task side) via a [`RawMode`] guard**,
384/// not by the reading thread — this is critical for clean terminal restoration on exit:
385/// when Ctrl-D or `:q` exits, the reading thread is typically still **blocked in
386/// `crossterm::event::read()`** (it reads one key then waits for the next, never
387/// terminating on its own). If the guard were on that thread's stack, it would never be
388/// dropped when the process exits, so `disable_raw_mode()` would not run and the terminal
389/// would remain in raw mode (no echo, misaligned cursor). By attaching the guard to
390/// `InputReader`, which is dropped when the main task returns, `disable_raw_mode()`
391/// executes on both normal exit and unwind. These calls operate on the global TTY and are
392/// cross-thread safe — the terminal is restored even while the reading thread is still
393/// blocked.
394struct InputReader {
395    handle: Option<std::thread::JoinHandle<()>>,
396    /// Raw mode guard (TTY only). Restores the terminal on drop; see struct docs.
397    _raw: Option<RawMode>,
398}
399
400impl InputReader {
401    fn spawn(tx: mpsc::Sender<KeyMsg>) -> Self {
402        let tty = std::io::stdin().is_terminal();
403        // Enable raw mode on the main task side (TTY only); the guard is held by
404        // `InputReader`. If enabling fails, degrade gracefully: the guard is not held,
405        // but the key-reading thread still runs (crossterm can still read in non-raw
406        // mode, albeit with degraded behavior).
407        let raw = if tty { RawMode::enable().ok() } else { None };
408        let handle = std::thread::spawn(move || {
409            if tty {
410                read_keys_raw(&tx);
411            } else {
412                read_lines_cooked(&tx);
413            }
414        });
415        Self {
416            handle: Some(handle),
417            _raw: raw,
418        }
419    }
420}
421
422impl Drop for InputReader {
423    fn drop(&mut self) {
424        // Raw mode is restored here: the `_raw` guard calls `disable_raw_mode()` when
425        // this struct is dropped. The key-reading thread may still be blocked in `read()`
426        // — we do not join or forcibly kill it (no portable way exists), and it will be
427        // reclaimed on process exit. Terminal state has already been restored by the
428        // guard on this (main) thread, regardless of whether that thread has finished.
429        if let Some(h) = self.handle.take() {
430            drop(h);
431        }
432    }
433}
434
435/// Raw-mode key-reading loop (TTY). Each meaningful key press sends a [`KeyMsg`]; the
436/// line buffer is maintained by the main task, so this loop only forwards keys **as-is**
437/// (except Enter, Ctrl-C, and Ctrl-D, which are interpreted as control messages).
438/// Ctrl-D's "EOF only on empty buffer" semantics require buffer state, so a **length
439/// mirror** is tracked here.
440fn read_keys_raw(tx: &mpsc::Sender<KeyMsg>) {
441    // Raw mode is already enabled by the caller (`InputReader::spawn`) on the main task
442    // side, which holds the guard — we do not hold it in this thread, because if the
443    // thread blocks on `read()` and the process exits, the guard would never drop,
444    // leaving the terminal stuck in raw mode (see `InputReader` docs). Here we just read
445    // keys.
446    // Buffer length mirror: used only to decide whether Ctrl-D (empty buffer = EOF) or
447    // backspace has content to delete.
448    // The actual buffer contents live in the main task's `LineEditor`.
449    let mut len = 0usize;
450    loop {
451        let Ok(event) = crossterm::event::read() else {
452            let _ = tx.blocking_send(KeyMsg::Eof);
453            return;
454        };
455        let Event::Key(key) = event else {
456            continue; // resize, focus, paste, mouse events — ignore
457        };
458        // Windows reports both Press and Release; only handle Press.
459        if key.kind == KeyEventKind::Release {
460            continue;
461        }
462        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
463        let msg = match key.code {
464            KeyCode::Enter => {
465                len = 0;
466                // The line content is handled by the main task; sending an empty `Line`
467                // here triggers a "submit", but the actual text is provided by the main
468                // task.
469                // However, the main task needs the text — so this was changed: `Enter`
470                // also goes through `Edit`, and the main task decides whether to submit.
471                // See below.
472                KeyMsg::Edit(key)
473            }
474            KeyCode::Char('c') if ctrl => {
475                len = 0;
476                KeyMsg::Interrupt
477            }
478            KeyCode::Char('d') if ctrl && len == 0 => KeyMsg::Eof,
479            KeyCode::Char('d') if ctrl => continue, // Ctrl-D with non-empty buffer: ignore
480            KeyCode::Backspace => {
481                len = len.saturating_sub(1);
482                KeyMsg::Edit(key)
483            }
484            KeyCode::Char(_) if !ctrl => {
485                len += 1;
486                KeyMsg::Edit(key)
487            }
488            _ => continue, // Arrow keys / Tab / other control keys: ignore
489        };
490        if tx.blocking_send(msg).is_err() {
491            return; // the main task exits
492        }
493    }
494}
495
496/// Reads lines in non-TTY (cooked) mode: sends a [`KeyMsg::Line`] per line, and
497/// [`KeyMsg::Eof`] on EOF.
498fn read_lines_cooked(tx: &mpsc::Sender<KeyMsg>) {
499    use std::io::BufRead;
500    let stdin = std::io::stdin();
501    let mut line = String::new();
502    loop {
503        line.clear();
504        match stdin.lock().read_line(&mut line) {
505            Ok(0) | Err(_) => {
506                let _ = tx.blocking_send(KeyMsg::Eof);
507                return;
508            }
509            Ok(_) => {
510                let trimmed = line.trim_end_matches(['\r', '\n']).to_owned();
511                if tx.blocking_send(KeyMsg::Line(trimmed)).is_err() {
512                    return;
513                }
514            }
515        }
516    }
517}
518
519/// RAII guard for terminal raw mode: enters raw mode on construction and restores it on
520/// `Drop`. Cross-platform support is handled by crossterm (termios on Unix, console mode
521/// on Windows); we do not interact with platform APIs directly.
522struct RawMode;
523
524impl RawMode {
525    fn enable() -> std::io::Result<Self> {
526        enable_raw_mode()?;
527        Ok(Self)
528    }
529}
530
531impl Drop for RawMode {
532    fn drop(&mut self) {
533        // Failure is unrecoverable here; best-effort only (same semantics as terminal
534        // state restoration).
535        let _ = disable_raw_mode();
536    }
537}
538
539/// Single-line editor + output coordinator on the main task side. **All stdout writes go
540/// through it.**
541///
542/// Uses a display state machine to resolve the conflict between streaming output and user
543/// input lines:
544/// - **Idle state** (`streaming = false`): the bottom of the screen shows the prompt plus
545///   the user's current buffer. Key presses update the buffer and redraw in place.
546/// - **Streaming state** (`streaming = true`): a turn is producing output (assistant text
547///   arrives as incremental chunk events). Text is **appended directly to the screen**
548///   without ever redrawing the prompt — otherwise the "erase line + redraw prompt"
549///   between chunks would fragment the just-printed assistant text (the root cause of
550///   earlier garbled output).
551///
552/// Entering streaming state: lazily triggered by the first content event (first erases
553/// the user's partial input line, then switches to streaming).
554/// Exiting streaming state: on `TurnEnded`, move to a clean line, redraw the prompt + the
555/// interrupted buffer.
556/// Characters typed by the user while streaming are silently appended to the buffer and
557/// shown when the turn ends and the display is redrawn.
558///
559/// Whether raw (TTY) determines if line breaks use `\r\n` or `\n`, and whether cursor
560/// control is performed.
561struct LineEditor {
562    prompt: String,
563    buf: String,
564    /// Whether the terminal is in raw mode (TTY). When not a TTY (pipe), no cursor
565    /// control is performed and newlines use `\n`.
566    tty: bool,
567    /// Whether the editor is in streaming output mode (a turn is in progress).
568    streaming: bool,
569    /// Whether the cursor is at the start of a line during streaming output (used to
570    /// decide whether to add a newline when a turn ends).
571    at_line_start: bool,
572    /// The kind of the most recently streamed segment within the current turn. Used to
573    /// insert a separating newline when the kind changes (e.g. thought → assistant text),
574    /// so consecutive segments of different kinds do not run together on one line. `None`
575    /// before the first segment of a turn.
576    last_kind: Option<StreamKind>,
577}
578
579/// The kind of a streamed output segment. Distinct kinds get a separating newline at their
580/// boundary; consecutive chunks of the same kind (e.g. multiple thought chunks) do not.
581#[derive(Debug, Clone, Copy, PartialEq, Eq)]
582enum StreamKind {
583    /// Assistant reasoning / thinking (dimmed italic).
584    Thought,
585    /// Assistant message text.
586    Text,
587    /// Tool call lifecycle lines (`⚙ …`, `  ↳ …`).
588    Tool,
589}
590
591impl LineEditor {
592    fn new(prompt: String) -> Self {
593        Self {
594            prompt,
595            buf: String::new(),
596            tty: std::io::stdin().is_terminal(),
597            streaming: false,
598            at_line_start: true,
599            last_kind: None,
600        }
601    }
602
603    /// Redraw the current input line (idle state): carriage return, clear to end of line,
604    /// write prompt + buffer.
605    async fn redraw(&self, out: &mut Stdout) -> anyhow::Result<()> {
606        if self.tty {
607            write(out, &format!("\r\x1b[K{}{}", self.prompt, self.buf)).await?;
608        } else {
609            write(out, &self.prompt).await?;
610        }
611        out.flush().await?;
612        Ok(())
613    }
614
615    /// Echo a "will run" prompt for a line queued during a turn: clear the current line,
616    /// print `prompt + line`, and add a newline so the user can see what will run next.
617    async fn echo_submitted(&mut self, out: &mut Stdout, line: &str) -> anyhow::Result<()> {
618        self.buf.clear();
619        if self.tty {
620            write(out, &format!("\r\x1b[K{}{}\r\n", self.prompt, line)).await?;
621        } else {
622            write(out, &format!("{}{}\n", self.prompt, line)).await?;
623        }
624        out.flush().await?;
625        Ok(())
626    }
627
628    /// Handle a line-editing key event. Enter returns `Some(line)` (with the buffer
629    /// emptied) to signal submission; all other keys return `None`. In streaming mode,
630    /// only update the buffer — do **not** redraw (redrawing would interfere with the
631    /// ongoing stream output). The user's input will be shown when the turn ends and a
632    /// redraw occurs.
633    async fn on_key(&mut self, key: KeyEvent, out: &mut Stdout) -> anyhow::Result<Option<String>> {
634        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
635        match key.code {
636            KeyCode::Enter => {
637                if !self.streaming {
638                    write(out, "\r\n").await?;
639                    out.flush().await?;
640                }
641                return Ok(Some(std::mem::take(&mut self.buf)));
642            }
643            KeyCode::Backspace => {
644                let changed = self.buf.pop().is_some();
645                // Redraw only when idle and a deletion actually occurred; in streaming
646                // mode the buffer silently follows input.
647                if changed && !self.streaming {
648                    self.redraw(out).await?;
649                }
650            }
651            KeyCode::Char(c) if !ctrl => {
652                self.buf.push(c);
653                if !self.streaming {
654                    self.redraw(out).await?;
655                }
656            }
657            _ => {}
658        }
659        Ok(None)
660    }
661
662    /// Ctrl-C: discard the current input line and redraw an empty prompt (only meaningful
663    /// in the idle state).
664    async fn clear_line(&mut self, out: &mut Stdout) -> anyhow::Result<()> {
665        self.buf.clear();
666        if !self.streaming {
667            self.redraw(out).await?;
668        }
669        Ok(())
670    }
671
672    /// Enter streaming mode (if not already): erase the input line the user is typing;
673    /// subsequent output is appended directly.
674    async fn enter_streaming(&mut self, out: &mut Stdout) -> anyhow::Result<()> {
675        if !self.streaming {
676            if self.tty {
677                write(out, "\r\x1b[K").await?; // Erase the prompt and partial input line
678            }
679            self.streaming = true;
680            self.at_line_start = true;
681        }
682        Ok(())
683    }
684
685    /// Exit streaming mode: add a newline if the cursor is not at the start of a line,
686    /// then redraw the prompt and the interrupted buffer.
687    async fn end_streaming(&mut self, out: &mut Stdout) -> anyhow::Result<()> {
688        if self.streaming {
689            if !self.at_line_start {
690                write(out, if self.tty { "\r\n" } else { "\n" }).await?;
691            }
692            self.streaming = false;
693            self.last_kind = None;
694            self.redraw(out).await?;
695        }
696        Ok(())
697    }
698
699    /// Render an [`AgentEvent`]. Content events lazily enter streaming mode and append
700    /// text directly; `TurnEnded` exits streaming mode and redraws the prompt. Only
701    /// handle event types that are meaningful to the user; ignore the rest.
702    async fn render_event(&mut self, out: &mut Stdout, event: AgentEvent) -> anyhow::Result<()> {
703        match event {
704            AgentEvent::AssistantText { content } => {
705                if let Some(text) = block_text(&content) {
706                    self.stream_text(out, &text, StreamKind::Text).await?;
707                }
708            }
709            AgentEvent::AssistantThought { content } => {
710                if let Some(text) = block_text(&content) {
711                    self.stream_text(
712                        out,
713                        &text.dimmed().italic().to_string(),
714                        StreamKind::Thought,
715                    )
716                    .await?;
717                }
718            }
719            AgentEvent::ToolCallStarted { name, fields, .. } => {
720                let title = fields.title.unwrap_or(name);
721                self.stream_text(
722                    out,
723                    &format!("{} {}\n", "⚙".yellow(), title.yellow()),
724                    StreamKind::Tool,
725                )
726                .await?;
727            }
728            AgentEvent::ToolCallFinished { fields, .. } => {
729                if let Some(status) = fields.status {
730                    self.stream_text(
731                        out,
732                        &format!("{} {status:?}\n", "  ↳".dimmed()),
733                        StreamKind::Tool,
734                    )
735                    .await?;
736                }
737            }
738            AgentEvent::TurnEnded { .. } => {
739                self.end_streaming(out).await?;
740            }
741            _ => {}
742        }
743        Ok(())
744    }
745
746    /// Stream a chunk of text of a given [`StreamKind`]: ensure streaming mode is active,
747    /// insert a separating newline if this segment's kind differs from the previous one
748    /// and the cursor is mid-line (so e.g. thinking and the assistant reply do not run
749    /// together), write the text (converting `\n` to `\r\n` in raw mode), and update the
750    /// line-start state.
751    async fn stream_text(
752        &mut self,
753        out: &mut Stdout,
754        text: &str,
755        kind: StreamKind,
756    ) -> anyhow::Result<()> {
757        if text.is_empty() {
758            return Ok(());
759        }
760        self.enter_streaming(out).await?;
761        // Boundary separation: when the kind changes (thought → text, text → tool, …) and
762        // we are not already at the start of a fresh line, break the line first. Same-kind
763        // chunks (the common streaming case) are concatenated without inserting breaks.
764        if self.last_kind.is_some_and(|prev| prev != kind) && !self.at_line_start {
765            write(out, if self.tty { "\r\n" } else { "\n" }).await?;
766            self.at_line_start = true;
767        }
768        write(out, &nl(text, self.tty)).await?;
769        out.flush().await?;
770        self.at_line_start = text.ends_with('\n');
771        self.last_kind = Some(kind);
772        Ok(())
773    }
774
775    /// Ensure the session returns to idle: if still streaming (no `TurnEnded` received),
776    /// finalize and redraw the prompt; if already idle, no-op (to avoid double-printing
777    /// the prompt with the `TurnEnded` redraw).
778    async fn ensure_idle(&mut self, out: &mut Stdout) -> anyhow::Result<()> {
779        if self.streaming {
780            self.end_streaming(out).await?;
781        }
782        Ok(())
783    }
784
785    /// Print an error line (turn fatal error; these do not emit a TurnEnded event), then
786    /// return to the idle prompt.
787    async fn print_error(&mut self, out: &mut Stdout, text: &str) -> anyhow::Result<()> {
788        // If currently streaming, finish the line first.
789        if self.streaming && !self.at_line_start {
790            write(out, if self.tty { "\r\n" } else { "\n" }).await?;
791        }
792        self.streaming = false;
793        self.last_kind = None;
794        if self.tty {
795            write(out, &format!("\r\x1b[K{text}\r\n")).await?;
796        } else {
797            write(out, &format!("{text}\n")).await?;
798        }
799        self.redraw(out).await?;
800        Ok(())
801    }
802}
803
804/// Replace `\n` with `\r\n` in raw mode (raw terminals do not perform ONLCR translation,
805/// so the cursor moves down but not to the first column). In non-raw (non-TTY) mode,
806/// return the string unchanged.
807fn nl(s: &str, tty: bool) -> String {
808    if tty {
809        s.replace('\n', "\r\n")
810    } else {
811        s.to_owned()
812    }
813}
814
815/// Extracts text from a [`ContentBlock`]; returns `None` for non-text blocks.
816fn block_text(block: &ContentBlock) -> Option<String> {
817    match block {
818        ContentBlock::Text(t) => Some(t.text.clone()),
819        _ => None,
820    }
821}
822
823/// Writes a string to stdout.
824async fn write(out: &mut Stdout, s: &str) -> anyhow::Result<()> {
825    out.write_all(s.as_bytes()).await?;
826    Ok(())
827}
828
829/// On resume, replay a historical message using the **same visual language as live
830/// rendering** (see [`LineEditor::render_event`] / [`LineEditor::echo_submitted`]): a user
831/// message gets the `› ` prompt, assistant text is printed bare, thoughts are dimmed
832/// italic, tool calls show `⚙ name`, and tool results show `  ↳ status`. Keeping the two
833/// paths identical avoids the jarring style break a resumed session used to show. Display
834/// only; does not affect session state. Newlines rely on the terminal's cooked mode (raw
835/// mode is not yet active).
836async fn render_history_message(out: &mut Stdout, message: &Message) -> anyhow::Result<()> {
837    for content in message.content.iter() {
838        match (message.role, content) {
839            // User text mirrors the live input line: `› <text>`.
840            (Role::User, MessageContent::Text { text }) => {
841                write(out, &format!("{}{text}\n", USER_PROMPT.cyan().bold())).await?;
842            }
843            // Assistant text is streamed bare during a live turn — replay it the same way.
844            (Role::Assistant, MessageContent::Text { text }) => {
845                write(out, &format!("{text}\n")).await?;
846            }
847            (_, MessageContent::Thinking { text, .. }) => {
848                write(out, &format!("{}\n", text.dimmed().italic())).await?;
849            }
850            (_, MessageContent::ToolUse { name, .. }) => {
851                write(out, &format!("{} {}\n", "⚙".yellow(), name.yellow())).await?;
852            }
853            (_, MessageContent::ToolResult { is_error, .. }) => {
854                let label = if *is_error { "Failed" } else { "Completed" };
855                write(out, &format!("{} {label}\n", "  ↳".dimmed())).await?;
856            }
857            _ => {}
858        }
859    }
860    Ok(())
861}