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