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}