feat(llm): chat panel UX — emoji width, word wrap, multi-line input, resize#175
Conversation
New private `paint_width.zig` module: ASCII fast-path, coarse East- Asian-Width / emoji range lookup, codepoint-boundary truncation. Replaces byte-slice truncation in subsequent commits so glyphs like `•` (U+2022 = 3 bytes) stop rendering as `�` when the cut lands mid-sequence. Scope is deliberately minimal — Ambiguous defaults to 1 col (Western terminals); the emoji-default-presentation subset of 0x2600..0x27BF is enumerated rather than blanket-billed; Wide covers CJK / Hangul / fullwidth / regional indicators / pictographs / supplemental symbols. 13 tests covering ascii / bullet / ellipsis / CJK / emoji / combining marks / truncation / invalid-sequence recovery. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routes every `slice = text[0..cap]` byte-slice through `paint_width.truncateToCols`, and every `text.len > cap` overflow check through `paint_width.measureCols`. Fixes the `•` (U+2022) and similar multi-byte chars rendering as `�` when the byte-prefix cut landed in the middle of a UTF-8 sequence. Wide glyphs (CJK, emoji like ✨) now bill 2 columns so trailing chrome stops getting pushed off the row. Sites touched: chat-overlay raw-fallback + per-action render (exec/question/done/choices), inline-panel renderTurnContent (envelope + raw), inline-panel divider label_visible math + clamp. 778/778 unit tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inline chat panel turns previously truncated at one row with a dim `[…]` marker — fine for envelope summaries, useless for raw LLM prose where the actual reply lived past the cut. Raw turns now wrap at the last space inside the row (hard-break on codepoint boundary when a token alone exceeds cols), capped at 3 rows per turn so one long reply can't push the rest of the scrollback off-panel. Overflow keeps the `[…]` marker; the full content remains in the alt-screen overlay (Alt+Shift+C). paint_width.zig gains `WrapIterator` + `wrapIter()`; 8 tests cover short / word-break / multi-word / hard-break / wide-char / empty / all-whitespace / cols=0 fallback. renderTurnContent now returns rows used; the scrollback loop advances `row` by that count instead of the previous hardcoded `+= 1`. Envelope turns still single-row — the structured `desc → cmd` summary doesn't gain readability from being split. 786/786 unit tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shift+Enter (kitty kbd `\x1b[13;2u`) inserts `\n` into the inline chat input buffer instead of submitting. Plain Enter still submits the buffer verbatim (newlines preserved → sent as-is to the LLM). Empty-check accepts buffers of pure whitespace+newlines as no-op so accidental Shift+Enter strokes don't fire an empty request. The input area grows up from `input_row` by one row per embedded newline, capped at `panel_rows / 2` so scrollback isn't completely starved. Continuation rows show a dim `…` chrome glyph at col 1 to distinguish them from new turns. Cursor glyph (reverse-video block) lands on whichever line contains the cursor byte. parseChatKey's digit + param-scan branches collapse into one unified scanner that handles both `<n>~` (VT-style function keys) and `<n>;<mod>u` (CSI-u modified keys); other CSI shapes still get consumed silently so private/unknown sequences don't leak as printables. Two new tests: Shift+Enter inserts the newline + cursor advances; all-whitespace buffer + plain Enter clears without firing a request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New actions `llm_chat_inline_grow` / `llm_chat_inline_shrink` bump the panel height by one row per press, clamped to a minimum of 3 rows (divider + 1 scrollback + input) and a loose upper sanity cap. The live height lives in `Runtime.chat_inline_rows_override` and resets to `null` on every panel close, so the next open starts at the configured `cfg.inline_chat_rows` default. Bindings ship as dual-encoded Ctrl+Alt+Up / Ctrl+Alt+Down (legacy modified-arrow `\x1b[1;7A/B` + kitty kbd CSI-u sibling); both forms reach the same action so the keystroke lands on every terminal atty targets. `extraReserveRows` and paint both consult the override so the proxy's `applyReserveRows` path grows the statusbar reservation on the next tick. PageUp/PageDown already handle scrollback (existing `chat_scroll_page_up/down` bindings via `chat_inline_view_offset`) — no new scroll keys added. Three new tests cover grow + clamp at 3 + reset-on-close. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR improves the LLM inline chat panel UX by making rendering column-aware for UTF-8, adding word-wrapping for long turns, supporting multi-line input via Shift+Enter, and enabling live panel resize (grow/shrink) via new keymap actions.
Changes:
- Added a UTF-8 codepoint iterator + coarse display-width measurement/truncation utilities and routed LLM paint truncation through them.
- Implemented wrapped rendering for raw (non-envelope) turns in the inline panel and multi-line input painting.
- Added new actions/keybindings for inline panel grow/shrink and reset the height override on panel close.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/proxy.zig | Allows new resize actions to be dispatched to the LLM module. |
| src/modules/llm/paint.zig | Switches truncation to column-aware logic, adds wrapped turn rendering, multi-line input painting, and uses live panel height override. |
| src/modules/llm/paint_width.zig | Introduces UTF-8 iteration + display width, truncation, and wrap iterator helpers. |
| src/modules/llm/paint_width_tests.zig | Adds unit tests for width measurement, truncation, and wrapping behavior. |
| src/modules/llm/hooks.zig | Extends CSI parsing for kitty CSI-u (Shift+Enter) and adds inline panel resize action handling + whitespace-only submit behavior. |
| src/modules/llm/hooks_tests.zig | Adds tests for Shift+Enter newline insertion, whitespace-only submit no-op, and resize actions. |
| src/modules/llm.zig | Adds default keybindings for inline panel grow/shrink and adds runtime field for height override. |
| src/modules/llm_tests.zig | Ensures the new paint_width module tests are discovered by the unit test runner. |
| src/keymap.zig | Adds new keymap actions for inline panel grow/shrink. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ion rows Subagent review round 1 — addresses the two blocker findings and three smaller items: - **Newest turn was getting clipped** when prior multi-row turns filled the scrollback budget. Previous `start_turn = visible_end - scrollback_budget` math assumed one row per turn; with wrap enabled it anchored OLDEST turns at the panel top instead. Now walks backwards from `visible_end`, summing each candidate's rendered-row claim (mirroring the wrap iterator used at paint time) so the newest turn stays anchored at the bottom and only the OLDEST visible turn gets row-capped. - **TAB was billed as 0 cols** by `displayWidth` (and `Utf8Iterator`'s ASCII fast-path), but `writeSanitized` passes tabs through to the terminal. Chat content with tabs was mis-truncated and mis-wrapped. Treat as 1 col — conservative approximation but consistent with how the rest of paint counts. - **Empty-buffer fallback in paintInputBlock was dead code** — the main loop already paints prompt+cursor for the `pos == 0 == buf.len` iteration. Dropped the duplicate path. - **Apply 512-byte cursor windowing on every input line**, not just the first — long pasted prompts with embedded newlines previously let continuation rows overflow. - **Doc fixes**: keymap `llm_chat_inline_grow` referenced a non-existent `inline_chat_rows_min`; renderTurnContent narrated WHAT instead of WHY. +2 regression tests: newest-turn-anchor + TAB width. 793/793 unit tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Subagent review round 1 (fix-and-ship verdict) — 10 findings. Addressed in 86d995d:
Deferred (small, can land in follow-up):
793/793 unit + 6/6 itest + fmt clean. |
Copilot review round 1 — six findings, five addressed: - **#1 cursor cell mid-byte slice** — `paintInputLine` rendered the cell under the cursor as `line[c_abs..c_abs+1]`, which cut multi- byte codepoints (`•`, emoji) mid-sequence and the terminal drew `�`. Now walks the UTF-8 codepoint starting at `c_abs` via `pw.utf8Iter` and slices the full byte range. Tail slice (`c_abs + cell_advance .. win_end`) follows. - **#2 label_buf re-cut** — the chat divider's provider-label truncation used `min(truncated.len, label_buf.len - 3)` to bound the @memcpy, which could re-cut a codepoint-aligned slice mid- sequence on a wide terminal. Capped `label_cap` at 32 cols (provider labels are model names — never longer in practice), bumped `label_buf` to 256 bytes, dropped the inner min. - **#5 UTF-8 paste through parseChatKey** — printable-insert arm only accepted 0x20..0x7E, so pasting `•` (0xE2 0x80 0xA2) hit `.none` for every byte. Extended to 0x80..0xFF — the terminal reassembles the codepoint on render. Also handles a bare CSI-u ASCII codepoint (`\x1b[N;1u` with N in 0x20..0x7E) as an insert fallback for terminals at higher progressive-enhancement levels. - **#4 CSI scanner overflow guard** — `p1 = p1 * 10 + (x - '0')` had no overflow check; malicious input with a long digit run could trap in safety builds. Cap accumulation at 1M. - **#6 doc nit** — `\x1b[1;7A` is xterm modifier 7 = Ctrl+Alt (not Shift+Alt+Ctrl, which is 8). Comment fixed. Deferred: #3 (continuation-row indent under role prefix — UX preference, not a bug). +1 test pinning `•` paste through onInput. 794/794 unit + fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review-loop round 2 — five findings, all addressed: - **Subagent #3 (newest-turn tail clip)**: round-1 fix anchored `start_turn` correctly via back-walk, but the render loop walked oldest→newest with a generic `min(per_turn_max_rows, remaining)` cap that still let the newest turn eat the deficit. Now the back- walk records `oldest_turn_cap = rows_remaining` when forced to include-and-clip; the render loop applies it ONLY to the first rendered (oldest) turn. Newer turns each keep their full per-turn claim. Regression test seeds 4 × 3-row turns with the marker in chunk 3 of the newest — verified fail-without/pass-with. - **Copilot #1 (paint.zig:486)**: `renderWrappedRaw` appended the `" […]"` overflow marker after a full-width chunk, pushing it past `cols`. Restructured the loop to look one chunk ahead via a `pending` slot: when the last allowed row is about to flush AND more content remains, trim the pending chunk to `cols - 5` and append the marker inline so the row stays within cols. - **Copilot #2 (paint.zig:578)**: input-line windowing used raw byte offsets; UTF-8 content >512 bytes could land win_start / win_end on a continuation byte and the first emitted slice would be invalid. Now snaps both edges forward past any continuation byte (`(b & 0xC0) == 0x80`). - **Copilot #3 (paint.zig:282)**: 1024-col comment claimed byte-for-byte parity with the old 1024-byte path; truncateToCols can return more bytes when content has zero-width chars. Comment now states the col semantics explicitly. - **Copilot #4 (hooks.zig:165)**: CSI scanner trigger only fired on param bytes (0x30..0x3F); intermediates in 0x20..0x2F (e.g. `ESC [ ! p`) fell through to the consume-3-byte arm and could leak their tail. Broadened to 0x20..0x3F (full P+I range per ECMA-48). 794/794 unit tests + fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Review loop complete — 3 rounds (subagent + Copilot in parallel each round). Findings addressed (14 total):
Deferred: per_turn_max_rows → defaults.zig (config knob), scrollback continuation indent under role prefix (UX preference), CSI-u multi-byte codepoint support (needs ChatKey state machine), test brittleness if inline_chat_rows default changes. 794/794 unit tests + 6/6 itest + zig fmt clean across all rounds. |
| if (dim) try w.writeAll("\x1B[2m"); | ||
| if (c_abs > win_start) try writeSanitized(w, line[win_start..c_abs]); | ||
| if (dim) try w.writeAll("\x1B[0m"); | ||
| if (focus) { | ||
| if (c_abs < len) { | ||
| try w.writeAll("\x1B[7m"); | ||
| try writeSanitized(w, line[c_abs .. c_abs + cell_advance]); | ||
| try w.writeAll("\x1B[0m"); |
| pub fn displayWidth(cp: u21) u8 { | ||
| if (cp == 0x09) return 1; | ||
| if (cp < 0x20 or cp == 0x7F) return 0; | ||
| if (cp < 0x80) return 1; | ||
| if (isZeroWidth(cp)) return 0; | ||
| if (isWide(cp)) return 2; | ||
| return 1; |
| // per press. Dual encoded: `keymap.key("Ctrl+Alt+Up")` | ||
| // resolves to the kitty kbd CSI-u form on terminals | ||
| // that pushed flag 1; `\x1b[1;7A` is the legacy xterm | ||
| // modifyOtherKeys encoding (modifier 7 = Ctrl+Alt; | ||
| // Shift+Alt+Ctrl would be 8). Ctrl+Alt+Arrow is | ||
| // rarely consumed by terminals so the binding lands | ||
| // reliably. | ||
| .{ .bytes = keymap.key("Ctrl+Alt+Up"), .action = .llm_chat_inline_grow, .label = "Ctrl+Alt+Up", .description = "inline chat: grow panel by one row" }, | ||
| .{ .bytes = "\x1b[1;7A", .action = .llm_chat_inline_grow }, | ||
| .{ .bytes = keymap.key("Ctrl+Alt+Down"), .action = .llm_chat_inline_shrink, .label = "Ctrl+Alt+Down", .description = "inline chat: shrink panel by one row" }, | ||
| .{ .bytes = "\x1b[1;7B", .action = .llm_chat_inline_shrink }, |
| /// closes. Default **Ctrl+Alt+Up / Ctrl+Alt+Down** (dual- | ||
| /// encoded: kitty kbd CSI-u + legacy modified-arrow). |
…istory The hardcoded `per_turn_max_rows = 3` truncated long replies with a dim `[…]` marker even when the scrollback budget had plenty of room. We already shipped PageUp/PageDown scrollback in #175 — truncation on top of it is just a wart that hides content the user came to see. Removed: each turn now renders its full wrap-chunk count, capped only by the scrollback budget. The back-walk's `oldest_turn_cap` path still handles the natural panel boundary when total demand exceeds budget — the OLDEST visible turn gets clipped (with the `[…]` marker), the newest always renders in full. `countTurnRows` now receives `scrollback_budget` as the upper bound instead of a fixed 3. The marker logic in `renderWrappedRaw` stays exactly the same — it just fires far less often. Regression test asserts the LAST sentinel of a 5-chunk turn lands in the paint output. Verified fail-without/pass-with (had the hardcoded 3-row cap, marker `EEEE-LAST-CHUNK-MARKER` was dropped). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ation (#176) * fix(llm): arm chat panel repaint when Alt+M cycles provider The chat panel divider renders the active provider name via `resolveProviderForMode(.chat, …)` on every paint, but the cycle action only nudged `current_provider_idx` and emitted a statusbar hint — neither chat surface's paint latch was armed, so the divider's `lo-qwen` (or whatever the previous provider) stayed visible until some other event triggered a repaint. The user saw the cycle reflected in the statusbar but not in the panel chrome. Now sets `chat_inline_paint_pending` / `chat_overlay_paint_pending` on every successful cycle so the next term-bytes tick re-emits the divider with the new label. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(llm): drop per-turn 3-row cap — render full content, scroll for history The hardcoded `per_turn_max_rows = 3` truncated long replies with a dim `[…]` marker even when the scrollback budget had plenty of room. We already shipped PageUp/PageDown scrollback in #175 — truncation on top of it is just a wart that hides content the user came to see. Removed: each turn now renders its full wrap-chunk count, capped only by the scrollback budget. The back-walk's `oldest_turn_cap` path still handles the natural panel boundary when total demand exceeds budget — the OLDEST visible turn gets clipped (with the `[…]` marker), the newest always renders in full. `countTurnRows` now receives `scrollback_budget` as the upper bound instead of a fixed 3. The marker logic in `renderWrappedRaw` stays exactly the same — it just fires far less often. Regression test asserts the LAST sentinel of a 5-chunk turn lands in the paint output. Verified fail-without/pass-with (had the hardcoded 3-row cap, marker `EEEE-LAST-CHUNK-MARKER` was dropped). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(llm): writeSanitized walks codepoints, preserves UTF-8 continuation bytes Em-dash `—` (U+2014 = E2 80 94) and other 3- or 4-byte UTF-8 sequences with continuation bytes in 0x80..0x9F were getting corrupted: the C1-control filter dropped those continuation bytes as if they were bare C1 controls, leaving an orphan leading byte that the terminal rendered as `�`. The 2-byte special case for `C2 + 80..9F` covered NBSP-region pairs but nothing wider. Rewrote `writeSanitized` to walk codepoints via `pw.utf8Iter` and check the C1 range against the DECODED codepoint, not the raw bytes. Multi-byte sequences re-emit verbatim; only actual C0/C1 controls + DEL get dropped (TAB still passes through, CR/LF still collapse to a single space). Regression test pins em-dash, bullet (U+2022), sparkle emoji, and a CJK glyph through the inline-panel paint path and asserts each survives intact with no U+FFFD replacement char in the output. Verified fail-without/pass-with. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Closes the remaining bullets (#1–#5) of #173 in one staged PR. Per-commit scope so the review loop sees clean atomic changes.
text[0..cap]byte slice routes throughpw.truncateToCols; every overflow check routes throughpw.measureCols. Fixes•(U+2022) and similar multi-byte chars rendering as�when the cut landed mid-sequence.[…]marker; full content still available in the alt-screen overlay.\x1b[13;2uinserts\ninto the chat input buffer; plain Enter still submits (newlines preserved → sent verbatim to LLM); all-whitespace-incl-newlines buffers are no-op'd. Input area grows up frominput_rowby one row per embedded newline, capped atpanel_rows / 2.llm_chat_inline_grow/llm_chat_inline_shrinkactions) bump panel height ±1 row, clamped at 3 minimum. Override stored inRuntime.chat_inline_rows_override, resets on panel close.PageUp/PageDown already handle scrollback (via existing
chat_scroll_page_up/downbindings +chat_inline_view_offsetplumbing) — bullet #5 of #173 doesn't need new scroll keys.Test plan
zig build test— 791/791 unit tests green (+13 paint_width tests, +3 Shift+Enter / empty-check tests, +3 resize action tests)zig build itest— 6/6 integration tests greenzig fmt --check src/ build.zig— clean•in chat input + verify it renders correctly (no�)[…]marker on overflow…glyphCloses #173.
🤖 Generated with Claude Code