Skip to content

fix(cli): translate LF→CRLF in streaming sink to stop rendered drift#125

Merged
emal-avala merged 1 commit intomainfrom
fix/stream-render-indent
Apr 15, 2026
Merged

fix(cli): translate LF→CRLF in streaming sink to stop rendered drift#125
emal-avala merged 1 commit intomainfrom
fix/stream-render-indent

Conversation

@emal-avala
Copy link
Copy Markdown
Member

Diagnosis — print layer, not markdown

The drift you saw (every newline indented to the column where the previous line ended) is not a markdown rendering bug — markdown is never applied during streaming at all. It's a raw-mode terminal bug introduced by #106.

Since #106 landed `spawn_escape_watcher` to make Escape/Ctrl+C interrupt the agent, the watcher calls `crossterm::terminal::enable_raw_mode()` at the start of a turn and keeps it on until the turn completes. In raw mode a bare `\n` moves the cursor down one row without returning to column 0 — the tty layer no longer translates LF to CRLF for you. The streaming sink was still emitting `print!("{text}")` and `println!()` and `eprintln!(...)`, so every newline the LLM produced dropped the cursor onto the next row at the column where the previous line ended. Over a multi-paragraph reply the text walks off the right edge, which matches both examples you posted exactly.

The tui helpers (`render_tool_block`, `render_turn_summary`, `render_thinking_block`, `render_status_bar`) were already correct — they go through `render_lines_to_ansi` which writes `\r\n`. Only the raw `print!`/`println!`/`eprintln!` call sites inside `TerminalSink` and the turn-error branch in `run_repl` were broken.

Fix

Two private helpers at the top of `crates/cli/src/ui/repl.rs`:

  • `raw_print(text)` — writes to stdout with LF → CRLF translation, flushes.
  • `raw_eprint(text)` — same for stderr.

Both normalize any existing `\r\n` first so translation is idempotent (no `\r\r\n`). Bare `\r` (used by `ActivityIndicator` to rewrite the status line) passes through unchanged.

All eight `print!`/`println!`/`eprintln!` sites inside the raw-mode window now route through them:

  • `TerminalSink::ensure_newline`
  • `TerminalSink::on_text`
  • `TerminalSink::on_tool_result` (both success and error branches)
  • `TerminalSink::on_error`
  • `TerminalSink::on_compact`
  • `TerminalSink::on_warning`
  • The turn-error branch in `run_repl`

Print sites outside the turn (setup wizard, shortcut panel, session summary, rustyline output) are untouched — they run in cooked mode.

Tests

Added a `raw_print_tests` module with 8 unit tests covering the translation table, including the two edge cases that matter:

  • existing `\r\n` must not become `\r\r\n`
  • bare `\r` must pass through (or the activity indicator stops rewriting in place)

```
running 8 tests
test ui::repl::raw_print_tests::existing_crlf_is_preserved_not_doubled ... ok
test ui::repl::raw_print_tests::bare_lf_becomes_crlf ... ok
test ui::repl::raw_print_tests::cr_followed_by_text_then_lf ... ok
test ui::repl::raw_print_tests::empty_string ... ok
test ui::repl::raw_print_tests::lone_cr_is_not_touched ... ok
test ui::repl::raw_print_tests::mixed_lf_and_crlf ... ok
test ui::repl::raw_print_tests::text_without_newlines_is_unchanged ... ok
test ui::repl::raw_print_tests::multiple_newlines_all_translated ... ok

test result: ok. 8 passed
```

Manual test plan

  • `cargo run -p agent-code`, ask for a multi-paragraph reply (e.g. "explain what pwd does"), confirm the response renders flush-left with no drift.
  • Run a turn that triggers a tool (`ls`, then a file read), confirm the tool blocks and result previews render flush-left.
  • Trigger an error mid-turn (bad tool input), confirm the ERROR label is flush-left.
  • Press Esc mid-stream to confirm fix: make Escape and Ctrl+C actually interrupt agent during streaming #106's interrupt behavior still works.
  • Press Ctrl+C mid-stream to confirm cancellation path still works.

Followup not in this PR

The other bug visible in your first paste — where three successive prompts `pwd` / `ls` / `fetch ...` got concatenated into `/doctoepwdlsfetch latest origin/main` and fed as a single input — looks like a separate rustyline / input-buffer issue, not a rendering issue. Worth filing separately; happy to pick it up next.

Since #106 added the escape-key watcher, the terminal is held in raw
mode for the entire duration of a streaming turn. In raw mode a bare
`\n` moves the cursor down one row without returning to column 0, so
every newline the LLM emits caused the next line to start at the
column where the previous line ended. Over a multi-paragraph reply the
text drifted off the right edge of the terminal.

Route every print site inside the raw-mode window through two new
helpers (`raw_print` / `raw_eprint`) that translate LF to CRLF before
writing. The tui renderer (`render_tool_block`, `render_turn_summary`,
etc.) already emits `\r\n` via `render_lines_to_ansi`, so it's
untouched. The unaffected print paths outside the turn (setup wizard,
shortcut panel, session summary) are also untouched.

Eight unit tests cover the translation table, including the two
edge cases that matter at runtime: existing `\r\n` must not become
`\r\r\n`, and bare `\r` (used by the activity indicator to rewrite the
status line) must pass through unchanged.
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@emal-avala emal-avala merged commit 954a268 into main Apr 15, 2026
13 of 14 checks passed
@emal-avala emal-avala deleted the fix/stream-render-indent branch April 15, 2026 08:59
emal-avala added a commit that referenced this pull request Apr 15, 2026
Highlights since v0.15.2:
- feat(sandbox): Linux bwrap strategy for the Bash tool (#124)
- fix(cli): translate LF->CRLF in streaming sink to stop rendered drift (#125)
- fix(llm): propagate cancel token into provider streaming task (#126)
emal-avala added a commit that referenced this pull request Apr 15, 2026
Highlights since v0.15.2:
- feat(sandbox): Linux bwrap strategy for the Bash tool (#124)
- fix(cli): translate LF->CRLF in streaming sink to stop rendered drift (#125)
- fix(llm): propagate cancel token into provider streaming task (#126)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant