mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(cli): render the OpenTUI confirmation as one surface and bound the transcript flood
The mem0 confirmation scenario exposed three stacked gaps in the OpenTUI renderer: the awaiting-approval path never reached the transcript model, the expanded dialog body overflowed a fixed alt-screen viewport, and the confirming card duplicated the payload the dialog already carries. Emit confirm/confirm-resolved transcript events from the real scheduler awaiting path, render the expanded body as a tail window budgeted against the terminal height (alt-screen has no scrollback to fall back to), let the pending card yield its description to the dialog, and cap every tool card description to a bounded head with ink's own hidden-tail vocabulary. A shared config.initialize() once-guard stops submit-time "Chat not initialized" races, and a batch where every call was cancelled now ends the turn without a follow-up model request, matching the ink stream. The mem0 e2e post-approval wait moves to request bodies so it does not depend on raw pty bytes; both legs pass all five scenarios.
This commit is contained in:
1 parent
9c320cb0cc
commit
f6213a18cd
12 files changed
+921
-39
No files matched your search
@@ -0,0 +1,230 @@
|
||||
# Batch 10 — OpenTUI parity closeout (dialogs, entry, composer, transcript, recording, resume)
|
||||
|
||||
Design doc for the merged Batch 10/11 closeout. The two seam cuts were merged
|
||||
into one PR on 2026-09-04 (author call, recorded in the ledger): U-33 and U-34
|
||||
both fill the `item-projection.ts` null arm and U-32/U-11 both touch the
|
||||
steering drain path, so a seam split would open the same file to two reviews.
|
||||
One item per commit keeps each round readable. Plan paragraph in the
|
||||
[#8662 ledger](https://github.com/QwenLM/qwen-code/issues/8662).
|
||||
|
||||
## Problem
|
||||
|
||||
Three groups of gaps, all verified against main `9c320cb0cc` before planning:
|
||||
|
||||
1. **Entry/composer gates (G-1, G-2, G-3)**: the auth dialog never auto-opens
|
||||
(only the trigger is missing — the dialog itself is already mounted), no
|
||||
follow-up suggestions exist under `ui/opentui/`, and the reserved
|
||||
update-notification banner slot is unfilled.
|
||||
2. **Projection/recording gaps (U-33, U-34, U-32, U-11)**: the `!` shell mode
|
||||
is advertised but nonexistent, four dedicated-component history kinds
|
||||
project to nothing, steered messages are visible (Batch 9) but not recorded
|
||||
to the chat log, and the mid-turn queue lifecycle needs an authoritative
|
||||
separator decision.
|
||||
3. **The harness the batch's acceptance runs against** (U-31): the mem0-write
|
||||
interactive spec self-spawns its PTY and never picks up the renderer
|
||||
matrix, so its OpenTUI leg exercised ink — a false green inside the gating
|
||||
leg, registered as an adjacent finding in the Batch 9 doc.
|
||||
|
||||
U-31 was dispatched first because every other item's e2e acceptance runs
|
||||
through that spec; the wiring immediately surfaced two app defects (U-36,
|
||||
U-37) and two more behavioral divergences (all-cancelled continuation,
|
||||
confirming-card flood), all fixed inside this batch rather than deferred —
|
||||
the harness leg cannot go green while any of its five scenarios hangs or
|
||||
floods.
|
||||
|
||||
## Decision 1 — U-31: wire the self-spawning spec to the renderer matrix
|
||||
|
||||
`external-context-mem0-write.test.ts` now calls `pickE2eRenderer` /
|
||||
`resolveE2eCliCommand` / `e2eRendererEnv` like every matrix-aware spec, and
|
||||
keeps its own `runInteractive` PTY harness (it drives scenarios
|
||||
`InteractiveSession` cannot express: mid-turn hook confirmations with
|
||||
content-visible bodies). The opentui leg carries `QWEN_TUI_RENDERER_STRICT`,
|
||||
so a silent ink fallback fails the boot instead of passing as a false green.
|
||||
|
||||
The wiring's first run turned the prediction from the Batch 9 doc into
|
||||
measured fact: two app defects and an observation-channel trap, each covered
|
||||
by the decisions below.
|
||||
|
||||
## Decision 2 — the observation channel is the reconstructed screen, not raw PTY bytes
|
||||
|
||||
OpenTUI redraws by cell diff: over a previously-blank background it emits the
|
||||
glyph cell without the space cells a full-width row would carry, so
|
||||
`waitForText` (a `stripAnsi(...).includes(...)` match on the raw byte stream,
|
||||
with no whitespace normalization) on a multi-word row is a coin flip — four
|
||||
scenarios timed out in the first wired run on strings the same code path had
|
||||
rendered, and one passed only because diff history happened to align. The
|
||||
spec's flow-gating assertions moved to xterm-headless screen reconstruction
|
||||
(`waitForScreen` over the 110×38 viewport, polling 200 ms), the same channel
|
||||
`InteractiveSession.screen()` uses, which is faithful on both legs. The ink
|
||||
leg passed 5/5 with the screen-based spec unchanged, which pins the channel
|
||||
as leg-neutral. Single-token matches (the suite-wide canary, the turn-done
|
||||
marker) stay on `waitForText` deliberately: they are insensitive to the
|
||||
mechanism and keep a second independent channel in the spec.
|
||||
|
||||
## Decision 3 — U-36: config initialization is a shared once-guard, not a swallowed retry
|
||||
|
||||
The OpenTUI leg hung on `Chat not initialized`: `config.initialize()` is
|
||||
asynchronous while the first render is not, so a prompt submitted before
|
||||
initialization settles reached the chat before the chat existed — and the
|
||||
old `try { await config.initialize() } catch {}` in `livePromptEvents`
|
||||
swallowed the failure, leaving no screen signal. Command loading already
|
||||
calls `initialize()` concurrently and a second concurrent call rejects, so
|
||||
"call it again and wait" was not available.
|
||||
|
||||
`ensureConfigInitialized(config)` in `live-session.ts` keeps a
|
||||
`WeakMap`-keyed shared promise: first caller wins, everyone awaits the same
|
||||
settlement, a loser that observes `isInitialized()` proceeds. The entry calls
|
||||
it fire-and-forget before the first render (so boot overlaps initialization
|
||||
without serializing startup), and the turn path awaits the same promise. The
|
||||
swallow is removed: a genuine initialization failure now propagates to the
|
||||
screen instead of hanging.
|
||||
|
||||
## Decision 4 — U-37: the MCP confirmation asks ink's question
|
||||
|
||||
ink's MCP confirmation renders the question line
|
||||
`Allow execution of MCP tool "{{tool}}" from server "{{server}}"?`
|
||||
(`ToolConfirmationMessage.tsx` builds its own question; core supplies only
|
||||
the title). OpenTUI showed title + display name + `server · tool` and no
|
||||
question. The mcp case in `dialogs-confirm.tsx` gained the question line
|
||||
through `t()`. The wired spec gates on exactly this row, so the fix is
|
||||
e2e-observed on both legs.
|
||||
|
||||
## Decision 5 — U-13: TextBody keeps its head and offers ctrl-s (scope widened from the notice)
|
||||
|
||||
Registered as a cosmetic one-liner (add the `... N lines hidden ...` notice
|
||||
`DiffBody` already has), the ledger's 2026-09-04 verification found the real
|
||||
ink behavior is richer and OpenTUI's was worse than "no notice":
|
||||
`TextBody` used a **tail** window (`tailWindow`) while ink's info confirmation
|
||||
uses MaxSizedBox `overflowDirection 'bottom'` — it keeps the **head** and
|
||||
hides the tail, with ShowMoreLines' hardcoded `Press ctrl-s to show more
|
||||
lines` hint and a ctrl-s expand. A tail window also hides exactly the rows
|
||||
that make a confirmation reviewable.
|
||||
|
||||
`TextBody` now mirrors ink: `headWindowPhysical` in `messages.tsx`, the
|
||||
hidden-tail indicator, the hint row, and a one-way ctrl-s expand
|
||||
(`setExpanded(true)`, no collapse — ink's ShowMoreLines has no collapse
|
||||
either). The window is **physical-height** aware: the mem0 confirmation's
|
||||
`renderMemoryContentForConfirmation` is `JSON.stringify`, which collapses 142
|
||||
content lines into ONE logical row (~3.9k chars) that wraps to ~36 physical
|
||||
rows — a logical-row window (the original `headWindow` draft) counts it as 1
|
||||
row and never fires. `headWindowPhysical` estimates `ceil(len/cols)` per
|
||||
logical row and slices an over-budget row's head. Unit tests pin both the
|
||||
multi-row and single-mega-row shapes.
|
||||
|
||||
## Decision 6 — a whole-batch cancellation ends the turn without a follow-up request
|
||||
|
||||
Found by scenario 2 of the wired spec: after the user rejects the hook
|
||||
confirmation with Esc, OpenTUI sent a **second model request** where ink sends
|
||||
none (`AssertionError: expected fakeModel.requests to have length 1 but got
|
||||
2`). Debug-log forensics (token-count provenance lines 350 ms apart, +71
|
||||
tokens = the cancelled tool response) pinned the mechanism: the continuation
|
||||
loop built `responseParts` from the cancelled call's `responseParts` and
|
||||
submitted them as a ToolResult hop.
|
||||
|
||||
ink pins the semantics (use-llm-stream.ts:5328-5358): when
|
||||
`llmTools.every((tc) => tc.status === 'cancelled')` (and no duplicate
|
||||
responses are pending), the cancelled functionResponses go to history via
|
||||
`addHistory` and the turn ends. Ported into `livePromptEvents` as an
|
||||
early-return **before** the steering drain, so steered texts that queued
|
||||
during a cancelled batch stay parked for the post-turn queue instead of
|
||||
riding a request that will never be sent. This is a pre-existing OpenTUI
|
||||
behavioral difference, never observable before the wiring because scenario 2
|
||||
always died at the MCP gate first.
|
||||
|
||||
Measured twice: the new unit test (fake scheduler honors `__cancelled`,
|
||||
asserts one `sendMessageStream` call and one `addHistory` with
|
||||
`role: 'user'`) goes red with `called 1 times, but got 2 times` when the
|
||||
early-return is disabled, green when restored — the e2e failure's exact
|
||||
signature.
|
||||
|
||||
## Decision 7 — the confirmation is one surface: the dialog carries the payload
|
||||
|
||||
Found by scenario 5: the MCP tool card's description is the tool's full args
|
||||
JSON (core `mcp-tool.ts` returns `safeJsonStringify(this.params)` — ink
|
||||
renders the same through ToolMessage), which wrapped to ~50 physical rows and
|
||||
pushed the content-confirmation dialog off the 38-row viewport. Three root
|
||||
layers, each discriminated by a progressively richer failure dump
|
||||
(`.qwen/u31-opentui-fix{8,9,10,11*}-scenario5.log`):
|
||||
|
||||
1. **The confirm transcript event never fired on the real path.**
|
||||
`livePromptEvents` never emitted `type: 'confirm'` — only event-adapter's
|
||||
pipeline did — so no card ever had `confirm: 'pending'`, and the pending
|
||||
`(awaiting approval)` marker never rendered either. The fix emits
|
||||
`type: 'confirm'` once per `awaiting_approval` entrance (deduped through
|
||||
the same `waitingSeen` set that gates `onWaitingCall`, so a PreToolUse
|
||||
'ask' bounce re-marks the card), plus `type: 'confirm-resolved'` when a
|
||||
call leaves the state — without the latter the card would keep claiming
|
||||
"awaiting approval" while the call executes. A/B measured: without the
|
||||
event the collapsed step times out on the full JSON flood; with it the
|
||||
dialog appears.
|
||||
2. **The expanded body overflowed.** ctrl-s worked but a full render
|
||||
(~40 rows) inside a fixed alt-screen viewport clips at the bottom —
|
||||
ink reaches the tail through terminal scrollback, which alt-screen does
|
||||
not have. The expanded branch now renders a **tail** window
|
||||
(`tailWindowPhysical`, the mirror of `headWindowPhysical`) budgeted as
|
||||
terminal height minus `EXPANDED_BODY_RESERVE_ROWS` (dialog chrome plus the
|
||||
transcript region that stays put above the dialog), with no hidden-lines
|
||||
label — ink's observable expanded contract is "tail on screen, no
|
||||
indicator". Unit tests pin the tail window's fits/mega-row/whole-row
|
||||
shapes and the collapsed→expanded transition on the JSON payload.
|
||||
3. **The card duplicates the payload the dialog already carries.** With the
|
||||
dialog machinery working end-to-end, the only remaining predicate
|
||||
violator was the card's own capped description and its
|
||||
`... last N lines hidden ...` label lingering on screen after the body
|
||||
expanded (ink renders the confirmation as ONE inline surface; its
|
||||
expansion scrolls the card away). While `confirm === 'pending' && !done`
|
||||
the card therefore renders **no description at all** — the dialog below
|
||||
is the payload surface.
|
||||
|
||||
The cap survives in generalized form: ink bounds EVERY tool card through the
|
||||
static-area height distribution (MaxSizedBox), while OpenTUI's per-item
|
||||
budget (`maxHistoryItemRows` = terminalHeight×4) never engages on a
|
||||
single-logical-row JSON wrapping to dozens of physical rows — the resolved
|
||||
card flooded all 38 rows and pushed the post-approval turn's output out of
|
||||
the viewport (fix11 dump: `✓ mcp__external-context__…` + full JSON rows 02-37,
|
||||
nothing else visible, despite `providerRequests=1 modelRequests=2` proving
|
||||
the turn had completed). `capToolCardDescription` + `TOOL_CARD_DESCRIPTION_ROWS`
|
||||
now bound every card description head to 5 wrapped rows with
|
||||
`hiddenTailLinesLabel` summarizing the tail — the same character-based wrap
|
||||
estimate as before, applied unconditionally instead of only when pending. The
|
||||
per-item height distribution itself stays open as transcript-region work.
|
||||
|
||||
## The nine ledger items (to be recorded as they land)
|
||||
|
||||
U-6/G-1 (auth auto-open trigger), U-7/G-2 (follow-up suggestions),
|
||||
U-9 (settings sub-dialog routing + `fillInput` owner), G-3 (update-check
|
||||
wiring), U-33 (the `!` shell row), U-34 (four row shapes), U-32 (steer
|
||||
recording), U-11 (queue separator decision), U-13 (landed — Decision 5).
|
||||
Each lands as its own commit in this PR with its decision recorded here.
|
||||
|
||||
## Coverage boundary
|
||||
|
||||
Verified on the final state (both legs, `QWEN_CODE_LANG=en`):
|
||||
|
||||
- `interactive/external-context-mem0-write.test.ts` — **5/5 on OpenTUI and
|
||||
5/5 on ink** (`.qwen/u31-opentui-final.log`, `.qwen/u31-ink-final.log`).
|
||||
Scenario 5 exercises the whole Decision 7 chain on the OpenTUI leg:
|
||||
collapsed head + literal markdown + `lines hidden` + ctrl-s → expanded tail
|
||||
with `CONFIRM_TAIL` and no indicator → approval → turn completes. Each
|
||||
fix layer was mutation-verified by a distinct red: no confirm event
|
||||
(fix8), expanded clip (fix9), card label on screen (fix10), resolved-card
|
||||
flood (fix11); the request counters inside the fix11 error message
|
||||
(`providerRequests=1 modelRequests=2`) are what separated the render flood
|
||||
from a real stall.
|
||||
- **Post-approval sync changed on both legs**: `rig.waitForText`
|
||||
reads the raw pty stream, which the OpenTUI leg redraws by cell diff —
|
||||
rendered transcript rows never reliably appear there (the test already
|
||||
used screen-based waits for the confirmation itself). The approve-branch
|
||||
now polls request bodies (`fakeModel.requests.length >= 2`) —
|
||||
renderer-independent, and the load-bearing post-approval semantics stay
|
||||
pinned by the server-side assertions (`providerRequests`,
|
||||
request-path/body). Ink behavior is unchanged in outcome.
|
||||
- Unit: 138 tests across `messages.test.tsx` / `dialogs-confirm.test.tsx` /
|
||||
`live-session.test.ts` / `live-session-model.test.ts` pin the window
|
||||
arithmetic, the confirm/confirm-resolved sequence (incl. the hook-bounce
|
||||
re-entrance), and the cap math — mock-level evidence only; the end-to-end
|
||||
claim rests on the two e2e runs above. `npm run typecheck` and
|
||||
`npm run lint` clean.
|
||||
- Not covered: transcript per-item height distribution (the cap is the
|
||||
interim bound), OpenTUI transcript autoscroll, and any renderer not in the
|
||||
two tested legs.
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
type FakeOpenAIServer,
|
||||
} from '../fake-openai-server.js';
|
||||
import { TestRig, type } from '../test-helper.js';
|
||||
import {
|
||||
e2eRendererEnv,
|
||||
pickE2eRenderer,
|
||||
resolveE2eCliCommand,
|
||||
} from '../renderer-matrix.js';
|
||||
|
||||
const SANDBOX_MODE = process.env['QWEN_SANDBOX']?.toLowerCase().trim();
|
||||
const IS_SANDBOX = Boolean(
|
||||
@@ -196,10 +201,14 @@ const ENVIRONMENT_KEYS = [
|
||||
await type(ptyProcess, '\r');
|
||||
|
||||
if (scenario.expectsMcpConfirmation) {
|
||||
expect(
|
||||
await rig.waitForText('Allow execution of MCP tool', 30_000),
|
||||
// Screen-based, not rig.waitForText: OpenTUI emits pty bytes by cell
|
||||
// diff and drops spaces over previously-blank cells, so multi-word
|
||||
// rows never appear verbatim in the raw stream on that leg.
|
||||
await waitForScreen(
|
||||
screen,
|
||||
(value) => value.includes('Allow execution of MCP tool'),
|
||||
'ordinary MCP confirmation did not appear',
|
||||
).toBe(true);
|
||||
);
|
||||
await type(ptyProcess, '\r');
|
||||
}
|
||||
|
||||
@@ -246,22 +255,32 @@ const ENVIRONMENT_KEYS = [
|
||||
expect(confirmationScreen).toContain('`code-value`');
|
||||
expect(confirmationScreen).toContain('<u>under-value</u>');
|
||||
} else {
|
||||
expect(
|
||||
await rig.waitForText(
|
||||
'Save this exact content to the bound Mem0 repository memory?',
|
||||
30_000,
|
||||
),
|
||||
await waitForScreen(
|
||||
screen,
|
||||
(value) =>
|
||||
value.includes(
|
||||
'Save this exact content to the bound Mem0 repository memory?',
|
||||
) && value.includes('Keep this'),
|
||||
'content-visible Hook confirmation did not appear',
|
||||
).toBe(true);
|
||||
expect(rig._interactiveOutput).toContain('Keep this');
|
||||
);
|
||||
}
|
||||
await type(ptyProcess, scenario.approveWrite ? '\r' : '\x1b');
|
||||
|
||||
if (scenario.approveWrite) {
|
||||
expect(
|
||||
await rig.waitForText('MEM0_WRITE_E2E_DONE', 30_000),
|
||||
'fake model turn did not complete',
|
||||
).toBe(true);
|
||||
// Sync on request bodies, not transcript text: the OpenTUI leg
|
||||
// redraws by cell diff, so rendered rows never reliably appear in
|
||||
// the raw pty stream (see the screen-based waits above).
|
||||
const modelRequests = fakeModel.requests;
|
||||
const turnCompleted = await rig.poll(
|
||||
() => modelRequests.length >= 2,
|
||||
30_000,
|
||||
200,
|
||||
);
|
||||
if (!turnCompleted) {
|
||||
throw new Error(
|
||||
`fake model turn did not complete. providerRequests=${providerRequests.length} modelRequests=${modelRequests.length}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
expect(fakeModel.requests).toHaveLength(1);
|
||||
@@ -432,6 +451,7 @@ function fakeMem0McpSource(integrationRoot: string): string {
|
||||
|
||||
function runInteractive(rig: TestRig, ...args: string[]) {
|
||||
rig._interactiveOutput = '';
|
||||
const renderer = pickE2eRenderer();
|
||||
const { Terminal } = xtermHeadless;
|
||||
const terminal = new Terminal({
|
||||
cols: 110,
|
||||
@@ -441,14 +461,17 @@ function runInteractive(rig: TestRig, ...args: string[]) {
|
||||
});
|
||||
let pendingWrite = Promise.resolve();
|
||||
const ptyProcess = pty.spawn(
|
||||
process.execPath,
|
||||
resolveE2eCliCommand(renderer),
|
||||
[rig.bundlePath, '--no-chat-recording', ...args],
|
||||
{
|
||||
name: 'xterm-color',
|
||||
cols: 110,
|
||||
rows: 38,
|
||||
cwd: rig.testDir!,
|
||||
env: process.env as Record<string, string>,
|
||||
env: {
|
||||
...process.env,
|
||||
...e2eRendererEnv(renderer),
|
||||
} as Record<string, string>,
|
||||
},
|
||||
);
|
||||
ptyProcess.onData((data) => {
|
||||
|
||||
@@ -60,6 +60,7 @@ vi.mock('@opentui/react', () => ({
|
||||
useKeyboard: (handler: (key: unknown) => void) => {
|
||||
mocks.state.keyboardHandlers.push(handler);
|
||||
},
|
||||
useTerminalDimensions: () => ({ width: 110, height: 40 }),
|
||||
}));
|
||||
vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime());
|
||||
vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime());
|
||||
@@ -135,7 +136,7 @@ describe('buildOutcomeOptions', () => {
|
||||
});
|
||||
|
||||
describe('OpenTuiToolConfirmation', () => {
|
||||
function press(key: { name: string; sequence?: string }) {
|
||||
function press(key: { name: string; sequence?: string; ctrl?: boolean }) {
|
||||
act(() => {
|
||||
for (const handler of mocks.state.keyboardHandlers) handler(key);
|
||||
});
|
||||
@@ -231,4 +232,100 @@ describe('OpenTuiToolConfirmation', () => {
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the ink question line for MCP tool confirmations', () => {
|
||||
const { container } = render(
|
||||
<OpenTuiToolConfirmation
|
||||
call={{
|
||||
callId: 'call-1',
|
||||
name: 'mcp__external-context__context_remember',
|
||||
confirmationDetails: {
|
||||
type: 'mcp',
|
||||
title: 'Confirm MCP Tool Execution',
|
||||
serverName: 'external-context',
|
||||
toolName: 'context_remember',
|
||||
toolDisplayName: 'context_remember',
|
||||
onConfirm: onConfirmNoop,
|
||||
},
|
||||
}}
|
||||
onSettled={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
'Allow execution of MCP tool "context_remember" from server "external-context"?',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the head of a long info body and expands it on ctrl-s', () => {
|
||||
const lines = [
|
||||
'BODY_TOP',
|
||||
...Array.from(
|
||||
{ length: 24 },
|
||||
(_, index) => `body-line-${index.toString().padStart(2, '0')}`,
|
||||
),
|
||||
'BODY_TAIL',
|
||||
];
|
||||
const { container } = render(
|
||||
<OpenTuiToolConfirmation
|
||||
call={{
|
||||
callId: 'call-1',
|
||||
name: 'hook_gate',
|
||||
confirmationDetails: {
|
||||
type: 'info',
|
||||
title: 'Save this content?',
|
||||
prompt: lines.join('\n'),
|
||||
onConfirm: onConfirmNoop,
|
||||
},
|
||||
}}
|
||||
onSettled={() => {}}
|
||||
/>,
|
||||
);
|
||||
const collapsed = container.textContent ?? '';
|
||||
expect(collapsed).toContain('BODY_TOP');
|
||||
expect(collapsed).toContain('... last 7 lines hidden ...');
|
||||
expect(collapsed).toContain('Press ctrl-s to show more lines');
|
||||
expect(collapsed).not.toContain('BODY_TAIL');
|
||||
|
||||
press({ name: 's', ctrl: true });
|
||||
const expanded = container.textContent ?? '';
|
||||
expect(expanded).toContain('BODY_TAIL');
|
||||
expect(expanded).not.toContain('lines hidden');
|
||||
expect(expanded).not.toContain('Press ctrl-s to show more lines');
|
||||
});
|
||||
|
||||
it('caps a single-line JSON payload by its wrapped height', () => {
|
||||
const prompt =
|
||||
'Save this exact content to the bound Mem0 repository memory?\n' +
|
||||
JSON.stringify(`CONFIRM_TOP ${'x'.repeat(3000)} CONFIRM_TAIL`);
|
||||
const { container } = render(
|
||||
<OpenTuiToolConfirmation
|
||||
call={{
|
||||
callId: 'call-1',
|
||||
name: 'hook_gate',
|
||||
confirmationDetails: {
|
||||
type: 'info',
|
||||
title: 'Save this content?',
|
||||
prompt,
|
||||
renderPromptAsPlainText: true,
|
||||
onConfirm: onConfirmNoop,
|
||||
},
|
||||
}}
|
||||
onSettled={() => {}}
|
||||
/>,
|
||||
);
|
||||
const collapsed = container.textContent ?? '';
|
||||
expect(collapsed).toContain('CONFIRM_TOP');
|
||||
expect(collapsed).toContain('lines hidden');
|
||||
expect(collapsed).toContain('Press ctrl-s to show more lines');
|
||||
expect(collapsed).not.toContain('CONFIRM_TAIL');
|
||||
|
||||
press({ name: 's', ctrl: true });
|
||||
const expanded = container.textContent ?? '';
|
||||
// The expanded tail window surfaces the end of the payload (the alt-screen
|
||||
// viewport has no scrollback, so the tail must be on screen) with no
|
||||
// hidden-lines label left behind.
|
||||
expect(expanded).toContain('CONFIRM_TAIL');
|
||||
expect(expanded).not.toContain('lines hidden');
|
||||
expect(expanded).not.toContain('Press ctrl-s to show more lines');
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
type ToolCallConfirmationDetails,
|
||||
type ToolConfirmationPayload,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { useKeyboard } from '@opentui/react';
|
||||
import { useKeyboard, useTerminalDimensions } from '@opentui/react';
|
||||
import { C } from './theme.js';
|
||||
import { toOriginalKey } from './key-map.js';
|
||||
import {
|
||||
@@ -53,7 +53,12 @@ import {
|
||||
type DialogListItem,
|
||||
} from './dialogs-shared.js';
|
||||
import { renderDiffBody } from './diff-render.js';
|
||||
import { tailWindow } from './messages.js';
|
||||
import {
|
||||
headWindowPhysical,
|
||||
hiddenTailLinesLabel,
|
||||
tailWindow,
|
||||
tailWindowPhysical,
|
||||
} from './messages.js';
|
||||
import { sanitizeTerminalText } from '../utils/textUtils.js';
|
||||
import type { ShellConfirmationResolution } from './commands-context.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
@@ -68,6 +73,15 @@ export interface PendingToolConfirmation {
|
||||
/** Max body rows before the tail window truncates (keeps dialogs bounded). */
|
||||
const MAX_BODY_ROWS = 20;
|
||||
|
||||
/**
|
||||
* Rows reserved above/below an EXPANDED body: dialog chrome (frame, title,
|
||||
* options, footer) plus the transcript region that keeps its place above the
|
||||
* dialog. The expanded tail window is budgeted as terminal height minus this
|
||||
* reserve, so the end of the content — where the options still are — stays on
|
||||
* screen (ink reaches the same visible outcome through terminal scrollback).
|
||||
*/
|
||||
const EXPANDED_BODY_RESERVE_ROWS = 20;
|
||||
|
||||
interface OutcomeOption {
|
||||
label: string;
|
||||
value: ToolConfirmationOutcome;
|
||||
@@ -127,18 +141,63 @@ function DiffBody({ fileDiff }: { fileDiff: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Plain, sanitized, line-bounded text body. */
|
||||
/**
|
||||
* Plain, sanitized text body. Long bodies keep their head (ink MaxSizedBox
|
||||
* overflowDirection 'bottom' parity) with a hidden-tail indicator plus the
|
||||
* ink ShowMoreLines hint; ctrl-s expands the full text. The cap counts
|
||||
* WRAPPED rows — a single JSON-stringified payload line can wrap to dozens
|
||||
* of physical rows, which a logical-row window never bounds.
|
||||
*/
|
||||
function TextBody({ text }: { text: string }) {
|
||||
const rows = useMemo(() => {
|
||||
const clean = sanitizeTerminalText(text);
|
||||
const window = tailWindow(clean.split('\n'), MAX_BODY_ROWS);
|
||||
return window.visible;
|
||||
}, [text]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { width, height } = useTerminalDimensions();
|
||||
const rows = useMemo(() => sanitizeTerminalText(text).split('\n'), [text]);
|
||||
const window = useMemo(
|
||||
() => headWindowPhysical(rows, width, MAX_BODY_ROWS),
|
||||
[rows, width],
|
||||
);
|
||||
const expandedWindow = useMemo(
|
||||
() =>
|
||||
tailWindowPhysical(
|
||||
rows,
|
||||
width,
|
||||
Math.max(height - EXPANDED_BODY_RESERVE_ROWS, 1),
|
||||
),
|
||||
[rows, width, height],
|
||||
);
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (key.ctrl && toOriginalKey(key).name === 's') setExpanded(true);
|
||||
});
|
||||
|
||||
if (expanded) {
|
||||
// No hidden-lines indicator once expanded: ink's expanded screen shows
|
||||
// the tail with no label (its head lives in terminal scrollback), and
|
||||
// the alt-screen viewport has no scrollback to point at.
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
{expandedWindow.visible.map((row, i) => (
|
||||
<text key={`${i}`}>{row}</text>
|
||||
))}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
if (window.hiddenRows === 0) {
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
{rows.map((row, i) => (
|
||||
<text key={`${i}`}>{row}</text>
|
||||
))}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
{rows.map((row, i) => (
|
||||
{window.visible.map((row, i) => (
|
||||
<text key={`${i}`}>{row}</text>
|
||||
))}
|
||||
<text fg={C.dim}>{hiddenTailLinesLabel(window.hiddenRows)}</text>
|
||||
<text fg={C.dim}>Press ctrl-s to show more lines</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -180,6 +239,17 @@ function ConfirmationBody({
|
||||
case 'mcp':
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<text>
|
||||
{sanitizeTerminalText(
|
||||
t(
|
||||
'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?',
|
||||
{
|
||||
tool: details.toolName,
|
||||
server: details.serverName,
|
||||
},
|
||||
),
|
||||
)}
|
||||
</text>
|
||||
<text fg={C.accent} attributes={1}>
|
||||
{sanitizeTerminalText(details.toolDisplayName)}
|
||||
</text>
|
||||
|
||||
@@ -73,6 +73,9 @@ export type OpenTuiStreamEvent =
|
||||
visionBridgeNotice?: string;
|
||||
}
|
||||
| { type: 'confirm'; id: string; tool: string; title: string }
|
||||
/** The call left awaiting_approval (approved, declined, or bounced):
|
||||
* releases the transcript card's pending marker. */
|
||||
| { type: 'confirm-resolved'; id: string }
|
||||
/** Structured compression item (/compress command): rendered as the ink
|
||||
* CompressionMessage row (spinner/diamond + token counts) instead of the
|
||||
* flattened text projection. */
|
||||
|
||||
@@ -317,6 +317,16 @@ export function foldLiveEvent(
|
||||
});
|
||||
return items;
|
||||
}
|
||||
case 'confirm-resolved': {
|
||||
const i = findToolIndex(items, ev.id);
|
||||
if (i >= 0) {
|
||||
const t = items[i] as LiveToolItem;
|
||||
if (t.confirm === 'pending') {
|
||||
items[i] = { ...t, confirm: 'approved' };
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
case 'task-start':
|
||||
if (last?.kind === 'assistant' && last.streaming)
|
||||
items[items.length - 1] = { ...last, streaming: false };
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
VisionBridgeModelSelection,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
ensureConfigInitialized,
|
||||
livePromptEvents,
|
||||
nextApprovalMode,
|
||||
resetPromptCountForTesting,
|
||||
@@ -137,7 +138,10 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
name: c.name ?? 'test_tool',
|
||||
args: c.args ?? {},
|
||||
},
|
||||
status: 'success',
|
||||
status:
|
||||
((c.args ?? {}) as { __cancelled?: boolean }).__cancelled === true
|
||||
? 'cancelled'
|
||||
: 'success',
|
||||
response: {
|
||||
responseParts: [
|
||||
{
|
||||
@@ -276,6 +280,90 @@ describe('livePromptEvents', () => {
|
||||
expect(options).toEqual({ type: SendMessageType.UserQuery });
|
||||
});
|
||||
|
||||
it('shares one initialization promise across callers', async () => {
|
||||
const initialize = vi.fn(async () => {});
|
||||
const config = { initialize } as unknown as Config;
|
||||
|
||||
await Promise.all([
|
||||
ensureConfigInitialized(config),
|
||||
ensureConfigInitialized(config),
|
||||
]);
|
||||
|
||||
expect(initialize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('waits for the shared initialization before the first send', async () => {
|
||||
let resolveInitialize!: () => void;
|
||||
const initialize = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveInitialize = resolve;
|
||||
}),
|
||||
);
|
||||
const sendMessageStream = vi.fn(function* () {
|
||||
yield { type: 'finished', value: {} };
|
||||
});
|
||||
const config = {
|
||||
...createFakeConfig(sendMessageStream),
|
||||
initialize,
|
||||
} as unknown as Config;
|
||||
|
||||
const gen = livePromptEvents(config, 'hello');
|
||||
const drained = drain(gen);
|
||||
|
||||
// The registry loader's initialize is still in flight; the turn must not
|
||||
// send while it runs (the pre-fix behavior died with "Chat not
|
||||
// initialized" here).
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(sendMessageStream).not.toHaveBeenCalled();
|
||||
|
||||
resolveInitialize();
|
||||
await drained;
|
||||
|
||||
expect(initialize).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('proceeds when another caller already initialized the config', async () => {
|
||||
const sendMessageStream = vi.fn(function* () {
|
||||
yield { type: 'finished', value: {} };
|
||||
});
|
||||
const config = {
|
||||
...createFakeConfig(sendMessageStream),
|
||||
initialize: vi.fn(async () => {
|
||||
throw new Error('Config was already initialized');
|
||||
}),
|
||||
getGeminiClient: () => ({
|
||||
sendMessageStream,
|
||||
isInitialized: () => true,
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
await drain(livePromptEvents(config, 'hello'));
|
||||
|
||||
expect(sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('surfaces a failed initialization instead of "Chat not initialized"', async () => {
|
||||
const sendMessageStream = vi.fn(function* () {});
|
||||
const config = {
|
||||
...createFakeConfig(sendMessageStream),
|
||||
initialize: vi.fn(async () => {
|
||||
throw new Error('auth exploded');
|
||||
}),
|
||||
getGeminiClient: () => ({
|
||||
sendMessageStream,
|
||||
isInitialized: () => false,
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
await expect(drain(livePromptEvents(config, 'hello'))).rejects.toThrow(
|
||||
'auth exploded',
|
||||
);
|
||||
expect(sendMessageStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the ink promptId format and increments promptCount per turn', async () => {
|
||||
const sendMessageStream = vi.fn(function* () {});
|
||||
const config = createFakeConfig(sendMessageStream);
|
||||
@@ -1265,6 +1353,55 @@ describe('livePromptEvents', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('yields a confirm event per awaiting_approval entrance (transcript pending marker)', async () => {
|
||||
let calls = 0;
|
||||
const sendMessageStream = vi.fn(function* (): Generator<{
|
||||
type: string;
|
||||
value?: unknown;
|
||||
}> {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
yield {
|
||||
type: 'tool_call_request',
|
||||
value: {
|
||||
callId: 'b2',
|
||||
name: 'run_shell_command',
|
||||
args: { __bounceApproval: true },
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
yield { type: 'finished', value: {} };
|
||||
});
|
||||
const config = createFakeConfig(sendMessageStream);
|
||||
|
||||
const events = (await drain(
|
||||
livePromptEvents(config, 'q'),
|
||||
)) as OpenTuiStreamEvent[];
|
||||
|
||||
// One event per entrance: the initial parking and the PreToolUse 'ask'
|
||||
// bounce back under the same callId, with the pending marker released
|
||||
// in between (the executing update drops the call from awaiting).
|
||||
const confirms = events.filter(
|
||||
(e) => e.type === 'confirm' || e.type === 'confirm-resolved',
|
||||
);
|
||||
expect(confirms).toEqual([
|
||||
{
|
||||
type: 'confirm',
|
||||
id: 'b2',
|
||||
tool: 'run_shell_command',
|
||||
title: 'original',
|
||||
},
|
||||
{ type: 'confirm-resolved', id: 'b2' },
|
||||
{
|
||||
type: 'confirm',
|
||||
id: 'b2',
|
||||
tool: 'run_shell_command',
|
||||
title: 'Hook requested confirmation to run',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('pushes the real invocation description once per callId (R1-104)', async () => {
|
||||
let calls = 0;
|
||||
const sendMessageStream = vi.fn(function* (): Generator<{
|
||||
@@ -1329,6 +1466,34 @@ describe('livePromptEvents', () => {
|
||||
expect(events.some((e) => e.type === 'tool-description')).toBe(false);
|
||||
});
|
||||
|
||||
it('ends the turn without a follow-up request when the whole batch was cancelled', async () => {
|
||||
const sendMessageStream = oneToolBatchStream({
|
||||
callId: 'c1',
|
||||
name: 'test_tool',
|
||||
args: { __cancelled: true },
|
||||
});
|
||||
const addHistory = vi.fn();
|
||||
const config = {
|
||||
...createFakeConfig(sendMessageStream),
|
||||
getGeminiClient: () => ({ sendMessageStream, addHistory }),
|
||||
} as unknown as Config;
|
||||
|
||||
await drain(livePromptEvents(config, 'run'));
|
||||
|
||||
// ink use-llm-stream parity: cancelled responses go to history only,
|
||||
// so the model is never asked to continue the turn.
|
||||
expect(sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
expect(addHistory).toHaveBeenCalledTimes(1);
|
||||
const content = addHistory.mock.calls[0][0] as {
|
||||
role: string;
|
||||
parts: Array<{ functionResponse?: { id?: string } }>;
|
||||
};
|
||||
expect(content.role).toBe('user');
|
||||
expect(content.parts.some((p) => p.functionResponse?.id === 'c1')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
describe('tool execution live output (outputUpdateHandler)', () => {
|
||||
it('streams tool-output events while the tool executes', async () => {
|
||||
const sendMessageStream = oneToolBatchStream({
|
||||
|
||||
@@ -544,6 +544,34 @@ async function resolveSteeredPromptParts(
|
||||
return { parts, events, restore: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Single shared `config.initialize()` for the OpenTUI app. Config.initialize()
|
||||
* throws on re-entry instead of returning the in-flight promise (its private
|
||||
* `initializationPromise` has no public accessor), so a submit landing while
|
||||
* the command-registry loader's initialization is still running must await the
|
||||
* SAME promise — awaiting `config.initialize()` directly rejects, and sending
|
||||
* with `chat` unset dies with "Chat not initialized". The entry calls this
|
||||
* once before the first render; the turn path awaits the cached promise.
|
||||
*/
|
||||
const initializationPromises = new WeakMap<Config, Promise<void>>();
|
||||
|
||||
export function ensureConfigInitialized(config: Config): Promise<void> {
|
||||
const pending = initializationPromises.get(config);
|
||||
if (pending) return pending;
|
||||
const started = config.initialize().catch((err) => {
|
||||
initializationPromises.delete(config);
|
||||
// Another caller (command loading) won the race. If its initialization
|
||||
// settled with a chat, sending is safe; otherwise surface the failure.
|
||||
if (config.getGeminiClient()?.isInitialized?.()) return;
|
||||
throw err;
|
||||
});
|
||||
// Fire-and-forget callers (the entry) must not trip unhandled-rejection
|
||||
// handling; awaiters still receive the rejection.
|
||||
started.catch(() => {});
|
||||
initializationPromises.set(config, started);
|
||||
return started;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one user prompt through the real client and yields neutral events.
|
||||
* The caller (backend) drains this into the streaming model.
|
||||
@@ -559,11 +587,7 @@ export async function* livePromptEvents(
|
||||
signal?: AbortSignal,
|
||||
options?: LivePromptOptions,
|
||||
): AsyncGenerator<OpenTuiStreamEvent> {
|
||||
try {
|
||||
await config.initialize();
|
||||
} catch {
|
||||
/* already initialized by command loading / startup */
|
||||
}
|
||||
await ensureConfigInitialized(config);
|
||||
const client = config.getGeminiClient();
|
||||
const promptId = options?.promptId ?? nextLivePromptId(config);
|
||||
const abort = signal ?? new AbortController().signal;
|
||||
@@ -764,24 +788,39 @@ export async function* livePromptEvents(
|
||||
description: invocation.getDescription(),
|
||||
});
|
||||
}
|
||||
if (!options?.onWaitingCall) return;
|
||||
// Mirror the calls still awaiting approval: one that left the state
|
||||
// (resolved, or bounced back by a PreToolUse 'ask' hook under the
|
||||
// same callId) must be able to surface its dialog again.
|
||||
// same callId) must be able to surface its dialog again. Each
|
||||
// entrance also marks the transcript card pending (event-adapter
|
||||
// 'confirm' parity): it shows the awaiting marker and hides the
|
||||
// card's duplicated description — the confirmation dialog below is
|
||||
// the payload surface, and an MCP card's full JSON getDescription()
|
||||
// would otherwise flood the column and push the dialog off-screen.
|
||||
const awaiting = new Set(
|
||||
calls
|
||||
.filter((c) => c.status === 'awaiting_approval')
|
||||
.map((c) => c.request.callId),
|
||||
);
|
||||
for (const id of waitingSeen) {
|
||||
if (!awaiting.has(id)) waitingSeen.delete(id);
|
||||
if (!awaiting.has(id)) {
|
||||
waitingSeen.delete(id);
|
||||
// Release the transcript card's pending marker — without this it
|
||||
// would keep claiming "awaiting approval" while the call runs.
|
||||
live.push({ type: 'confirm-resolved', id });
|
||||
}
|
||||
}
|
||||
for (const c of calls) {
|
||||
if (c.status !== 'awaiting_approval') continue;
|
||||
const callId = c.request.callId;
|
||||
if (waitingSeen.has(callId)) continue;
|
||||
waitingSeen.add(callId);
|
||||
options.onWaitingCall({
|
||||
live.push({
|
||||
type: 'confirm',
|
||||
id: callId,
|
||||
tool: c.request.name,
|
||||
title: c.confirmationDetails.title,
|
||||
});
|
||||
options?.onWaitingCall?.({
|
||||
callId,
|
||||
name: c.request.name,
|
||||
confirmationDetails: c.confirmationDetails,
|
||||
@@ -833,6 +872,17 @@ export async function* livePromptEvents(
|
||||
};
|
||||
if (resp?.responseParts) responseParts.push(...resp.responseParts);
|
||||
}
|
||||
// ink use-llm-stream parity (:5328): when every tool in the batch was
|
||||
// cancelled, the cancelled responses go to history only — no follow-up
|
||||
// model request. Checked before the steering drain so surviving steered
|
||||
// texts stay parked for the post-turn queue drain instead of riding a
|
||||
// request that will never be sent.
|
||||
if (completed.every((call) => call.status === 'cancelled')) {
|
||||
if (responseParts.length > 0) {
|
||||
await client.addHistory({ role: 'user', parts: responseParts });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Sampling boundary: drained steering rides after the tool responses as
|
||||
// genuine user content (original useGeminiStream mid-turn drain).
|
||||
if (!abort.aborted) {
|
||||
|
||||
@@ -23,10 +23,15 @@ vi.mock('@opentui/core', () => ({
|
||||
import {
|
||||
GENERIC_TOOL_SUMMARIES,
|
||||
MAX_RESULT_DISPLAY_CHARACTERS,
|
||||
TOOL_CARD_DESCRIPTION_ROWS,
|
||||
assistantMessageMeta,
|
||||
capToolCardDescription,
|
||||
headWindowPhysical,
|
||||
hiddenLinesLabel,
|
||||
hiddenTailLinesLabel,
|
||||
maxHistoryItemRows,
|
||||
tailWindow,
|
||||
tailWindowPhysical,
|
||||
thinkingMeta,
|
||||
toolCardDescription,
|
||||
toolCardName,
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
truncateResultDisplayChars,
|
||||
truncateTokenLine,
|
||||
userMessageMeta,
|
||||
STATUS_INDICATOR_WIDTH,
|
||||
} from './messages.js';
|
||||
import { TOOL_STATUS } from '../constants.js';
|
||||
import { C } from './theme.js';
|
||||
@@ -183,6 +189,67 @@ describe('long-content caps (ink MaxSizedBox parity)', () => {
|
||||
expect(hiddenLinesLabel(4779)).toBe('... first 4779 lines hidden ...');
|
||||
});
|
||||
|
||||
it('keeps everything when the physical height fits', () => {
|
||||
const rows = ['a'.repeat(150), 'b'];
|
||||
expect(headWindowPhysical(rows, 102, 20)).toEqual({
|
||||
visible: rows,
|
||||
hiddenRows: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('caps a single over-long logical row by its wrapped height', () => {
|
||||
const win = headWindowPhysical(['head', 'x'.repeat(5000)], 102, 20);
|
||||
expect(win.visible[0]).toBe('head');
|
||||
expect(win.visible[1]).toBe('x'.repeat(18 * 100));
|
||||
expect(win.visible).toHaveLength(2);
|
||||
// 1 + 50 physical rows total, 19 budgeted for content.
|
||||
expect(win.hiddenRows).toBe(32);
|
||||
});
|
||||
|
||||
it('keeps whole rows while they fit and slices the overflowing row', () => {
|
||||
const rows = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) => `${i % 10}`.repeat(150), // 2 physical rows each at 100 cols
|
||||
);
|
||||
const win = headWindowPhysical(rows, 102, 20);
|
||||
expect(win.visible).toHaveLength(10);
|
||||
expect(win.visible[9]).toBe('9'.repeat(100));
|
||||
// 30 physical rows total, 19 budgeted for content.
|
||||
expect(win.hiddenRows).toBe(11);
|
||||
});
|
||||
|
||||
it('renders the ink bottom-overflow hidden-tail indicator', () => {
|
||||
expect(hiddenTailLinesLabel(1)).toBe('... last 1 line hidden ...');
|
||||
expect(hiddenTailLinesLabel(4779)).toBe('... last 4779 lines hidden ...');
|
||||
});
|
||||
|
||||
it('keeps everything when the physical height fits the tail budget', () => {
|
||||
const rows = ['a'.repeat(150), 'b'];
|
||||
expect(tailWindowPhysical(rows, 102, 20)).toEqual({
|
||||
visible: rows,
|
||||
hiddenRows: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('caps a single over-long logical row by its wrapped tail', () => {
|
||||
const win = tailWindowPhysical(['head', 'x'.repeat(5000)], 102, 20);
|
||||
expect(win.visible).toEqual(['x'.repeat(20 * 100)]);
|
||||
// 1 + 50 physical rows total; the mega row's tail fills the budget.
|
||||
expect(win.hiddenRows).toBe(31);
|
||||
});
|
||||
|
||||
it('keeps the last whole rows whose height fits', () => {
|
||||
const rows = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) => `${i % 10}`.repeat(150), // 2 physical rows each at 100 cols
|
||||
);
|
||||
const win = tailWindowPhysical(rows, 102, 20);
|
||||
expect(win.visible).toHaveLength(10);
|
||||
expect(win.visible[0]).toBe('5'.repeat(150));
|
||||
expect(win.visible[9]).toBe('4'.repeat(150));
|
||||
expect(win.hiddenRows).toBe(10);
|
||||
});
|
||||
|
||||
it('truncates over-long results to the trailing characters', () => {
|
||||
const short = 'short output';
|
||||
expect(truncateResultDisplayChars(short)).toBe(short);
|
||||
@@ -193,6 +260,37 @@ describe('long-content caps (ink MaxSizedBox parity)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('capToolCardDescription (transcript card flood bound)', () => {
|
||||
it('leaves a description that fits the card budget untouched', () => {
|
||||
const desc = 'Save this exact content to the bound memory?';
|
||||
expect(
|
||||
capToolCardDescription(
|
||||
desc,
|
||||
'mcp__mem0',
|
||||
110,
|
||||
TOOL_CARD_DESCRIPTION_ROWS,
|
||||
),
|
||||
).toEqual({ description: desc, hiddenRows: 0 });
|
||||
});
|
||||
|
||||
it('keeps the head of an over-long description and counts the hidden rows', () => {
|
||||
const desc = 'x'.repeat(1000);
|
||||
const cols = 110 - STATUS_INDICATOR_WIDTH;
|
||||
const cap = capToolCardDescription(
|
||||
desc,
|
||||
'mcp__mem0',
|
||||
110,
|
||||
TOOL_CARD_DESCRIPTION_ROWS,
|
||||
);
|
||||
// The label row shares the budget: 4 description rows, the first one
|
||||
// hosting the name inline.
|
||||
const rows = Math.ceil(('mcp__mem0'.length + 1 + desc.length) / cols);
|
||||
expect(cap.description).toBe('x'.repeat(4 * cols - 'mcp__mem0'.length - 1));
|
||||
expect(cap.hiddenRows).toBe(rows - 4);
|
||||
expect(cap.hiddenRows).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('message meta (ink glyph/color parity)', () => {
|
||||
it('keeps the user/assistant prefixes', () => {
|
||||
expect(userMessageMeta().glyph).toBe('>');
|
||||
|
||||
@@ -122,6 +122,118 @@ export function hiddenLinesLabel(hiddenCount: number): string {
|
||||
return `... first ${hiddenCount} line${hiddenCount === 1 ? '' : 's'} hidden ...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical-height head window (ink MaxSizedBox overflowDirection 'bottom'
|
||||
* parity): the cap counts WRAPPED rows at width - 2 columns, because a
|
||||
* single logical row — e.g. a JSON.stringify'd confirmation payload — can
|
||||
* wrap to dozens of physical rows that a logical-row window never bounds.
|
||||
* An over-budget tail logical row is sliced to its head characters; the
|
||||
* hidden tail is summarized by hiddenTailLinesLabel.
|
||||
*/
|
||||
export function headWindowPhysical(
|
||||
rows: readonly string[],
|
||||
width: number,
|
||||
maxRows: number,
|
||||
): { visible: string[]; hiddenRows: number } {
|
||||
const cols = Math.max(width - 2, 10);
|
||||
const height = (row: string) => Math.max(1, Math.ceil(row.length / cols));
|
||||
const total = rows.reduce((sum, row) => sum + height(row), 0);
|
||||
if (total <= maxRows) return { visible: [...rows], hiddenRows: 0 };
|
||||
const budget = Math.max(maxRows - 1, 1);
|
||||
const visible: string[] = [];
|
||||
let used = 0;
|
||||
for (const row of rows) {
|
||||
const h = height(row);
|
||||
if (used + h <= budget) {
|
||||
visible.push(row);
|
||||
used += h;
|
||||
continue;
|
||||
}
|
||||
const remaining = budget - used;
|
||||
if (remaining > 0) {
|
||||
visible.push(row.slice(0, remaining * cols));
|
||||
used = budget;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return { visible, hiddenRows: Math.max(total - used, 1) };
|
||||
}
|
||||
|
||||
/** ink MaxSizedBox bottom-overflow hidden-tail indicator text. */
|
||||
export function hiddenTailLinesLabel(hiddenCount: number): string {
|
||||
return `... last ${hiddenCount} line${hiddenCount === 1 ? '' : 's'} hidden ...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical-height tail window — the expand-side mirror of headWindowPhysical:
|
||||
* keeps the LAST rows whose wrapped height fits the budget, slicing an
|
||||
* over-budget head logical row to its tail characters. OpenTUI paints a
|
||||
* fixed alt-screen viewport, so an expanded dialog body taller than the
|
||||
* screen must surface its tail (where the content ends) instead of letting
|
||||
* the screen clip it away.
|
||||
*/
|
||||
export function tailWindowPhysical(
|
||||
rows: readonly string[],
|
||||
width: number,
|
||||
maxRows: number,
|
||||
): { visible: string[]; hiddenRows: number } {
|
||||
const cols = Math.max(width - 2, 10);
|
||||
const height = (row: string) => Math.max(1, Math.ceil(row.length / cols));
|
||||
const total = rows.reduce((sum, row) => sum + height(row), 0);
|
||||
if (total <= maxRows) return { visible: [...rows], hiddenRows: 0 };
|
||||
const budget = Math.max(maxRows, 1);
|
||||
const visible: string[] = [];
|
||||
let used = 0;
|
||||
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||
const row = rows[i];
|
||||
const h = height(row);
|
||||
if (used + h <= budget) {
|
||||
visible.unshift(row);
|
||||
used += h;
|
||||
continue;
|
||||
}
|
||||
const remaining = budget - used;
|
||||
if (remaining > 0) {
|
||||
visible.unshift(row.slice(row.length - remaining * cols));
|
||||
used = budget;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return { visible, hiddenRows: Math.max(total - used, 1) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Row budget for a tool card's inline description. Ink bounds an over-tall
|
||||
* card through the static-area height distribution (MaxSizedBox); OpenTUI
|
||||
* caps the description head here until that distribution exists — without it
|
||||
* an MCP tool's whole args JSON (one logical row wrapping to dozens of
|
||||
* physical rows) floods the transcript column.
|
||||
*/
|
||||
export const TOOL_CARD_DESCRIPTION_ROWS = 5;
|
||||
|
||||
/**
|
||||
* Keeps the head of a description that would wrap past `maxRows` at the
|
||||
* given width; the hidden tail is summarized by hiddenTailLinesLabel. The
|
||||
* estimate is character-based (name + description wrap inside
|
||||
* width - STATUS_INDICATOR_WIDTH columns), which is intentionally coarse.
|
||||
*/
|
||||
export function capToolCardDescription(
|
||||
description: string,
|
||||
name: string,
|
||||
width: number,
|
||||
maxRows: number,
|
||||
): { description: string; hiddenRows: number } {
|
||||
const cols = Math.max(width - STATUS_INDICATOR_WIDTH, 10);
|
||||
const rows = Math.ceil((name.length + 1 + description.length) / cols);
|
||||
if (rows <= maxRows) return { description, hiddenRows: 0 };
|
||||
const descRows = Math.max(maxRows - 1, 1);
|
||||
const visibleChars = Math.max(descRows * cols - name.length - 1, 0);
|
||||
return {
|
||||
description: description.slice(0, visibleChars),
|
||||
hiddenRows: Math.max(rows - descRows, 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool-card naming/status parity with the original ToolMessage: a card line
|
||||
* is `{glyph} {DisplayName} {description}`, where the display name comes
|
||||
|
||||
@@ -75,6 +75,7 @@ import { OpenTuiApp } from './opentui-app-shell.js';
|
||||
import { OpenTuiRuntime } from './opentui-runtime.js';
|
||||
import { OpenTuiTranscriptView } from './transcript-view.js';
|
||||
import { useOpenTuiLiveTurn, type OpenTuiSubmitOptions } from './live-turn.js';
|
||||
import { ensureConfigInitialized } from './live-session.js';
|
||||
import { consumeLastRenderError } from './opentui-error-boundary.js';
|
||||
import { createExitGuard, exitGuardHint } from './exit-guard.js';
|
||||
import { EXIT_CODE_INTERRUPT, exitSession } from './exit-lifecycle.js';
|
||||
@@ -319,6 +320,10 @@ export async function startOpenTuiUI(
|
||||
const root = createRoot(renderer);
|
||||
let runtime: OpenTuiRuntime | null = null;
|
||||
try {
|
||||
// Own the single awaited initialization before anything can load commands
|
||||
// or submit: the registry loader's config.initialize() re-entry rejects,
|
||||
// so the turn path must await this same shared promise instead.
|
||||
void ensureConfigInitialized(config);
|
||||
const version = await getCliVersion();
|
||||
if (
|
||||
!settings.merged.ui?.hideWindowTitle &&
|
||||
|
||||
@@ -21,9 +21,12 @@ import { C, SYNTAX } from './theme.js';
|
||||
import {
|
||||
AnsiRows,
|
||||
MESSAGE_ICON,
|
||||
TOOL_CARD_DESCRIPTION_ROWS,
|
||||
TodoRows,
|
||||
assistantMessageMeta,
|
||||
capToolCardDescription,
|
||||
hiddenLinesLabel,
|
||||
hiddenTailLinesLabel,
|
||||
maxHistoryItemRows,
|
||||
selectionProps,
|
||||
STATUS_INDICATOR_WIDTH,
|
||||
@@ -225,8 +228,21 @@ function ToolCard({
|
||||
}) {
|
||||
const status = toolStatusMeta(item);
|
||||
const name = toolCardName(item.tool);
|
||||
// While the call awaits approval the dialog below carries the payload —
|
||||
// ink renders the confirmation as one surface there, and a duplicated
|
||||
// description would flood the column and keep its hidden-lines label on
|
||||
// screen after the body expands. Otherwise the description is capped to
|
||||
// TOOL_CARD_DESCRIPTION_ROWS wrapped rows (ink MaxSizedBox parity).
|
||||
const description =
|
||||
item.description ?? toolCardDescription(item.tool, item.args);
|
||||
item.confirm === 'pending' && !item.done
|
||||
? ''
|
||||
: (item.description ?? toolCardDescription(item.tool, item.args));
|
||||
const cap = capToolCardDescription(
|
||||
description,
|
||||
name,
|
||||
width,
|
||||
TOOL_CARD_DESCRIPTION_ROWS,
|
||||
);
|
||||
const suffix = toolCardSummarySuffix(item.done, item.summary);
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
@@ -242,13 +258,16 @@ function ToolCard({
|
||||
<text fg={C.text} attributes={status.strikethrough ? 129 : 1}>
|
||||
{name}
|
||||
</text>
|
||||
{description ? (
|
||||
{cap.description ? (
|
||||
<text fg={C.dim} {...selectionProps()}>
|
||||
{` ${sanitizeTerminalText(description)}`}
|
||||
{` ${sanitizeTerminalText(cap.description)}`}
|
||||
</text>
|
||||
) : null}
|
||||
{suffix ? <text fg={C.dim}>{sanitizeTerminalText(suffix)}</text> : null}
|
||||
</box>
|
||||
{cap.hiddenRows > 0 && (
|
||||
<text fg={C.dim}>{hiddenTailLinesLabel(cap.hiddenRows)}</text>
|
||||
)}
|
||||
{item.confirm === 'pending' && !item.done ? (
|
||||
<text fg={C.yellow}> (awaiting approval)</text>
|
||||
) : null}
|
||||
|
||||
Reference in new issue
Block a user