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