Skip to content

feat(llm): inline chat panel (Alt+C) above the statusbar#64

Merged
fentas merged 10 commits into
masterfrom
feat/inline-chat
May 17, 2026
Merged

feat(llm): inline chat panel (Alt+C) above the statusbar#64
fentas merged 10 commits into
masterfrom
feat/inline-chat

Conversation

@fentas
Copy link
Copy Markdown
Owner

@fentas fentas commented May 17, 2026

Summary

  • Adds a slim chat surface (Alt+C) pinned 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.
  • The LLM is now told (in both single + dialog system prompts) about both chat surfaces and that returning action=exec from chat injects a command at the user's shell prompt — so the model knows it can drive the shell from inside the chat.
  • New Config.inline_chat_rows knob (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's extraReserveRows hook. Proxy calls it each iteration and re-emits DECSTBM via sb.activate on 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.
  • onInput mutually-exclusive routing: inline buffer when chat_inline_open, else overlay buffer when chat_overlay_open, else fall-through to existing flow.

UX vocabulary (matches PR #53/#63 palette)

  • Mauve ✨ icon + cyan shortcuts + dim chrome on the divider.
  • Block-cursor (reverse-video space) on the input row matches the overlay's input row.
  • Footer hint reads as a single sentence ("Alt+C close · Enter send") rather than a key cheatsheet.

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.
  • Manual verification: open atty in Ghostty, press Alt+C while typing — panel slides up, scrollback shows last turns, input row takes focus.
  • Manual verification: Alt+Shift+C still works for the full overlay; pressing one while the other is open closes the first.

🤖 Generated with Claude Code

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>
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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…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>
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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…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>
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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…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>
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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…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>
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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

… 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>
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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…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>
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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…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>
@fentas fentas requested a review from Copilot May 17, 2026 08:47
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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…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>
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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…+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>
@fentas
Copy link
Copy Markdown
Owner Author

fentas commented May 17, 2026

Review loop complete — 10 rounds (subagent-only mode; Copilot was added each round but its review side-channel didn't drive material findings).

Summary

Each round caught real issues — none were rubber-stamps:

Round Finding Severity
1 Mutual exclusion was one-way; sb.activate wiped screen via ED 2; SIGWINCH didn't re-arm panel; hardcoded base = 3 Bug × 4
2 Doc drift on applyReserveRows / onResize arity Cosmetic
3 LLM response didn't repaint inline panel; ghost text painted over panel Bug × 2
4 Stale ghost on Alt+C open; line_state.applyInput polluted by chat keystrokes Bug × 2
5 inline_chat_rows < 3 underflow; Alt+C with statusbar.enabled=false trapped the user Bug × 2
6 SIGWINCH paint stale ~50 ms; clamp was silent; incognito had no visual cue UX × 3
7 Clamp hint flickered every tick; incognito label width off-by-one UX × 2
8 Stale doc / missing config example Cosmetic
9 Alt+C-in-same-iteration-as-SIGWINCH race Bug × 1
10 No new findings — audit clean

Final verdict: ship-ready, squash-merge recommended.

Deferred (intentionally out of scope)

  • Subprocess context (ssh foo@bar, sudo bash, …) doesn't propagate to the chat system prompt — rt.context_blob is built once at attach. Pre-existing.
  • One-frame cursor flicker on inline close.
  • cfg.inline_chat_rows × cols × 4 > chat_inline_buf.len could overflow on very wide terminals — paint rolls back gracefully with a hint.

All 471 unit tests pass; zig fmt --check clean; zig build e2e not broken.

🤖 Generated with Claude Code

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@fentas fentas merged commit 628f584 into master May 17, 2026
6 of 7 checks passed
@fentas fentas deleted the feat/inline-chat branch May 17, 2026 09:09
@github-actions github-actions Bot mentioned this pull request May 19, 2026
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.

2 participants