feat(llm): inline chat panel (Alt+C) above the statusbar#64
Merged
Conversation
Adds a slim chat surface that pins to the bottom of the user's main
screen — shell stays visible above, cursor focus moves into the panel's
input row. Mutually exclusive with the full Alt+Shift+C overlay.
Pieces:
- Statusbar gains `setReserveRows` / `baseReserveRows` so the proxy can
grow / shrink the bottom reservation per-tick. Hint row anchors to the
base reservation rather than the live one so an expanded panel takes
the top rows of the reservation without overlapping the hint.
- Dispatcher gets `extraReserveRows`, a saturating-sum walker over each
module's optional `extraReserveRows` hook. Used by the proxy on every
iteration to decide whether to re-emit DECSTBM via `sb.activate`.
- LLM module:
* `chat_inline_open` + `chat_inline_input_buf` Runtime fields.
* `isInlineChatActive` + `extraReserveRows` hooks.
* `paintInlineChat` renders divider + recent turns + input row with
block-cursor + Alt+C / Enter shortcuts, anchored to the expanded
reservation. Save-cursor on open, restore on close.
* `llm_inline_chat_toggle` action closes the full overlay first if it
was open (mutual exclusivity), arms the inline paint latch.
* `onInput` routes printable / backspace / Enter into the inline
buffer when the panel is open, with the same Enter→dialog-fire path
as the overlay.
- System prompts (single + dialog) now describe both chat surfaces and
spell out that an `action=exec` envelope returned from chat injects a
command at the user's shell prompt — the LLM is meant to know that.
- Config: new `inline_chat_rows: u16 = 10` knob.
- Tests: panel paint chrome + reserve-rows toggle + overlay/inline
mutual exclusion + onInput routing.
`zig build test` + itest pass; `zig fmt --check` clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…servation, SIGWINCH, Context plumbing Subagent review of PR #64 flagged five correctness issues. Fixes: - **Mutual exclusion was one-way**: `llm_chat_overlay_toggle` didn't close `chat_inline_open`. Pressing Alt+C then Alt+Shift+C left both flags true, with `extraReserveRows` keeping the reservation grown. Now symmetric: both arms close the sibling surface and arm its repaint. - **`sb.activate` clears the whole screen on reserve toggle**: emitted `\x1B[2J`, wiping the user's visible shell history on every Alt+C — exactly what inline chat is meant to preserve. New `StatusBar.applyReserveRows(w, n)` does DECSC + clear-just-released rows (when shrinking) + DECSTBM + clear-new-reservation + DECRC. The proxy's reserve-edge handler now uses it. - **SIGWINCH didn't re-arm the inline panel**: resize updated the statusbar but left panel chrome at the old geometry until the next keystroke. Added `Dispatcher.notifyResize` + `Module.onResize` hook; LLM module flips `chat_inline_paint_pending` so the next term-bytes tick re-renders. - **`paintInlineChat` hardcoded `base_reserve = 3`**: replaced with Context plumbing. New `Context` fields `statusbar_base_reserve`, `statusbar_reserve`, `terminal_rows`, `terminal_cols` — proxy refreshes them per-iteration. Paint reads truth (incl. any proxy clamp for tiny terminals) instead of guessing. - **Statusbar hint-fallback**: documented the pathological case + added regression test pinning the hint to `rows - base + 1` even after `setReserveRows` grew the reservation. Tests added: - `applyReserveRows grows without screen-clear (no ED 2)` - `applyReserveRows shrinks: clears just-released rows` - `hint row anchors to base_reserve_rows after setReserveRows growth` - `inline chat: Alt+Shift+C closes inline panel first (reverse direction)` - `extraReserveRows: sums declaring modules, skips non-declaring, saturates` - `notifyResize: fires onResize on declaring modules only` Also tightened the `extraReserveRows` walker doc (reviewer #7) and dropped the now-stale `base_reserve: u16 = 3` TODO paragraph in `paintInlineChat`. `zig build test` + `zig fmt --check` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…clarify onResize arity Round 2 subagent review verdict was "approve" but flagged doc drift: - `chat_inline_open` comment said "statusbar.activate" — round 1 swapped to `applyReserveRows`. Updated. - `extraReserveRows` doc-block on the LLM module pointed at the old call path. Updated. - Header label "phase 2c" was the milestone NAME for this PR series; removed three deferred-feature references that read as forward pointers to work being done elsewhere now. - `Dispatcher.notifyResize` doc now spells out the single-arg signature contract so a future contributor doesn't accidentally add `rows, cols` args (silently dropped by the `inline for`). `zig build test` + `zig fmt --check` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ost over inline panel Round 3 subagent review found two functional gaps: 1. **Inline panel sat stale when LLM responded.** `pushTurn` is the single funnel for every new turn (user submit, assistant reply, observation). Re-arming the chat-paint latch there means the response surface — whether overlay or inline — re-renders on the next term-bytes tick instead of waiting for the next keystroke. `@hasField` guard keeps the helper reusable by the existing `FakeRuntime`-based test fixtures. 2. **Ghost text painted over the inline panel chrome.** `renderGhost` was gated only on subprocess/alt-screen state; the inline panel matches neither but parks the cursor inside its input row. Now gated on `D.anyInlineChatActive` too — mirror of the existing `D.anyOverlayActive` walker. Added regression test: `pushTurn arms paint latch when inline open`. `zig build test` + `zig fmt --check` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ate for chat keystrokes Two UX bugs flagged in round 4 subagent review: 1. **Stale ghost text after Alt+C.** The new suppression branch stopped FUTURE ghost paints but couldn't unpaint what was already on the shell's prompt row. Now the reserve-edge handler calls clearGhost before applyReserveRows, so the ghost row gets erased when the panel opens. 2. **`line_state.applyInput` polluted by chat keystrokes.** Inline chat's `onInput` returns `.swallow`, but the proxy was still feeding the typed bytes into `line_state` — so after Alt+C close, the line_state model held chat prose instead of real shell input, breaking atuin/history ghost matches until the user retyped. Now gated on `!D.anyInlineChatActive` (mirror of the existing alt-screen gate above). `zig build test` + `zig fmt --check` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… no statusbar Round 5 subagent review found two user-reachable footguns: 1. **`inline_chat_rows < 3` underflowed `panel_rows - 2`.** types.zig documented "Minimum 3" but never enforced. ReleaseSafe panics, ReleaseFast wraps to ~65534. Now `@compileError` at module factory entry — bad config caught at build time. 2. **Alt+C with `statusbar.enabled = false` trapped the user.** Without a statusbar, the reservation block in proxy.zig is skipped, so DECSTBM never changes; the panel paints at the screen bottom but the shell scrolls THROUGH it, and `onInput` swallows keystrokes with no visible feedback. Now the action handler refuses to open when `ctx.statusbar_reserve == null` (set only when statusbar is live), surfaces a hint explaining how to enable it, and falls back to stderr (since the hint surface itself depends on the statusbar being on). Mirror of the overlay's empty-session refusal pattern. Test fixtures updated to populate the new Context fields when they need the paint path to succeed; new test pins the no-statusbar refusal behaviour. `zig build test` (470 pass) + `zig fmt --check` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…to indicator + idempotent test
Round 6 subagent review found four actionable issues:
1. **SIGWINCH left the panel blank for ~50 ms.** `notifyResize` armed
the paint latch but the signal-branch code path doesn't drain
`gatherTermBytes`; the next paint waited for the timeout tick.
Now drain immediately after `notifyResize` so the panel re-renders
at the new geometry inside the same iteration.
2. **`extraReserveRows > rows` clamp was silent.** Setting
`inline_chat_rows = 50` on a 24-row terminal silently squeezed
the shell down to one row. Now surface a hint explaining the
clamp ("inline panel clamped — using N rows of M requested").
3. **Incognito didn't show in chat chrome.** The user could open
inline chat in incognito mode and have no visual indication.
The divider now swaps the ✨ sparkle for a 🕶 glasses glyph +
"(incognito)" label so the cue is at the top of the panel.
(Note: incognito gates LOCAL recording; chat prompts still ship
to the remote LLM API. The chrome states "won't be saved here,"
not "won't leave this box.")
4. **No idempotent `applyReserveRows` test.** Added one pinning
`n == current` = zero bytes emitted.
Pre-existing issues NOT fixed in this PR (filed as follow-ups):
- Subprocess context (`ssh foo@bar`) doesn't propagate to the chat
system prompt — `rt.context_blob` is built once at `attach`.
- Cursor-flicker on inline close (single-frame visual).
`zig build test` (471 pass) + `zig fmt --check` clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…label width
Round 7 review:
1. **Clamp hint fired every tick.** `setHint` invalidates the dedup
tracker on every call, so the proxy's clamp-active branch was
re-painting the hint row each iteration — visible cursor flicker
and wasted I/O. Now track `prev_requested_reserve` and fire
`setHint` only on the edge (requested-value transition).
2. **Incognito chrome label width was off by one.** 🕶 (`\u{1F576}`)
and ✨ render as double-width in Ghostty/kitty/foot/wezterm, so
the divider's trailer math under-counted; on narrow terminals
the chrome wrapped to the next row. Bumped `label_visible` to
26 / 15 and named the trailer constant.
`zig build test` (471 pass) + `zig fmt --check` clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…bel in modules.md Round 8 subagent verdict was "ship-ready" with two cosmetic followups: 1. `src/config.def.zig` — added a commented `.inline_chat_rows = 10` entry to the LLM module's example block so users see the knob when they read the seed template. 2. `docs/modules.md` — `isOverlayActive` example referenced `Alt+C` as the overlay binding (stale; now Alt+C is inline, Alt+Shift+C is overlay). Updated the paragraph to describe both surfaces and note that inline mode goes through `extraReserveRows`, not `isOverlayActive`. `zig build test` (471 pass) + `zig fmt --check` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…+C in same iter) Round 9 review found a race: when the user presses Alt+C AND the terminal resizes within the same `poll()` cycle, the iteration-top reservation block runs FIRST (before stdin sets `chat_inline_open`), so `sb.reserve_rows` stays at base. The SIGWINCH branch then ran `sb.activate` at the OLD reservation, and `paintInlineChat` saw `ctx.statusbar_reserve == base_reserve` → bailed with "terminal too small" + rolled `chat_inline_open` back. The user pressed Alt+C and got the error toast instead of the panel. Fix: inside the SIGWINCH branch, re-evaluate `extraReserveRows` AFTER `sb.onResize` updates dimensions, apply the new reserve via `setReserveRows`, and refresh the Context geometry fields before `sb.activate` runs. The subsequent `gatherTermBytes` drain at the new geometry now sees a consistent reservation. `zig build test` (471 pass) + `zig fmt --check` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Owner
Author
|
Review loop complete — 10 rounds (subagent-only mode; Copilot was added each round but its review side-channel didn't drive material findings). SummaryEach round caught real issues — none were rubber-stamps:
Final verdict: ship-ready, squash-merge recommended. Deferred (intentionally out of scope)
All 471 unit tests pass; 🤖 Generated with Claude Code |
This was referenced May 17, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
action=execfrom chat injects a command at the user's shell prompt — so the model knows it can drive the shell from inside the chat.Config.inline_chat_rowsknob (default 10) controls panel height.Architecture
StatusBar.setReserveRows(n)/baseReserveRows()— the proxy grows / shrinks the reservation per-tick. Hint row anchors to the base reservation, not the live one, so an expanded panel takes the top rows without overlapping the hint.Dispatcher.extraReserveRows(rts)— saturating-sum walker over each module'sextraReserveRowshook. Proxy calls it each iteration and re-emits DECSTBM viasb.activateon edges.paintInlineChat— divider chrome (✨ atty chat ─── … Alt+C close · Enter send) + recent turns (oldest→newest, last N fit) + input row with reverse-video block cursor.onInputmutually-exclusive routing: inline buffer whenchat_inline_open, else overlay buffer whenchat_overlay_open, else fall-through to existing flow.UX vocabulary (matches PR #53/#63 palette)
Test plan
zig build test -Dtarget=x86_64-linux-gnu— all unit tests pass, including three new tests covering toggle / reserve-rows / mutual-exclusion / input routing.zig build itest -Dtarget=x86_64-linux-gnu— PTY integration tests pass.zig fmt --check src/ build.zig— clean.🤖 Generated with Claude Code