Skip to content

feat(cli): add /btw for one-shot side questions - #268

Merged
ZouR-Ma merged 3 commits into
mainfrom
feat/btw-side-question
Oct 20, 2026
Merged

ZouR-Ma merged 3 commits into
mainfrom
feat/btw-side-question

Conversation

@lit26

@lit26 lit26 commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

What

/btw <question> asks a quick question without disturbing the main conversation. An overlay opens, answers, and closes on esc — nothing is appended to the transcript, the session file, or the model's context.

Why this shape

The obvious implementations both fail:

  • Send it as a normal turn — the reply is an ordinary assistant message, and interactive-mode.ts renders every one of those unconditionally. The answer appears twice: once in the overlay, once below it.
  • Hide it at render time — a global "skip the next assistant message" flag in front of the transcript fails as "your real answer vanished", which is worse than a duplicate.

So the side question never enters the agent loop at all. It goes through modelRegistry.complete(), which talks to the same provider with the same credentials but owns no agent and no session. There is nothing to append and nothing to rewind, a main turn in flight is untouched, and tools are unreachable because complete() has no tool loop.

Details worth a second look

  • Message fork. The question is sent against a copy of the current branch (buildContextEntries → convertToLlm), with a trailing assistant message holding unanswered tool calls dropped. That is exactly the state a streaming main turn leaves behind, and providers that validate message order reject it.
  • History. Prior exchanges live in an in-memory BtwHistory, capped at 20, and are replayed only into the next side question. Main turns never see them. Deliberately not persisted: writing it down would put the exchanges back into the session this command exists to keep them out of.
  • Spinner. Reuses pi-tui's Loader as a frame clock only — the frame is read out of its paint callback and the panel draws its own row, matching view/transcript/bash-execution.ts. The interval is released on both finish() and dispose(); the overlay host already calls dispose() on close.

Panel

esc close · c copy · x clear history · shift+←/→ browse earlier exchanges · ↑/↓ scroll. Shows (+N earlier /btw) when history is non-empty.

Tests

Three cases in test/step-slash-commands.test.ts (30 passing in that file):

  • answers out of band — asserts sendMessage is never called, the dangling tool call is stripped from the fork, the answer reaches the panel, the next side question carries the previous answer, and x clears it
  • spinner advances on the Loader clock, and finish() + dispose() stop requesting renders (no leaked timer per /btw)
  • rejects an empty question and a session with no model

pnpm check passes (biome + all 16 repo invariant checks + tsgo --noEmit).

Known gaps

  • No token streaming. complete() resolves with the finished message, so the panel shows the spinner and then the whole answer. Streaming would need stream() exposed on the ModelRegistry facade.
  • Not exercised in a live TUI — behaviour is derived from the overlay host contract (showExtensionCustom calls component.dispose() on close) rather than observed end to end.

Ask a question without disturbing the main conversation. `/btw <question>`
opens an overlay, answers it, and leaves the session untouched: nothing is
appended to the transcript, the session file, or the model's context.

The answer comes from `modelRegistry.complete()`, which talks to the same
provider with the same credentials but owns no agent and no session. That
is what keeps the exchange out of the conversation, and it also means a
main turn in flight is unaffected, since the side question never enters
the agent loop. Tools are unreachable for the same reason: `complete()`
has no tool loop.

The question is sent against a copy of the current branch's messages, with
a trailing assistant message holding unanswered tool calls dropped -- that
is exactly the state a streaming main turn leaves behind, and providers
that validate message order reject it.

Prior side questions are kept in an in-memory `BtwHistory` (last 20) and
replayed only into the next side question, so follow-ups have context
while main turns stay blind to them. It is deliberately not persisted:
writing it down would put the exchanges back into the session the command
exists to keep them out of.

Panel chords: esc close, c copy, x clear history, shift+left/right browse
earlier exchanges, up/down scroll. The waiting spinner reuses pi-tui's
Loader as a frame clock only, the way the bash card does, and its interval
is released on both finish() and dispose().
@ZouR-Ma
ZouR-Ma merged commit 999dcae into main Oct 20, 2026
2 checks passed
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