Skip to content

feat(llm): chat panel UX — emoji width, word wrap, multi-line input, resize#175

Merged
fentas merged 8 commits into
masterfrom
feat/chat-ux-paint-refactor
May 21, 2026
Merged

feat(llm): chat panel UX — emoji width, word wrap, multi-line input, resize#175
fentas merged 8 commits into
masterfrom
feat/chat-ux-paint-refactor

Conversation

@fentas
Copy link
Copy Markdown
Owner

@fentas fentas commented May 21, 2026

Summary

Closes the remaining bullets (#1#5) of #173 in one staged PR. Per-commit scope so the review loop sees clean atomic changes.

  • utf8 + display width (paint_width.zig): codepoint walker, coarse East-Asian-Width / emoji lookup, ASCII fast-path. New private module — replaces byte-slice truncation in subsequent commits.
  • paint.zig truncations use cols, not bytes: every text[0..cap] byte slice routes through pw.truncateToCols; every overflow check routes through pw.measureCols. Fixes (U+2022) and similar multi-byte chars rendering as when the cut landed mid-sequence.
  • word wrap: raw assistant turns wrap at last-space-in-row (hard-break on codepoint boundary if a token alone exceeds cols), capped at 3 rows per turn; overflow keeps the dim […] marker; full content still available in the alt-screen overlay.
  • multi-line input (Shift+Enter): kitty kbd \x1b[13;2u inserts \n into 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 from input_row by one row per embedded newline, capped at panel_rows / 2.
  • resize keys: Ctrl+Alt+Up/Down (llm_chat_inline_grow / llm_chat_inline_shrink actions) bump panel height ±1 row, clamped at 3 minimum. Override stored in Runtime.chat_inline_rows_override, resets on panel close.

PageUp/PageDown already handle scrollback (via existing chat_scroll_page_up/down bindings + chat_inline_view_offset plumbing) — 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 green
  • zig fmt --check src/ build.zig — clean
  • Build green on release-please's musl target
  • Manual: type in chat input + verify it renders correctly (no )
  • Manual: long assistant reply wraps across 3 rows with […] marker on overflow
  • Manual: Shift+Enter inserts a newline visible as a continuation row with dim glyph
  • Manual: Ctrl+Alt+Up grows panel; Alt+C close + reopen returns to default size

Closes #173.

🤖 Generated with Claude Code

fentas and others added 5 commits May 21, 2026 08:56
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/modules/llm/paint.zig Outdated
Comment thread src/modules/llm/paint.zig Outdated
Comment thread src/modules/llm/paint.zig Outdated
Comment thread src/modules/llm/hooks.zig
Comment thread src/modules/llm/hooks.zig
Comment thread src/modules/llm.zig Outdated
…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>
@fentas
Copy link
Copy Markdown
Owner Author

fentas commented May 21, 2026

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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Comment thread src/modules/llm/paint.zig Outdated
Comment thread src/modules/llm/paint.zig
Comment thread src/modules/llm/paint.zig Outdated
Comment thread src/modules/llm/hooks.zig Outdated
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>
@fentas
Copy link
Copy Markdown
Owner Author

fentas commented May 21, 2026

Review loop complete — 3 rounds (subagent + Copilot in parallel each round).

Findings addressed (14 total):

  • Round 1 — subagent: tab=0 cols, newest-turn anchor (start_turn one-row-per-turn assumption), dead empty-buffer fallback, windowing on continuation rows, keymap doc, renderTurnContent doc. Copilot: cursor mid-byte slice, label_buf re-cut, CSI scanner overflow, CSI-u scope, mod-7 doc.
  • Round 2 — subagent: oldest-turn-cap allocation bug (newest still clipped after round-1's back-walk). Copilot: renderWrappedRaw overflow-marker row overrun, UTF-8-unsafe windowing, stale comment, CSI scanner intermediate-byte gap.
  • Round 3 — subagent verdict ship; Copilot returned no new findings.

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.

@fentas fentas merged commit a7c4aa9 into master May 21, 2026
6 checks passed
@fentas fentas deleted the feat/chat-ux-paint-refactor branch May 21, 2026 07:56
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Comment thread src/modules/llm/paint.zig
Comment on lines +644 to +651
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");
Comment on lines +63 to +69
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;
Comment thread src/modules/llm.zig
Comment on lines +349 to +359
// 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 },
Comment thread src/keymap.zig
Comment on lines +195 to +196
/// closes. Default **Ctrl+Alt+Up / Ctrl+Alt+Down** (dual-
/// encoded: kitty kbd CSI-u + legacy modified-arrow).
fentas added a commit that referenced this pull request May 21, 2026
…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>
fentas added a commit that referenced this pull request May 21, 2026
…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>
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.

feat(llm): inline chat UX — emojis, multi-line input, word wrap, resize, scroll

2 participants