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}