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