From 1e7328829ae102449db48b82b7aae0915da99ac3 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 1 Sep 2026 00:53:49 +0800 Subject: [PATCH 1/4] feat(tui): answer structured form interactions Review and edit Host-owned forms in the existing TUI interaction queue, preserving optional omission and all six primitive field kinds. Route accepted, declined, and cancelled responses through the generic Runtime Host interaction operation, while non-interactive runs stop the exact Turn instead of dropping the request. Keep requester provenance and sensitive-data guidance visible, validate with the shared Core contract, and retire stale overlays when authoritative transcript state changes. Part of #4364. Generated-by: OpenAI Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 33 +- .../__tests__/pi-tui-form-interaction.test.ts | 245 +++++++++ .../cli/src/__tests__/pi-tui-runner.test.ts | 186 +++++++ .../runtime-host-run-command.test.ts | 61 +++ .../runtime-host-session-driver.test.ts | 86 ++- packages/cli/src/pi-transcript.ts | 17 +- packages/cli/src/pi-tui-form-interaction.ts | 501 ++++++++++++++++++ packages/cli/src/pi-tui-runner.ts | 98 +++- packages/cli/src/runtime-host-run-command.ts | 10 +- .../cli/src/runtime-host-session-channel.ts | 2 + .../cli/src/runtime-host-session-driver.ts | 18 + packages/cli/src/session-driver.ts | 2 + packages/cli/src/tui-copy-catalog.ts | 34 ++ packages/core/package.json | 1 + 14 files changed, 1275 insertions(+), 19 deletions(-) create mode 100644 packages/cli/src/__tests__/pi-tui-form-interaction.test.ts create mode 100644 packages/cli/src/pi-tui-form-interaction.ts diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 39ffbfe26b..6a8e205ed5 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -2090,7 +2090,7 @@ describe('Maka Pi TUI transcript', () => { assert.ok(visibleLines.every((line) => !line.includes(' a '))); }); - test('queues sandbox boundary and user-question requests in arrival order', () => { + test('queues sandbox boundary, question, and form requests in arrival order', () => { const state = createMakaPiTranscriptState(); applyMakaSessionEventToTranscript( state, @@ -2106,6 +2106,17 @@ describe('Maka Pi TUI transcript', () => { }, }), ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'form_request', + requestId: 'form-1', + toolUseId: 'tool-3', + message: 'Configure deployment', + requester: { name: 'deploy', source: 'Acme MCP' }, + fields: [{ kind: 'boolean', name: 'notify', label: 'Notify', required: false }], + }), + ); applyMakaSessionEventToTranscript( state, event({ @@ -2119,7 +2130,7 @@ describe('Maka Pi TUI transcript', () => { assert.equal(state.pendingInteraction?.requestId, 'boundary-1'); assert.deepEqual( state.queuedInteractions.map((item) => item.requestId), - ['question-1'], + ['form-1', 'question-1'], ); applyMakaSessionEventToTranscript( @@ -2133,7 +2144,25 @@ describe('Maka Pi TUI transcript', () => { revision: 1, }), ); + assert.equal(state.pendingInteraction?.requestId, 'form-1'); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'form_answer_ack', + requestId: 'form-1', + toolUseId: 'tool-3', + }), + ); assert.equal(state.pendingInteraction?.requestId, 'question-1'); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'user_question_answer_ack', + requestId: 'question-1', + toolUseId: 'tool-2', + }), + ); + assert.equal(state.pendingInteraction, undefined); assert.deepEqual(state.queuedInteractions, []); }); diff --git a/packages/cli/src/__tests__/pi-tui-form-interaction.test.ts b/packages/cli/src/__tests__/pi-tui-form-interaction.test.ts new file mode 100644 index 0000000000..4c9e9cb039 --- /dev/null +++ b/packages/cli/src/__tests__/pi-tui-form-interaction.test.ts @@ -0,0 +1,245 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { TUI } from '@earendil-works/pi-tui'; +import type { FormRequestEvent } from '@maka/core/events'; +import type { InteractionFormResponse } from '@maka/core/interaction'; +import { + buildTuiFormResponse, + createTuiFormDrafts, + FormInteractionOverlay, +} from '../pi-tui-form-interaction.js'; +import { stripAnsi } from '../tui-ansi.js'; + +const REQUEST: FormRequestEvent = { + type: 'form_request', + id: 'event-form', + ts: 1, + turnId: 'turn-1', + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Configure deployment', + requester: { name: 'deploy', source: 'Acme MCP' }, + fields: [ + { kind: 'string', name: 'version', label: 'Version', required: true, minLength: 2 }, + { kind: 'number', name: 'ratio', label: 'Ratio', required: true, default: 1.5 }, + { kind: 'integer', name: 'replicas', label: 'Replicas', required: true, default: 3 }, + { kind: 'boolean', name: 'notify', label: 'Notify', required: false }, + { + kind: 'single_select', + name: 'channel', + label: 'Channel', + required: true, + default: 'stable', + options: [ + { value: 'stable', label: 'Stable' }, + { value: 'canary', label: 'Canary' }, + ], + }, + { + kind: 'multi_select', + name: 'owners', + label: 'Owners', + required: false, + default: ['a'], + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ], + }, + ], +}; + +test('TUI drafts cover every primitive and preserve optional omission', () => { + assert.deepEqual(createTuiFormDrafts(REQUEST.fields), [ + { included: true, value: '' }, + { included: true, value: '1.5' }, + { included: true, value: '3' }, + { included: false, value: false }, + { included: true, value: 'stable' }, + { included: true, value: ['a'] }, + ]); +}); + +test('TUI acceptance parses numbers without inventing omitted values', () => { + const drafts = createTuiFormDrafts(REQUEST.fields); + drafts[0] = { included: true, value: 'v2' }; + assert.deepEqual(buildTuiFormResponse(REQUEST, drafts), { + requestId: 'form-1', + action: 'accept', + values: { + version: 'v2', + ratio: 1.5, + replicas: 3, + channel: 'stable', + owners: ['a'], + }, + }); + drafts[2] = { included: true, value: '3.5' }; + assert.equal(buildTuiFormResponse(REQUEST, drafts), null); +}); + +test('protocol field names remain own data properties', () => { + const request = { + ...REQUEST, + fields: [{ kind: 'string', name: '__proto__', label: 'Prototype', required: true }], + } satisfies FormRequestEvent; + const response = buildTuiFormResponse(request, [{ included: true, value: 'data' }]); + assert.equal(response?.action, 'accept'); + if (response?.action !== 'accept') assert.fail('expected an accepted response'); + assert.equal(Object.hasOwn(response.values, '__proto__'), true); + assert.equal(response.values.__proto__, 'data'); +}); + +test('overlay retains invalid drafts, then submits the corrected value', () => { + const responses: InteractionFormResponse[] = []; + const request = { ...REQUEST, fields: [REQUEST.fields[0]!] }; + const overlay = new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request, + onRespond: (response) => responses.push(response), + }); + + overlay.handleInput('s'); + assert.equal(responses.length, 0); + assert.match(rendered(overlay), /Value does not meet this field's constraints/u); + + overlay.handleInput('\r'); + overlay.handleInput('v'); + overlay.handleInput('2'); + overlay.handleInput('\r'); + overlay.handleInput('s'); + assert.deepEqual(responses, [ + { requestId: 'form-1', action: 'accept', values: { version: 'v2' } }, + ]); +}); + +test('overlay distinguishes optional false, decline, and cancel', () => { + const request = { ...REQUEST, fields: [REQUEST.fields[3]!] }; + const accepted: InteractionFormResponse[] = []; + const overlay = new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request, + onRespond: (response) => accepted.push(response), + }); + assert.match(rendered(overlay), /omitted/u); + overlay.handleInput(' '); + overlay.handleInput('s'); + assert.deepEqual(accepted, [ + { requestId: 'form-1', action: 'accept', values: { notify: false } }, + ]); + + const declined: InteractionFormResponse[] = []; + new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request, + onRespond: (response) => declined.push(response), + }).handleInput('d'); + assert.deepEqual(declined, [{ requestId: 'form-1', action: 'decline' }]); + + const cancelled: InteractionFormResponse[] = []; + new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request, + onRespond: (response) => cancelled.push(response), + }).handleInput('\u001b'); + assert.deepEqual(cancelled, [{ requestId: 'form-1', action: 'cancel' }]); +}); + +test('overlay renders provenance and neutralizes terminal control text', () => { + const overlay = new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request: { + ...REQUEST, + message: '\u001b[31mDeploy\nnow', + requester: { name: '\u202edeploy', source: '\u001b]0;owned\u0007MCP' }, + fields: [ + { + kind: 'string', + name: 'name', + label: '\u001b[2JName', + required: false, + default: '\u001b[31mvalue', + }, + ], + }, + onRespond: () => undefined, + }); + const output = rendered(overlay); + assert.match(output, /Deploy now/u); + assert.match(output, /Requested by deploy · MCP/u); + assert.match(output, /Do not enter passwords, API keys, access tokens, or payment details/u); + assert.doesNotMatch(output, /\u001b\[31m|\u001b\]0/u); +}); + +test('overlay keeps bounded field and option windows around the active row', () => { + const fields = Array.from({ length: 12 }, (_, index) => ({ + kind: 'boolean' as const, + name: `field-${index}`, + label: `Field ${index}`, + required: true, + })); + const overlay = new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request: { ...REQUEST, fields }, + onRespond: () => undefined, + }); + for (let index = 0; index < 10; index += 1) overlay.handleInput('\u001b[B'); + const fieldWindow = rendered(overlay); + assert.match(fieldWindow, /Field 10/u); + assert.match(fieldWindow, /… ↑/u); + assert.doesNotMatch(fieldWindow, /Field 0 /u); + + const options = Array.from({ length: 14 }, (_, index) => ({ + value: `option-${index}`, + label: `Option ${index}`, + })); + const optionOverlay = new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request: { + ...REQUEST, + fields: [ + { + kind: 'single_select', + name: 'choice', + label: 'Choice', + required: true, + options, + }, + ], + }, + onRespond: () => undefined, + }); + optionOverlay.handleInput('\r'); + for (let index = 0; index < 11; index += 1) optionOverlay.handleInput('\u001b[B'); + const optionWindow = rendered(optionOverlay); + assert.match(optionWindow, /Option 11/u); + assert.match(optionWindow, /… ↑/u); + assert.doesNotMatch(optionWindow, /Option 0/u); +}); + +function fakeTui(): TUI { + return { requestRender: () => undefined } as unknown as TUI; +} + +function rendered(overlay: FormInteractionOverlay): string { + return overlay.render(100).map(stripAnsi).join('\n'); +} diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 58540fd5ee..199a4386cd 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -38,6 +38,7 @@ import { type SessionSummary, type StoredMessage } from '@maka/core/session'; import { type ThinkingLevel } from '@maka/core/model-thinking'; import type { RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot } from '@maka/runtime-host/client'; import { type UserQuestionResponse } from '@maka/core/user-question'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; import type { AgentGraphClientSnapshot, @@ -2201,6 +2202,129 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('reviews and answers a Host-owned form without losing typed or optional values', async () => { + const terminal = new FakeTerminal(100, 30); + const driver = new FormPromptDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('deploy'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Configure deployment'), + ); + const firstScreen = plainTerminalOutput(terminal.screenOutput()); + assert.match(firstScreen, /Requested by deploy · Acme MCP/u); + assert.match(firstScreen, /Do not enter passwords, API keys, access tokens/u); + assert.match(firstScreen, /Version \(required\): empty/u); + assert.match(firstScreen, /Notify \(optional\): omitted/u); + + terminal.input('\r'); + terminal.input('v2'); + terminal.input('\r'); + terminal.input('\x1b[B'); + terminal.input(' '); + terminal.input('\r'); + terminal.input('\x1b[A'); + terminal.input('\r'); + terminal.input('s'); + + await waitFor(() => driver.responses.length === 1); + assert.deepEqual(driver.responses, [ + { + requestId: 'form-1', + action: 'accept', + values: { version: 'v2', notify: true }, + }, + ]); + + exitMaka(terminal); + await run; + }); + + test('keeps form cancel distinct from global Turn stop', async () => { + const cancelTerminal = new FakeTerminal(100, 30); + const cancelDriver = new FormPromptDriver(); + const cancelRun = runMakaPiTui({ + title: 'Maka', + driver: cancelDriver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal: cancelTerminal, + }); + cancelTerminal.input('deploy'); + cancelTerminal.input('\r'); + await waitFor(() => + plainTerminalOutput(cancelTerminal.screenOutput()).includes('Configure deployment'), + ); + cancelTerminal.input('\u001b'); + await waitFor(() => cancelDriver.responses.length === 1); + assert.deepEqual(cancelDriver.responses, [{ requestId: 'form-1', action: 'cancel' }]); + assert.equal(cancelDriver.stopCalls, 0); + exitMaka(cancelTerminal); + await cancelRun; + + const stopTerminal = new FakeTerminal(100, 30); + const stopDriver = new FormPromptDriver(); + const stopRun = runMakaPiTui({ + title: 'Maka', + driver: stopDriver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal: stopTerminal, + }); + stopTerminal.input('deploy'); + stopTerminal.input('\r'); + await waitFor(() => + plainTerminalOutput(stopTerminal.screenOutput()).includes('Configure deployment'), + ); + stopTerminal.input('\u0003'); + await waitFor(() => stopDriver.stopCalls === 1); + assert.deepEqual(stopDriver.responses, []); + exitMaka(stopTerminal); + await stopRun; + }); + + test('closes a stale form when reconnect replaces the authoritative transcript', async () => { + const terminal = new FakeTerminal(100, 30); + const driver = new FormPromptDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('deploy'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Configure deployment'), + ); + + driver.publishReconnect(); + await waitFor( + () => !plainTerminalOutput(terminal.screenOutput()).includes('Configure deployment'), + ); + assert.deepEqual(driver.responses, []); + + exitMaka(terminal); + await run; + }); + test('off-screen shell-run settle never clears scrollback (#1135)', async () => { const terminal = new FakeTerminal(); const driver = new OffscreenSettleDriver(); @@ -9002,6 +9126,68 @@ class LongOptionsQuestionDriver extends FakeSessionDriver { } } +class FormPromptDriver extends FakeSessionDriver { + readonly responses: InteractionFormResponse[] = []; + stopCalls = 0; + private release: (() => void) | undefined; + readonly #transcriptListeners = new Set< + ( + sessionId: string, + turnId: string, + messages: StoredMessage[], + reason: MakaTranscriptReplacementReason, + ) => void + >(); + + preparePrompt(prompt: string): Promise { + return prepareTestPrompt(this, prompt); + } + async *promptEvents(_prompt: string): AsyncIterable { + yield { + type: 'form_request', + id: 'event-form', + turnId: 'turn-1', + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Configure deployment', + requester: { name: 'deploy', source: 'Acme MCP' }, + fields: [ + { kind: 'string', name: 'version', label: 'Version', required: true, minLength: 2 }, + { kind: 'boolean', name: 'notify', label: 'Notify', required: false }, + ], + }; + await new Promise((resolve) => { + this.release = resolve; + }); + yield { type: 'complete', id: 'complete-1', turnId: 'turn-1', ts: 2, stopReason: 'end_turn' }; + } + async respondToUserForm(response: InteractionFormResponse): Promise { + this.responses.push(response); + this.release?.(); + } + async stop(): Promise { + this.stopCalls += 1; + this.release?.(); + } + subscribeTranscriptReplacements( + listener: ( + sessionId: string, + turnId: string, + messages: StoredMessage[], + reason: MakaTranscriptReplacementReason, + ) => void, + ): () => void { + this.#transcriptListeners.add(listener); + return () => this.#transcriptListeners.delete(listener); + } + publishReconnect(): void { + for (const listener of this.#transcriptListeners) { + listener('session-1', 'turn-1', [], 'reconnect'); + } + } +} + class InterruptibleTurnDriver extends FakeSessionDriver { stopCalls = 0; readonly prompts: string[] = []; diff --git a/packages/cli/src/__tests__/runtime-host-run-command.test.ts b/packages/cli/src/__tests__/runtime-host-run-command.test.ts index 49d76680c8..c0173f2f6e 100644 --- a/packages/cli/src/__tests__/runtime-host-run-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-run-command.test.ts @@ -748,6 +748,33 @@ describe('Runtime Host maka run adapter', () => { ]); }); + test('fails and stops instead of dropping an interactive form', async () => { + const fixture = runFixture({ + turnEvents: formEvents('turn-1'), + pendingInteractions: [pendingForm('turn-1')], + pendingAfterTurnStarts: true, + }); + const session = await fixture.context.runtime.createSession({ + cwd: '/workspace', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + permissionMode: 'ask', + }); + + await assert.rejects( + collect( + fixture.context.runtime.sendMessage(session.id, { + turnId: 'turn-1', + text: 'configure deployment', + }), + ), + new Error('interactive user forms are unavailable in non-interactive mode'), + ); + assert.deepEqual(fixture.exactTurnStops, [ + { sessionId: session.id, turnId: 'turn-1', runId: 'run-1' }, + ]); + }); + test('stops Graph Mode when a successor waits for an interactive question', async () => { const fixture = runFixture({ graph: true, @@ -1268,6 +1295,20 @@ async function* questionEvents(turnId: string): AsyncIterable { ], }; } + +async function* formEvents(turnId: string): AsyncIterable { + yield { + type: 'form_request', + id: `${turnId}-form`, + turnId, + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Configure deployment', + requester: { name: 'deploy', source: 'Acme MCP' }, + fields: [{ kind: 'string', name: 'version', label: 'Version', required: true }], + }; +} function pendingQuestion(turnId: string): InteractionPendingSnapshot { return { schemaVersion: 1, @@ -1286,6 +1327,26 @@ function pendingQuestion(turnId: string): InteractionPendingSnapshot { }; } +function pendingForm(turnId: string): InteractionPendingSnapshot { + return { + schemaVersion: 1, + interactionId: 'form-1', + sessionId: 'session-created', + turnId, + runId: turnId === 'turn-1' ? 'run-1' : 'run-2', + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'form', + toolUseId: 'tool-form', + message: 'Configure deployment', + requester: { name: 'deploy', source: 'Acme MCP' }, + fields: [{ kind: 'string', name: 'version', label: 'Version', required: true }], + }, + }; +} + function pendingPermission(turnId: string): InteractionPendingSnapshot { return { schemaVersion: 1, diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 1acaa8841b..230b1ec72e 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1917,6 +1917,48 @@ describe('Runtime Host Maka Session driver', () => { }); }); + test('answers and releases a Host-owned form through the generic Interaction operation', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ interactions: { pending: [pendingForm()] } }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 76, + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + assert.equal((await nextEvent(switched.activeTurn.events)).type, 'form_request'); + + await driver.respondToUserForm!({ + requestId: 'form-1', + action: 'accept', + values: { version: 'v2' }, + }); + + assert.deepEqual(connection.requests.at(-1), { + operation: 'interaction.answer', + input: { + sessionId: 'session-1', + interactionId: 'form-1', + answer: { kind: 'form', action: 'accept', values: { version: 'v2' } }, + }, + }); + assert.deepEqual(await nextEvent(switched.activeTurn.events), { + type: 'form_answer_ack', + id: 'host-interaction:form-1:2', + turnId: 'turn-1', + ts: 76, + requestId: 'form-1', + toolUseId: 'tool-form', + }); + }); + test('publishes a pending permission that has no transcript event', async () => { const permission = pendingPermission(); const subscription = new FakeSubscription( @@ -2807,12 +2849,24 @@ class FakeConnection { ], } : operation === 'interaction.answer' - ? { - ...pendingQuestion(), - revision: 2, - status: 'answered', - outcome: { kind: 'question_answer', answers: ['Yes'], committedAt: 75 }, - } + ? (input as OperationInput<'interaction.answer'>).answer.kind === 'form' + ? { + ...pendingForm(), + revision: 2, + status: 'answered', + outcome: { + kind: 'form_answer', + action: 'accept', + values: { version: 'v2' }, + committedAt: 76, + }, + } + : { + ...pendingQuestion(), + revision: 2, + status: 'answered', + outcome: { kind: 'question_answer', answers: ['Yes'], committedAt: 75 }, + } : operation === 'interaction.query' ? this.interactionQuery : operation === 'turn.start' @@ -3142,6 +3196,26 @@ function pendingQuestion() { }; } +function pendingForm() { + return { + schemaVersion: 1 as const, + interactionId: 'form-1', + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + revision: 1 as const, + status: 'pending' as const, + outcome: null, + request: { + kind: 'form' as const, + toolUseId: 'tool-form', + message: 'Configure deployment', + requester: { name: 'deploy', source: 'Acme MCP' }, + fields: [{ kind: 'string' as const, name: 'version', label: 'Version', required: true }], + }, + }; +} + function pendingPermission() { return { schemaVersion: 1 as const, diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index f3bb911159..c0b6d0a236 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -21,6 +21,7 @@ import { Markdown, visibleWidth } from '@earendil-works/pi-tui'; import type { ProviderRetryEvent, ProviderRetryScheduledEvent, + FormRequestEvent, SandboxBoundaryRequestEvent, UserQuestionRequestEvent, SessionEvent, @@ -122,7 +123,10 @@ export interface MakaPiTranscriptState { providerRetry?: ProviderRetryCountdown; } -export type MakaPiPendingInteraction = SandboxBoundaryRequestEvent | UserQuestionRequestEvent; +export type MakaPiPendingInteraction = + | SandboxBoundaryRequestEvent + | UserQuestionRequestEvent + | FormRequestEvent; /** * A provider retry event plus the CLIENT-local time it was applied. Counting @@ -936,6 +940,9 @@ export function applyMakaSessionEventToTranscript( case 'user_question_request': enqueuePendingInteraction(state, event); break; + case 'form_request': + enqueuePendingInteraction(state, event); + break; case 'sandbox_boundary_decision_ack': { @@ -955,6 +962,10 @@ export function applyMakaSessionEventToTranscript( completePendingInteraction(state, event.requestId); break; + case 'form_answer_ack': + completePendingInteraction(state, event.requestId); + break; + case 'plan_submitted': state.entries.push({ kind: 'notice', @@ -1483,6 +1494,10 @@ export function activeUserQuestionRequest( : undefined; } +export function activeFormRequest(state: MakaPiTranscriptState): FormRequestEvent | undefined { + return state.pendingInteraction?.type === 'form_request' ? state.pendingInteraction : undefined; +} + function enqueuePendingInteraction( state: MakaPiTranscriptState, request: MakaPiPendingInteraction, diff --git a/packages/cli/src/pi-tui-form-interaction.ts b/packages/cli/src/pi-tui-form-interaction.ts new file mode 100644 index 0000000000..ecb622be57 --- /dev/null +++ b/packages/cli/src/pi-tui-form-interaction.ts @@ -0,0 +1,501 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + Editor, + Key, + isKeyRepeat, + matchesKey, + truncateToWidth, + visibleWidth, + type Component, + type TUI, +} from '@earendil-works/pi-tui'; +import type { FormRequestEvent } from '@maka/core/events'; +import { + isInteractionFormFieldValueValid, + type InteractionFormField, + type InteractionFormResponse, + type InteractionFormValue, +} from '@maka/core/interaction'; +import { sanitizeUnicodeText } from '@maka/core/text-sanitize'; +import { + defineUiMessageCatalog, + formatUiMessage, + resolveUiMessageCatalog, + type UiLocale, +} from '@maka/core/ui-locale'; +import { ansi, editorTheme, stripAnsi } from './tui-ansi.js'; +import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js'; + +export interface TuiFormDraft { + readonly included: boolean; + readonly value: string | boolean | readonly string[]; +} + +interface TuiFormCopy { + readonly requestedBy: string; + readonly sensitiveWarning: string; + readonly required: string; + readonly optional: string; + readonly omitted: string; + readonly empty: string; + readonly invalid: string; + readonly reviewHint: string; + readonly textHint: string; + readonly choiceHint: string; + readonly multiHint: string; + readonly trueValue: string; + readonly falseValue: string; + readonly selectedCount: string; +} + +const FORM_COPY = resolveUiMessageCatalog( + defineUiMessageCatalog()(TUI_COPY_RESOURCES['form-interaction']), +); + +const FORM_REVIEW_MAX_VISIBLE_FIELDS = 8; +const FORM_CHOICE_MAX_VISIBLE_OPTIONS = 10; + +type EditMode = + | { readonly kind: 'review' } + | { readonly kind: 'text'; readonly index: number } + | { readonly kind: 'choice'; readonly index: number; optionIndex: number } + | { readonly kind: 'multi'; readonly index: number; optionIndex: number }; + +export function createTuiFormDrafts(fields: readonly InteractionFormField[]): TuiFormDraft[] { + return fields.map((field) => { + const included = field.required || field.default !== undefined; + if (field.kind === 'boolean') return { included, value: field.default ?? false }; + if (field.kind === 'multi_select') return { included, value: [...(field.default ?? [])] }; + if (field.kind === 'number' || field.kind === 'integer') { + return { included, value: field.default === undefined ? '' : String(field.default) }; + } + return { included, value: field.default ?? '' }; + }); +} + +export function buildTuiFormResponse( + request: FormRequestEvent, + drafts: readonly TuiFormDraft[], +): InteractionFormResponse | null { + if (drafts.length !== request.fields.length) return null; + const entries: Array<[string, InteractionFormValue]> = []; + for (const [index, field] of request.fields.entries()) { + const draft = drafts[index]; + if (!draft) return null; + if (!draft.included) { + if (field.required) return null; + continue; + } + const value = draftValue(field, draft); + if (!isInteractionFormFieldValueValid(field, value)) return null; + entries.push([field.name, value]); + } + return { + requestId: request.requestId, + action: 'accept', + values: Object.fromEntries(entries), + }; +} + +export class FormInteractionOverlay implements Component { + readonly #copy: TuiFormCopy; + readonly #editor: Editor; + #drafts: TuiFormDraft[]; + #activeIndex = 0; + #mode: EditMode = { kind: 'review' }; + #invalid = new Set(); + #submitting = false; + + constructor( + tui: TUI, + private readonly input: { + readonly locale: UiLocale; + readonly request: FormRequestEvent; + readonly onRespond: (response: InteractionFormResponse) => void; + }, + ) { + this.#copy = FORM_COPY[input.locale]; + this.#drafts = createTuiFormDrafts(input.request.fields); + this.#editor = new Editor(tui, editorTheme(), { paddingX: 0 }); + this.#editor.onChange = (value) => { + if (this.#mode.kind !== 'text') return; + this.#replaceDraft(this.#mode.index, { value }); + }; + } + + invalidate(): void { + this.#editor.invalidate(); + } + + setSubmissionFailed(): void { + this.#submitting = false; + } + + handleInput(data: string): void { + if (this.#submitting) return; + if (this.#mode.kind === 'review') { + this.#handleReviewInput(data); + } else if (this.#mode.kind === 'text') { + if (matchesKey(data, Key.escape)) { + this.#mode = { kind: 'review' }; + } else if ( + (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) && + !isKeyRepeat(data) + ) { + this.#replaceDraft(this.#mode.index, { value: this.#editor.getText() }); + this.#mode = { kind: 'review' }; + } else { + this.#editor.handleInput(data); + } + } else if (this.#mode.kind === 'choice') { + this.#handleChoiceInput(data, this.#mode); + } else { + this.#handleMultiInput(data, this.#mode); + } + } + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + const lines = this.#renderHeader(safeWidth); + if (this.#mode.kind === 'review') lines.push(...this.#renderReview(safeWidth)); + else if (this.#mode.kind === 'text') + lines.push(...this.#renderTextEditor(this.#mode.index, safeWidth)); + else if (this.#mode.kind === 'choice') lines.push(...this.#renderChoice(this.#mode, safeWidth)); + else lines.push(...this.#renderMulti(this.#mode, safeWidth)); + lines.push(padLine(ansi.accent('-'.repeat(safeWidth)), safeWidth)); + return lines; + } + + #handleReviewInput(data: string): void { + const fields = this.input.request.fields; + if (matchesKey(data, Key.escape)) { + this.#respond({ requestId: this.input.request.requestId, action: 'cancel' }); + return; + } + if (data === 'd' || data === 'D') { + this.#respond({ requestId: this.input.request.requestId, action: 'decline' }); + return; + } + if (data === 's' || data === 'S') { + this.#submit(); + return; + } + if (fields.length === 0) return; + if (matchesKey(data, Key.up)) { + this.#activeIndex = this.#activeIndex === 0 ? fields.length - 1 : this.#activeIndex - 1; + return; + } + if (matchesKey(data, Key.down)) { + this.#activeIndex = this.#activeIndex === fields.length - 1 ? 0 : this.#activeIndex + 1; + return; + } + const field = fields[this.#activeIndex]; + const draft = this.#drafts[this.#activeIndex]; + if (!field || !draft) return; + if (matchesKey(data, Key.space) && !field.required) { + this.#replaceDraft(this.#activeIndex, { included: !draft.included }); + return; + } + if ((matchesKey(data, Key.enter) || matchesKey(data, Key.return)) && !isKeyRepeat(data)) { + if (!draft.included) this.#replaceDraft(this.#activeIndex, { included: true }); + this.#openEditor(field, this.#activeIndex); + } + } + + #handleChoiceInput(data: string, mode: Extract): void { + if (matchesKey(data, Key.escape)) { + this.#mode = { kind: 'review' }; + return; + } + const field = this.input.request.fields[mode.index]; + if (!field || (field.kind !== 'boolean' && field.kind !== 'single_select')) return; + const count = field.kind === 'boolean' ? 2 : field.options.length; + if (count === 0) return; + if (matchesKey(data, Key.up)) + mode.optionIndex = mode.optionIndex === 0 ? count - 1 : mode.optionIndex - 1; + else if (matchesKey(data, Key.down)) + mode.optionIndex = mode.optionIndex === count - 1 ? 0 : mode.optionIndex + 1; + else if ((matchesKey(data, Key.enter) || matchesKey(data, Key.return)) && !isKeyRepeat(data)) { + const value = + field.kind === 'boolean' ? mode.optionIndex === 0 : field.options[mode.optionIndex]?.value; + if (value !== undefined) this.#replaceDraft(mode.index, { included: true, value }); + this.#mode = { kind: 'review' }; + } + } + + #handleMultiInput(data: string, mode: Extract): void { + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.enter) || + matchesKey(data, Key.return) + ) { + if (!isKeyRepeat(data)) this.#mode = { kind: 'review' }; + return; + } + const field = this.input.request.fields[mode.index]; + const draft = this.#drafts[mode.index]; + if (!field || field.kind !== 'multi_select' || !draft || !Array.isArray(draft.value)) return; + if (field.options.length === 0) return; + if (matchesKey(data, Key.up)) + mode.optionIndex = mode.optionIndex === 0 ? field.options.length - 1 : mode.optionIndex - 1; + else if (matchesKey(data, Key.down)) + mode.optionIndex = mode.optionIndex === field.options.length - 1 ? 0 : mode.optionIndex + 1; + else if (matchesKey(data, Key.space)) { + const value = field.options[mode.optionIndex]?.value; + if (value === undefined) return; + const selected = draft.value.includes(value) + ? draft.value.filter((candidate) => candidate !== value) + : [...draft.value, value]; + this.#replaceDraft(mode.index, { included: true, value: selected }); + } + } + + #openEditor(field: InteractionFormField, index: number): void { + const draft = this.#drafts[index]; + if (!draft) return; + if (field.kind === 'boolean') { + this.#mode = { kind: 'choice', index, optionIndex: draft.value === true ? 0 : 1 }; + } else if (field.kind === 'single_select') { + this.#mode = { + kind: 'choice', + index, + optionIndex: Math.max( + 0, + field.options.findIndex((option) => option.value === draft.value), + ), + }; + } else if (field.kind === 'multi_select') { + this.#mode = { kind: 'multi', index, optionIndex: 0 }; + } else { + this.#editor.setText(typeof draft.value === 'string' ? draft.value : ''); + this.#mode = { kind: 'text', index }; + } + } + + #submit(): void { + const response = buildTuiFormResponse(this.input.request, this.#drafts); + if (response) { + this.#respond(response); + return; + } + this.#invalid = new Set( + this.input.request.fields.flatMap((field, index) => { + const draft = this.#drafts[index]; + if (!draft || (!draft.included && field.required)) return [index]; + if (!draft.included) return []; + return isInteractionFormFieldValueValid(field, draftValue(field, draft)) ? [] : [index]; + }), + ); + const first = this.#invalid.values().next().value; + if (typeof first === 'number') this.#activeIndex = first; + } + + #respond(response: InteractionFormResponse): void { + this.#submitting = true; + this.input.onRespond(response); + } + + #replaceDraft(index: number, patch: Partial): void { + const current = this.#drafts[index]; + if (!current) return; + this.#drafts = this.#drafts.map((draft, candidate) => + candidate === index ? { ...current, ...patch } : draft, + ); + this.#invalid.delete(index); + } + + #renderHeader(width: number): string[] { + const requester = this.input.request.requester; + const detail = requester.source + ? `${safeDisplay(requester.name)} · ${safeDisplay(requester.source)}` + : safeDisplay(requester.name); + const provenance = formatUiMessage(this.#copy.requestedBy, { detail }, this.input.locale); + return [ + padLine(ansi.bold(safeDisplay(this.input.request.message)), width), + padLine(ansi.dim(provenance), width), + padLine(ansi.red(this.#copy.sensitiveWarning), width), + padLine('', width), + ]; + } + + #renderReview(width: number): string[] { + const lines: string[] = []; + const window = visibleWindow( + this.input.request.fields.length, + this.#activeIndex, + FORM_REVIEW_MAX_VISIBLE_FIELDS, + ); + if (window.start > 0) lines.push(padLine(ansi.dim(` … ↑ ${window.start}`), width)); + this.input.request.fields.slice(window.start, window.end).forEach((field, offset) => { + const index = window.start + offset; + const draft = this.#drafts[index]; + if (!draft) return; + const requirement = field.required ? this.#copy.required : this.#copy.optional; + const prefix = index === this.#activeIndex ? '→ ' : ' '; + const summary = this.#summary(field, draft); + const invalid = this.#invalid.has(index); + const row = `${prefix}${safeDisplay(field.label)} (${requirement}): ${summary}${invalid ? ` · ${this.#copy.invalid}` : ''}`; + lines.push( + formatReviewRow(invalid ? ansi.red(row) : row, index === this.#activeIndex, width), + ); + if (index === this.#activeIndex && field.description) { + lines.push(padLine(` ${ansi.dim(safeDisplay(field.description))}`, width)); + } + }); + if (window.end < this.input.request.fields.length) { + lines.push( + padLine(ansi.dim(` … ↓ ${this.input.request.fields.length - window.end}`), width), + ); + } + if (this.input.request.fields.length === 0) lines.push(padLine(ansi.dim('(no fields)'), width)); + lines.push(padLine('', width)); + lines.push(padLine(ansi.dim(this.#copy.reviewHint), width)); + return lines; + } + + #renderTextEditor(index: number, width: number): string[] { + const field = this.input.request.fields[index]; + if (!field) return []; + this.#editor.focused = true; + return [ + padLine( + `${safeDisplay(field.label)} (${field.required ? this.#copy.required : this.#copy.optional})`, + width, + ), + ...(field.description ? [padLine(ansi.dim(safeDisplay(field.description)), width)] : []), + ...this.#editor.render(width), + padLine(ansi.dim(this.#copy.textHint), width), + ]; + } + + #renderChoice(mode: Extract, width: number): string[] { + const field = this.input.request.fields[mode.index]; + if (!field || (field.kind !== 'boolean' && field.kind !== 'single_select')) return []; + const options = + field.kind === 'boolean' + ? [this.#copy.trueValue, this.#copy.falseValue] + : field.options.map((option) => safeDisplay(option.label)); + const window = visibleWindow(options.length, mode.optionIndex, FORM_CHOICE_MAX_VISIBLE_OPTIONS); + return [ + padLine(safeDisplay(field.label), width), + ...(field.description ? [padLine(ansi.dim(safeDisplay(field.description)), width)] : []), + ...(window.start > 0 ? [padLine(ansi.dim(` … ↑ ${window.start}`), width)] : []), + ...options.slice(window.start, window.end).map((label, offset) => { + const index = window.start + offset; + return formatReviewRow( + `${index === mode.optionIndex ? '→ ' : ' '}${label}`, + index === mode.optionIndex, + width, + ); + }), + ...(window.end < options.length + ? [padLine(ansi.dim(` … ↓ ${options.length - window.end}`), width)] + : []), + padLine(ansi.dim(this.#copy.choiceHint), width), + ]; + } + + #renderMulti(mode: Extract, width: number): string[] { + const field = this.input.request.fields[mode.index]; + const draft = this.#drafts[mode.index]; + if (!field || field.kind !== 'multi_select' || !draft || !Array.isArray(draft.value)) return []; + const selected = draft.value; + const window = visibleWindow( + field.options.length, + mode.optionIndex, + FORM_CHOICE_MAX_VISIBLE_OPTIONS, + ); + return [ + padLine(safeDisplay(field.label), width), + ...(field.description ? [padLine(ansi.dim(safeDisplay(field.description)), width)] : []), + ...(window.start > 0 ? [padLine(ansi.dim(` … ↑ ${window.start}`), width)] : []), + ...field.options.slice(window.start, window.end).map((option, offset) => { + const index = window.start + offset; + const row = `${index === mode.optionIndex ? '→ ' : ' '}[${selected.includes(option.value) ? 'x' : ' '}] ${safeDisplay(option.label)}`; + return formatReviewRow(row, index === mode.optionIndex, width); + }), + ...(window.end < field.options.length + ? [padLine(ansi.dim(` … ↓ ${field.options.length - window.end}`), width)] + : []), + padLine(ansi.dim(this.#copy.multiHint), width), + ]; + } + + #summary(field: InteractionFormField, draft: TuiFormDraft): string { + if (!draft.included) return ansi.dim(this.#copy.omitted); + if (field.kind === 'boolean') + return draft.value === true ? this.#copy.trueValue : this.#copy.falseValue; + if (field.kind === 'single_select') { + const option = field.options.find((candidate) => candidate.value === draft.value); + return option ? safeDisplay(option.label) : ansi.dim(this.#copy.empty); + } + if (field.kind === 'multi_select' && Array.isArray(draft.value)) { + return formatUiMessage( + this.#copy.selectedCount, + { count: draft.value.length }, + this.input.locale, + ); + } + return typeof draft.value === 'string' && draft.value.length > 0 + ? safeDisplay(draft.value) + : ansi.dim(this.#copy.empty); + } +} + +function draftValue(field: InteractionFormField, draft: TuiFormDraft): InteractionFormValue { + if (field.kind === 'number' || field.kind === 'integer') { + return typeof draft.value === 'string' && draft.value.trim() !== '' + ? Number(draft.value) + : Number.NaN; + } + return draft.value; +} + +function safeDisplay(value: string): string { + return sanitizeUnicodeText(stripAnsi(value), { maxCodePoints: 1_024 }); +} + +function formatReviewRow(text: string, active: boolean, width: number): string { + const padded = padLine(text, width); + return active ? ansi.reverse(padded) : padded; +} + +function padLine(text: string, width: number): string { + const safeWidth = Math.max(1, width); + const trimmed = visibleWidth(text) > safeWidth ? truncateToWidth(text, safeWidth, '') : text; + return `${trimmed}${' '.repeat(Math.max(0, safeWidth - visibleWidth(trimmed)))}`; +} + +function visibleWindow( + length: number, + activeIndex: number, + limit: number, +): { + readonly start: number; + readonly end: number; +} { + if (length <= limit) return { start: 0, end: length }; + const start = Math.min( + Math.max(0, activeIndex - Math.floor(limit / 2)), + Math.max(0, length - limit), + ); + return { start, end: Math.min(length, start + limit) }; +} diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 28934db7bf..d07b53b68c 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -112,6 +112,7 @@ import { createMakaPiTranscriptState, hasRunningUserCommand, activeSandboxBoundaryRequest, + activeFormRequest, activeUserQuestionRequest, applyExpansionDefaultToAll, completePendingInteraction, @@ -128,6 +129,8 @@ import { type ExpansionEntryKind, type MakaPiTranscriptMetadata, } from './pi-transcript.js'; +import { FormInteractionOverlay } from './pi-tui-form-interaction.js'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import { runMakaPiTuiTurn, type MakaPiTuiTurnRequest } from './pi-tui-turn.js'; import { editorTheme, selectListTheme } from './tui-ansi.js'; import { MakaAutocompleteAboveEditorComponent } from './tui-autocomplete-layout.js'; @@ -516,6 +519,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { answers: Array; } | undefined; + let formResponseInFlightRequestId: string | undefined; + let formOverlay: OverlayHandle | undefined; + let formOverlayComponent: FormInteractionOverlay | undefined; + let formOverlayRequestId: string | undefined; let turnRunning = false; // Monotonic generation for visible agent turns. A mid-turn `/session` // switch-away (#3380) bumps it to orphan the in-flight drain: every callback @@ -726,7 +733,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return; } permissionResponseInFlightRequestId = null; - syncUserQuestionOverlay(); + syncInteractionOverlays(); requestRender(); }) ?? (() => {}); const unsubscribeTranscriptReplacements = @@ -734,6 +741,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (closed || input.driver.getSessionId() !== sessionId) return; if (reason === 'reconnect') { replaceTranscript(messages, { preserveClientLocalEntries: true }); + syncInteractionOverlays(); shellRunElapsedTicker.sync(); requestRender(); const messageIds = state.entries.flatMap((entry) => @@ -1491,6 +1499,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { replaceTranscript(authoritativeAttachedTurn.messages, { preserveClientLocalEntries: true, }); + syncInteractionOverlays(); shellRunHydration.reset(); if (input.listShellRunUpdates) { await shellRunHydration.hydrate(authoritativeAttachedTurn.sessionId); @@ -1513,7 +1522,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // not reach the adopted Session's transcript or overlays. if (superseded()) return; if ( - (event.type === 'sandbox_boundary_request' || event.type === 'user_question_request') && + (event.type === 'sandbox_boundary_request' || + event.type === 'user_question_request' || + event.type === 'form_request') && resolvedInteractionIds.delete(event.requestId) ) { return; @@ -1539,7 +1550,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { permissionAlerted = false; } shellRunElapsedTicker.sync(); - syncUserQuestionOverlay(); + syncInteractionOverlays(); requestRender(); }, // A turn failing is worth pulling the user back, regardless of how long it @@ -1553,7 +1564,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { appendTurnFailureToTranscript(state, error); attention.attentionNeeded(); shellRunElapsedTicker.sync(); - syncUserQuestionOverlay(); + syncInteractionOverlays(); requestRender(); }, }).then( @@ -1717,6 +1728,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }: MakaSessionSwitchResult): Promise => { adoptSessionMetadata(summary, false); replaceTranscript(messages); + syncInteractionOverlays(); if (connectionIdentityNotice) { state.entries.push({ kind: 'notice', level: 'error', text: connectionIdentityNotice }); } @@ -2108,13 +2120,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { completePendingInteraction(state, requestId); } userQuestionProgress = undefined; - syncUserQuestionOverlay(); + syncInteractionOverlays(); requestRender(); }) .catch((error) => { userQuestionInFlight = false; reportError(error); - syncUserQuestionOverlay(); + syncInteractionOverlays(); }); }; @@ -2168,6 +2180,75 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } }; + const closeFormOverlay = (): void => { + formOverlay?.hide(); + formOverlay = undefined; + formOverlayComponent = undefined; + formOverlayRequestId = undefined; + }; + + const finishUserForm = (response: InteractionFormResponse): void => { + if (formResponseInFlightRequestId) return; + const respond = input.driver.respondToUserForm; + if (!respond) { + formOverlayComponent?.setSubmissionFailed(); + reportError(new Error('User forms are unavailable on this driver.')); + return; + } + formResponseInFlightRequestId = response.requestId; + void respond + .call(input.driver, response) + .then(() => { + if (formResponseInFlightRequestId === response.requestId) { + formResponseInFlightRequestId = undefined; + } + if (activeFormRequest(state)?.requestId === response.requestId) { + completePendingInteraction(state, response.requestId); + } + closeFormOverlay(); + syncInteractionOverlays(); + requestRender(); + }) + .catch((error) => { + if (formResponseInFlightRequestId === response.requestId) { + formResponseInFlightRequestId = undefined; + } + if ( + activeFormRequest(state)?.requestId === response.requestId && + formOverlayRequestId === response.requestId + ) { + formOverlayComponent?.setSubmissionFailed(); + } else { + closeFormOverlay(); + syncInteractionOverlays(); + } + reportError(error); + requestRender(); + }); + }; + + const syncFormOverlay = (): void => { + const request = activeFormRequest(state); + if (!request) { + closeFormOverlay(); + return; + } + if (formOverlayRequestId !== request.requestId) closeFormOverlay(); + if (formResponseInFlightRequestId || formOverlayComponent) return; + formOverlayComponent = new FormInteractionOverlay(tui, { + locale, + request, + onRespond: finishUserForm, + }); + formOverlayRequestId = request.requestId; + formOverlay = showBottomPicker(formOverlayComponent); + }; + + const syncInteractionOverlays = (): void => { + syncUserQuestionOverlay(); + syncFormOverlay(); + }; + const showSelectPicker = ( title: string, rightLabel: string, @@ -2563,7 +2644,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { applyMakaSessionEventToTranscript(state, event); ctxRefresher?.observe(event); shellRunElapsedTicker.sync(); - syncUserQuestionOverlay(); + syncInteractionOverlays(); requestRender(); } } catch (error) { @@ -2789,6 +2870,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // session, send a prompt to begin" cue. A notice here would make entries // non-empty and suppress it. replaceTranscript([]); + syncInteractionOverlays(); shellRunElapsedTicker.sync(); await discardCurrentSidePair(); requestRender(); @@ -3904,7 +3986,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return { consume: true }; } if ( - activeUserQuestionRequest(state) && + (activeUserQuestionRequest(state) || activeFormRequest(state)) && turnRunning && matchesKey(data, Key.ctrl('c')) && !isKeyRepeat(data) diff --git a/packages/cli/src/runtime-host-run-command.ts b/packages/cli/src/runtime-host-run-command.ts index 92b84cc285..daa99e57b4 100644 --- a/packages/cli/src/runtime-host-run-command.ts +++ b/packages/cli/src/runtime-host-run-command.ts @@ -453,7 +453,11 @@ class RuntimeHostRunRuntime implements MakaRunRuntime { const next = await this.#interactions.race(events.next()); if (next.done) break; const event = next.value; - if (event.type === 'user_question_request' || event.type === 'sandbox_boundary_request') { + if ( + event.type === 'user_question_request' || + event.type === 'form_request' || + event.type === 'sandbox_boundary_request' + ) { continue; } active.outcome.accept(observationFromSessionEvent(event)); @@ -892,7 +896,9 @@ class NonInteractiveInteractionController { throw new Error( pending.request.kind === 'question' ? 'interactive user questions are unavailable in non-interactive mode' - : 'interactive permission requests are unavailable in non-interactive mode', + : pending.request.kind === 'form' + ? 'interactive user forms are unavailable in non-interactive mode' + : 'interactive permission requests are unavailable in non-interactive mode', ); } diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index 3937b371ad..a75ff5d63b 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -288,6 +288,8 @@ export class RuntimeHostSessionChannel { }; if (answered.outcome.kind === 'question_answer') { this.#emit({ type: 'user_question_answer_ack', ...base }); + } else if (answered.outcome.kind === 'form_answer') { + this.#emit({ type: 'form_answer_ack', ...base }); } else if (answered.outcome.kind === 'sandbox_boundary_decision') { this.#emit({ type: 'sandbox_boundary_decision_ack', diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 8a79e6f0b6..bcbce2672f 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -52,6 +52,7 @@ import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; import { isRuntimeHostTerminalTurn as isTerminalTurn } from '@maka/runtime-host/adapter'; import type { DirectRequestOperationKey, RuntimeHostConnection } from '@maka/runtime-host/client'; @@ -592,6 +593,23 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { if (pending) this.#channel?.publishInteractionAnswer(answered, pending); } + async respondToUserForm(response: InteractionFormResponse): Promise { + const sessionId = this.#requireSession('respond to a user form'); + const pending = this.#channel?.pendingInteraction(response.requestId); + if (pending && pending.request.kind !== 'form') { + throw new Error('Interaction is not a form request'); + } + const answered = await this.#request('interaction.answer', { + sessionId, + interactionId: response.requestId, + answer: + response.action === 'accept' + ? { kind: 'form', action: 'accept', values: response.values } + : { kind: 'form', action: response.action }, + }); + if (pending) this.#channel?.publishInteractionAnswer(answered, pending); + } + setModel(model: string, connectionSlug?: string, connectionId?: string): Promise { return this.#admit(() => this.#setModel(model, connectionSlug, connectionId)); } diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 4dac719577..aef111ab4f 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -26,6 +26,7 @@ import type { SessionSummary, StoredMessage } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { CreateSessionInput, TurnOrchestration } from '@maka/core/runtime-inputs'; import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; import type { @@ -165,6 +166,7 @@ export interface MakaSessionDriver { retractQueued?(): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion?(response: UserQuestionResponse): Promise; + respondToUserForm?(response: InteractionFormResponse): Promise; setModel(model: string, connectionSlug?: string, connectionId?: string): Promise; setThinkingLevel(level: ThinkingLevel | undefined): Promise; setPermissionMode(mode: PermissionMode): Promise; diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index 94ab6d6584..20141258e6 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -253,6 +253,40 @@ export const TUI_COPY_RESOURCES = { }, }, }, + 'form-interaction': { + en: { + requestedBy: 'Requested by {detail}', + sensitiveWarning: 'Do not enter passwords, API keys, access tokens, or payment details.', + required: 'required', + optional: 'optional', + omitted: 'omitted', + empty: 'empty', + invalid: "Value does not meet this field's constraints", + reviewHint: '↑↓ field · Enter edit · Space include/omit · s submit · d decline · Esc cancel', + textHint: 'Type a value · Enter review · Esc review', + choiceHint: '↑↓ select · Enter review · Esc review', + multiHint: '↑↓ move · Space toggle · Enter review · Esc review', + trueValue: 'true', + falseValue: 'false', + selectedCount: '{count} selected', + }, + zh: { + requestedBy: '由 {detail} 请求', + sensitiveWarning: '请勿输入密码、API 密钥、访问令牌或支付信息。', + required: '必填', + optional: '选填', + omitted: '未提供', + empty: '空', + invalid: '该值不符合字段约束', + reviewHint: '↑↓ 选择字段 · Enter 编辑 · Space 提供/省略 · s 提交 · d 拒绝 · Esc 取消', + textHint: '输入值 · Enter 返回检查 · Esc 返回检查', + choiceHint: '↑↓ 选择 · Enter 返回检查 · Esc 返回检查', + multiHint: '↑↓ 移动 · Space 切换 · Enter 返回检查 · Esc 返回检查', + trueValue: '是', + falseValue: '否', + selectedCount: '已选择 {count} 项', + }, + }, pickers: { en: { modelPickerTitle: 'Select Model', diff --git a/packages/core/package.json b/packages/core/package.json index 11e29d6f3b..194034d62b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -142,6 +142,7 @@ "./task-submission-readiness": "./dist/task-submission-readiness.js", "./terminal-input": "./dist/terminal-input.js", "./terminal-mouse-input": "./dist/terminal-mouse-input.js", + "./text-sanitize": "./dist/text-sanitize.js", "./tool-activity-args": "./dist/tool-activity-args.js", "./tool-quiet-preview": "./dist/tool-quiet-preview.js", "./tool-result-record-schema": "./dist/tool-result-record-schema.js", From bea0bcebb1bd2d37a3dcd78c551330fd42ff8a54 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 1 Sep 2026 16:04:58 +0800 Subject: [PATCH 2/4] fix(tui): retain reconnecting form drafts --- .../__tests__/pi-tui-form-interaction.test.ts | 32 ++++++ .../cli/src/__tests__/pi-tui-runner.test.ts | 60 +++++++++-- packages/cli/src/pi-tui-form-interaction.ts | 99 ++++++++++++++++++- packages/cli/src/pi-tui-runner.ts | 83 +++++++++++++++- packages/cli/src/tui-copy-catalog.ts | 20 ++++ 5 files changed, 277 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-form-interaction.test.ts b/packages/cli/src/__tests__/pi-tui-form-interaction.test.ts index 4c9e9cb039..8466823935 100644 --- a/packages/cli/src/__tests__/pi-tui-form-interaction.test.ts +++ b/packages/cli/src/__tests__/pi-tui-form-interaction.test.ts @@ -132,6 +132,38 @@ test('overlay retains invalid drafts, then submits the corrected value', () => { ]); }); +test('overlay restores a same-request draft and explains active constraints', () => { + const request = { + ...REQUEST, + fields: [{ + kind: 'string', + name: 'version', + label: 'Version', + required: true, + minLength: 2, + maxLength: 12, + format: 'date-time', + }], + } satisfies FormRequestEvent; + const first = new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request, + onRespond: () => undefined, + }); + assert.match(rendered(first), /2–12 characters · Format: date-time/u); + first.handleInput('\r'); + first.handleInput('2'); + first.handleInput('\r'); + + const restored = new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request: { ...request, fields: request.fields.map((field) => ({ ...field })) }, + initialDrafts: first.snapshotDrafts(), + onRespond: () => undefined, + }); + assert.match(rendered(restored), /Version \(required\): 2/u); +}); + test('overlay distinguishes optional false, decline, and cancel', () => { const request = { ...REQUEST, fields: [REQUEST.fields[3]!] }; const accepted: InteractionFormResponse[] = []; diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 199a4386cd..73199c4919 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -2325,6 +2325,42 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('restores a still-pending form draft after reconnect replay', async () => { + const terminal = new FakeTerminal(100, 30); + const driver = new FormPromptDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('deploy'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Configure deployment'), + ); + terminal.input('\r'); + terminal.input('v2'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Version (required): v2')); + + driver.publishReconnect(); + await waitFor(() => + !plainTerminalOutput(terminal.screenOutput()).includes('Configure deployment'), + ); + driver.replayForm(); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Version (required): v2')); + + terminal.input('\u001b'); + await waitFor(() => driver.responses.length === 1); + exitMaka(terminal); + await run; + }); + test('off-screen shell-run settle never clears scrollback (#1135)', async () => { const terminal = new FakeTerminal(); const driver = new OffscreenSettleDriver(); @@ -9129,7 +9165,7 @@ class LongOptionsQuestionDriver extends FakeSessionDriver { class FormPromptDriver extends FakeSessionDriver { readonly responses: InteractionFormResponse[] = []; stopCalls = 0; - private release: (() => void) | undefined; + private wake: ((action: 'replay' | 'release') => void) | undefined; readonly #transcriptListeners = new Set< ( sessionId: string, @@ -9143,7 +9179,7 @@ class FormPromptDriver extends FakeSessionDriver { return prepareTestPrompt(this, prompt); } async *promptEvents(_prompt: string): AsyncIterable { - yield { + const formRequest = { type: 'form_request', id: 'event-form', turnId: 'turn-1', @@ -9156,19 +9192,24 @@ class FormPromptDriver extends FakeSessionDriver { { kind: 'string', name: 'version', label: 'Version', required: true, minLength: 2 }, { kind: 'boolean', name: 'notify', label: 'Notify', required: false }, ], - }; - await new Promise((resolve) => { - this.release = resolve; - }); + } satisfies SessionEvent; + yield formRequest; + while (true) { + const action = await new Promise<'replay' | 'release'>((resolve) => { + this.wake = resolve; + }); + if (action === 'release') break; + yield { ...formRequest, id: `event-form-replay-${Date.now()}` }; + } yield { type: 'complete', id: 'complete-1', turnId: 'turn-1', ts: 2, stopReason: 'end_turn' }; } async respondToUserForm(response: InteractionFormResponse): Promise { this.responses.push(response); - this.release?.(); + this.wake?.('release'); } async stop(): Promise { this.stopCalls += 1; - this.release?.(); + this.wake?.('release'); } subscribeTranscriptReplacements( listener: ( @@ -9186,6 +9227,9 @@ class FormPromptDriver extends FakeSessionDriver { listener('session-1', 'turn-1', [], 'reconnect'); } } + replayForm(): void { + this.wake?.('replay'); + } } class InterruptibleTurnDriver extends FakeSessionDriver { diff --git a/packages/cli/src/pi-tui-form-interaction.ts b/packages/cli/src/pi-tui-form-interaction.ts index ecb622be57..d3cb1ec990 100644 --- a/packages/cli/src/pi-tui-form-interaction.ts +++ b/packages/cli/src/pi-tui-form-interaction.ts @@ -57,6 +57,16 @@ interface TuiFormCopy { readonly omitted: string; readonly empty: string; readonly invalid: string; + readonly minimumLength: string; + readonly maximumLength: string; + readonly lengthRange: string; + readonly minimumValue: string; + readonly maximumValue: string; + readonly valueRange: string; + readonly minimumItems: string; + readonly maximumItems: string; + readonly itemRange: string; + readonly format: string; readonly reviewHint: string; readonly textHint: string; readonly choiceHint: string; @@ -129,11 +139,14 @@ export class FormInteractionOverlay implements Component { private readonly input: { readonly locale: UiLocale; readonly request: FormRequestEvent; + readonly initialDrafts?: readonly TuiFormDraft[]; readonly onRespond: (response: InteractionFormResponse) => void; }, ) { this.#copy = FORM_COPY[input.locale]; - this.#drafts = createTuiFormDrafts(input.request.fields); + this.#drafts = input.initialDrafts?.length === input.request.fields.length + ? cloneTuiFormDrafts(input.initialDrafts) + : createTuiFormDrafts(input.request.fields); this.#editor = new Editor(tui, editorTheme(), { paddingX: 0 }); this.#editor.onChange = (value) => { if (this.#mode.kind !== 'text') return; @@ -149,6 +162,10 @@ export class FormInteractionOverlay implements Component { this.#submitting = false; } + snapshotDrafts(): readonly TuiFormDraft[] { + return cloneTuiFormDrafts(this.#drafts); + } + handleInput(data: string): void { if (this.#submitting) return; if (this.#mode.kind === 'review') { @@ -359,6 +376,10 @@ export class FormInteractionOverlay implements Component { if (index === this.#activeIndex && field.description) { lines.push(padLine(` ${ansi.dim(safeDisplay(field.description))}`, width)); } + if (index === this.#activeIndex) { + const constraint = this.#constraint(field); + if (constraint) lines.push(padLine(` ${ansi.dim(constraint)}`, width)); + } }); if (window.end < this.input.request.fields.length) { lines.push( @@ -380,7 +401,7 @@ export class FormInteractionOverlay implements Component { `${safeDisplay(field.label)} (${field.required ? this.#copy.required : this.#copy.optional})`, width, ), - ...(field.description ? [padLine(ansi.dim(safeDisplay(field.description)), width)] : []), + ...this.#fieldDetails(field, width), ...this.#editor.render(width), padLine(ansi.dim(this.#copy.textHint), width), ]; @@ -396,7 +417,7 @@ export class FormInteractionOverlay implements Component { const window = visibleWindow(options.length, mode.optionIndex, FORM_CHOICE_MAX_VISIBLE_OPTIONS); return [ padLine(safeDisplay(field.label), width), - ...(field.description ? [padLine(ansi.dim(safeDisplay(field.description)), width)] : []), + ...this.#fieldDetails(field, width), ...(window.start > 0 ? [padLine(ansi.dim(` … ↑ ${window.start}`), width)] : []), ...options.slice(window.start, window.end).map((label, offset) => { const index = window.start + offset; @@ -425,7 +446,7 @@ export class FormInteractionOverlay implements Component { ); return [ padLine(safeDisplay(field.label), width), - ...(field.description ? [padLine(ansi.dim(safeDisplay(field.description)), width)] : []), + ...this.#fieldDetails(field, width), ...(window.start > 0 ? [padLine(ansi.dim(` … ↑ ${window.start}`), width)] : []), ...field.options.slice(window.start, window.end).map((option, offset) => { const index = window.start + offset; @@ -458,6 +479,76 @@ export class FormInteractionOverlay implements Component { ? safeDisplay(draft.value) : ansi.dim(this.#copy.empty); } + + #fieldDetails(field: InteractionFormField, width: number): string[] { + const details = field.description ? [safeDisplay(field.description)] : []; + const constraint = this.#constraint(field); + if (constraint) details.push(constraint); + return details.map((detail) => padLine(ansi.dim(detail), width)); + } + + #constraint(field: InteractionFormField): string | undefined { + const constraints: string[] = []; + if (field.kind === 'string') { + if (field.minLength !== undefined && field.maxLength !== undefined) { + constraints.push(formatUiMessage(this.#copy.lengthRange, { + minimum: field.minLength, + maximum: field.maxLength, + }, this.input.locale)); + } else if (field.minLength !== undefined) { + constraints.push(formatUiMessage(this.#copy.minimumLength, { + minimum: field.minLength, + }, this.input.locale)); + } else if (field.maxLength !== undefined) { + constraints.push(formatUiMessage(this.#copy.maximumLength, { + maximum: field.maxLength, + }, this.input.locale)); + } + if (field.format !== undefined) { + constraints.push(formatUiMessage(this.#copy.format, { + format: field.format, + }, this.input.locale)); + } + } else if (field.kind === 'number' || field.kind === 'integer') { + if (field.minimum !== undefined && field.maximum !== undefined) { + constraints.push(formatUiMessage(this.#copy.valueRange, { + minimum: field.minimum, + maximum: field.maximum, + }, this.input.locale)); + } else if (field.minimum !== undefined) { + constraints.push(formatUiMessage(this.#copy.minimumValue, { + minimum: field.minimum, + }, this.input.locale)); + } else if (field.maximum !== undefined) { + constraints.push(formatUiMessage(this.#copy.maximumValue, { + maximum: field.maximum, + }, this.input.locale)); + } + } else if (field.kind === 'multi_select') { + if (field.minItems !== undefined && field.maxItems !== undefined) { + constraints.push(formatUiMessage(this.#copy.itemRange, { + minimum: field.minItems, + maximum: field.maxItems, + }, this.input.locale)); + } else if (field.minItems !== undefined) { + constraints.push(formatUiMessage(this.#copy.minimumItems, { + minimum: field.minItems, + }, this.input.locale)); + } else if (field.maxItems !== undefined) { + constraints.push(formatUiMessage(this.#copy.maximumItems, { + maximum: field.maxItems, + }, this.input.locale)); + } + } + return constraints.length === 0 ? undefined : constraints.join(' · '); + } +} + +function cloneTuiFormDrafts(drafts: readonly TuiFormDraft[]): TuiFormDraft[] { + return drafts.map((draft) => ({ + included: draft.included, + value: Array.isArray(draft.value) ? [...draft.value] : draft.value, + })); } function draftValue(field: InteractionFormField, draft: TuiFormDraft): InteractionFormValue { diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index d07b53b68c..3075ff6c98 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -129,7 +129,7 @@ import { type ExpansionEntryKind, type MakaPiTranscriptMetadata, } from './pi-transcript.js'; -import { FormInteractionOverlay } from './pi-tui-form-interaction.js'; +import { FormInteractionOverlay, type TuiFormDraft } from './pi-tui-form-interaction.js'; import type { InteractionFormResponse } from '@maka/core/interaction'; import { runMakaPiTuiTurn, type MakaPiTuiTurnRequest } from './pi-tui-turn.js'; import { editorTheme, selectListTheme } from './tui-ansi.js'; @@ -523,6 +523,16 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { let formOverlay: OverlayHandle | undefined; let formOverlayComponent: FormInteractionOverlay | undefined; let formOverlayRequestId: string | undefined; + let formOverlaySessionId: string | undefined; + let formOverlaySchema: string | undefined; + let retainedFormDraft: + | { + readonly sessionId: string; + readonly requestId: string; + readonly schema: string; + readonly drafts: readonly TuiFormDraft[]; + } + | undefined; let turnRunning = false; // Monotonic generation for visible agent turns. A mid-turn `/session` // switch-away (#3380) bumps it to orphan the in-flight drain: every callback @@ -2180,11 +2190,27 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } }; - const closeFormOverlay = (): void => { + const closeFormOverlay = (retainDraft = true): void => { + if ( + retainDraft && + formOverlayComponent && + formOverlaySessionId && + formOverlayRequestId && + formOverlaySchema + ) { + retainedFormDraft = { + sessionId: formOverlaySessionId, + requestId: formOverlayRequestId, + schema: formOverlaySchema, + drafts: formOverlayComponent.snapshotDrafts(), + }; + } formOverlay?.hide(); formOverlay = undefined; formOverlayComponent = undefined; formOverlayRequestId = undefined; + formOverlaySessionId = undefined; + formOverlaySchema = undefined; }; const finishUserForm = (response: InteractionFormResponse): void => { @@ -2195,6 +2221,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { reportError(new Error('User forms are unavailable on this driver.')); return; } + const responseSessionId = formOverlaySessionId ?? input.driver.getSessionId(); formResponseInFlightRequestId = response.requestId; void respond .call(input.driver, response) @@ -2202,10 +2229,24 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (formResponseInFlightRequestId === response.requestId) { formResponseInFlightRequestId = undefined; } - if (activeFormRequest(state)?.requestId === response.requestId) { + if ( + input.driver.getSessionId() === responseSessionId && + activeFormRequest(state)?.requestId === response.requestId + ) { completePendingInteraction(state, response.requestId); } - closeFormOverlay(); + if ( + retainedFormDraft?.sessionId === responseSessionId && + retainedFormDraft.requestId === response.requestId + ) { + retainedFormDraft = undefined; + } + if ( + formOverlaySessionId === responseSessionId && + formOverlayRequestId === response.requestId + ) { + closeFormOverlay(false); + } syncInteractionOverlays(); requestRender(); }) @@ -2215,6 +2256,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } if ( activeFormRequest(state)?.requestId === response.requestId && + formOverlaySessionId === responseSessionId && formOverlayRequestId === response.requestId ) { formOverlayComponent?.setSubmissionFailed(); @@ -2233,13 +2275,44 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { closeFormOverlay(); return; } - if (formOverlayRequestId !== request.requestId) closeFormOverlay(); + const sessionId = input.driver.getSessionId(); + if (!sessionId) { + closeFormOverlay(); + return; + } + if ( + retainedFormDraft?.sessionId === sessionId && + retainedFormDraft.requestId !== request.requestId + ) { + retainedFormDraft = undefined; + } + if ( + formOverlaySessionId !== sessionId || + formOverlayRequestId !== request.requestId + ) closeFormOverlay(); if (formResponseInFlightRequestId || formOverlayComponent) return; + const schema = JSON.stringify(request.fields); + const restoredDrafts = + retainedFormDraft?.sessionId === sessionId && + retainedFormDraft.requestId === request.requestId && + retainedFormDraft.schema === schema + ? retainedFormDraft.drafts + : undefined; + if ( + retainedFormDraft?.sessionId === sessionId && + retainedFormDraft.requestId === request.requestId && + retainedFormDraft.schema !== schema + ) { + retainedFormDraft = undefined; + } formOverlayComponent = new FormInteractionOverlay(tui, { locale, request, + initialDrafts: restoredDrafts, onRespond: finishUserForm, }); + formOverlaySessionId = sessionId; + formOverlaySchema = schema; formOverlayRequestId = request.requestId; formOverlay = showBottomPicker(formOverlayComponent); }; diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index 20141258e6..3cfdd9cf26 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -262,6 +262,16 @@ export const TUI_COPY_RESOURCES = { omitted: 'omitted', empty: 'empty', invalid: "Value does not meet this field's constraints", + minimumLength: 'At least {minimum} characters', + maximumLength: 'At most {maximum} characters', + lengthRange: '{minimum}–{maximum} characters', + minimumValue: 'Minimum {minimum}', + maximumValue: 'Maximum {maximum}', + valueRange: 'Range {minimum}–{maximum}', + minimumItems: 'Select at least {minimum}', + maximumItems: 'Select at most {maximum}', + itemRange: 'Select {minimum}–{maximum}', + format: 'Format: {format}', reviewHint: '↑↓ field · Enter edit · Space include/omit · s submit · d decline · Esc cancel', textHint: 'Type a value · Enter review · Esc review', choiceHint: '↑↓ select · Enter review · Esc review', @@ -278,6 +288,16 @@ export const TUI_COPY_RESOURCES = { omitted: '未提供', empty: '空', invalid: '该值不符合字段约束', + minimumLength: '至少 {minimum} 个字符', + maximumLength: '最多 {maximum} 个字符', + lengthRange: '长度 {minimum}–{maximum} 个字符', + minimumValue: '最小值 {minimum}', + maximumValue: '最大值 {maximum}', + valueRange: '范围 {minimum}–{maximum}', + minimumItems: '至少选择 {minimum} 项', + maximumItems: '最多选择 {maximum} 项', + itemRange: '选择 {minimum}–{maximum} 项', + format: '格式:{format}', reviewHint: '↑↓ 选择字段 · Enter 编辑 · Space 提供/省略 · s 提交 · d 拒绝 · Esc 取消', textHint: '输入值 · Enter 返回检查 · Esc 返回检查', choiceHint: '↑↓ 选择 · Enter 返回检查 · Esc 返回检查', From 61e2d81501130974c880f892ca053c0d521bb400 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 1 Sep 2026 16:24:22 +0800 Subject: [PATCH 3/4] test(tui): cover form constraint copy variables --- packages/cli/src/__tests__/tui-copy-catalog.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/src/__tests__/tui-copy-catalog.test.ts b/packages/cli/src/__tests__/tui-copy-catalog.test.ts index 0e50a3ec3d..289fbb16f2 100644 --- a/packages/cli/src/__tests__/tui-copy-catalog.test.ts +++ b/packages/cli/src/__tests__/tui-copy-catalog.test.ts @@ -35,6 +35,9 @@ const MESSAGE_VALUES = { request: 'nope', reason: 'not found', limit: 3, + minimum: 1, + maximum: 3, + format: 'email', notice: 'The original account was deleted.', recovery: 'Add or enable a connection first.', } as const; From 2f13e01fb54cb631f8303fd0e99869a83050f367 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 3 Sep 2026 14:02:45 +0800 Subject: [PATCH 4/4] style(tui): format form interaction sources The TUI form sources predate the formatter rules now on main; rebase onto the current parent and reformat so the changed-file biome gate passes. Reflow and trailing commas only, no semantic change. --- .../__tests__/pi-tui-form-interaction.test.ts | 20 +-- .../cli/src/__tests__/pi-tui-runner.test.ts | 12 +- packages/cli/src/pi-tui-form-interaction.ts | 133 +++++++++++++----- packages/cli/src/pi-tui-runner.ts | 6 +- 4 files changed, 118 insertions(+), 53 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-form-interaction.test.ts b/packages/cli/src/__tests__/pi-tui-form-interaction.test.ts index 8466823935..bdae0f633f 100644 --- a/packages/cli/src/__tests__/pi-tui-form-interaction.test.ts +++ b/packages/cli/src/__tests__/pi-tui-form-interaction.test.ts @@ -135,15 +135,17 @@ test('overlay retains invalid drafts, then submits the corrected value', () => { test('overlay restores a same-request draft and explains active constraints', () => { const request = { ...REQUEST, - fields: [{ - kind: 'string', - name: 'version', - label: 'Version', - required: true, - minLength: 2, - maxLength: 12, - format: 'date-time', - }], + fields: [ + { + kind: 'string', + name: 'version', + label: 'Version', + required: true, + minLength: 2, + maxLength: 12, + format: 'date-time', + }, + ], } satisfies FormRequestEvent; const first = new FormInteractionOverlay(fakeTui(), { locale: 'en', diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 73199c4919..2c633b4642 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -2346,14 +2346,18 @@ describe('Maka Pi TUI runner', () => { terminal.input('\r'); terminal.input('v2'); terminal.input('\r'); - await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Version (required): v2')); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Version (required): v2'), + ); driver.publishReconnect(); - await waitFor(() => - !plainTerminalOutput(terminal.screenOutput()).includes('Configure deployment'), + await waitFor( + () => !plainTerminalOutput(terminal.screenOutput()).includes('Configure deployment'), ); driver.replayForm(); - await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Version (required): v2')); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Version (required): v2'), + ); terminal.input('\u001b'); await waitFor(() => driver.responses.length === 1); diff --git a/packages/cli/src/pi-tui-form-interaction.ts b/packages/cli/src/pi-tui-form-interaction.ts index d3cb1ec990..1b5051cecd 100644 --- a/packages/cli/src/pi-tui-form-interaction.ts +++ b/packages/cli/src/pi-tui-form-interaction.ts @@ -144,9 +144,10 @@ export class FormInteractionOverlay implements Component { }, ) { this.#copy = FORM_COPY[input.locale]; - this.#drafts = input.initialDrafts?.length === input.request.fields.length - ? cloneTuiFormDrafts(input.initialDrafts) - : createTuiFormDrafts(input.request.fields); + this.#drafts = + input.initialDrafts?.length === input.request.fields.length + ? cloneTuiFormDrafts(input.initialDrafts) + : createTuiFormDrafts(input.request.fields); this.#editor = new Editor(tui, editorTheme(), { paddingX: 0 }); this.#editor.onChange = (value) => { if (this.#mode.kind !== 'text') return; @@ -491,53 +492,113 @@ export class FormInteractionOverlay implements Component { const constraints: string[] = []; if (field.kind === 'string') { if (field.minLength !== undefined && field.maxLength !== undefined) { - constraints.push(formatUiMessage(this.#copy.lengthRange, { - minimum: field.minLength, - maximum: field.maxLength, - }, this.input.locale)); + constraints.push( + formatUiMessage( + this.#copy.lengthRange, + { + minimum: field.minLength, + maximum: field.maxLength, + }, + this.input.locale, + ), + ); } else if (field.minLength !== undefined) { - constraints.push(formatUiMessage(this.#copy.minimumLength, { - minimum: field.minLength, - }, this.input.locale)); + constraints.push( + formatUiMessage( + this.#copy.minimumLength, + { + minimum: field.minLength, + }, + this.input.locale, + ), + ); } else if (field.maxLength !== undefined) { - constraints.push(formatUiMessage(this.#copy.maximumLength, { - maximum: field.maxLength, - }, this.input.locale)); + constraints.push( + formatUiMessage( + this.#copy.maximumLength, + { + maximum: field.maxLength, + }, + this.input.locale, + ), + ); } if (field.format !== undefined) { - constraints.push(formatUiMessage(this.#copy.format, { - format: field.format, - }, this.input.locale)); + constraints.push( + formatUiMessage( + this.#copy.format, + { + format: field.format, + }, + this.input.locale, + ), + ); } } else if (field.kind === 'number' || field.kind === 'integer') { if (field.minimum !== undefined && field.maximum !== undefined) { - constraints.push(formatUiMessage(this.#copy.valueRange, { - minimum: field.minimum, - maximum: field.maximum, - }, this.input.locale)); + constraints.push( + formatUiMessage( + this.#copy.valueRange, + { + minimum: field.minimum, + maximum: field.maximum, + }, + this.input.locale, + ), + ); } else if (field.minimum !== undefined) { - constraints.push(formatUiMessage(this.#copy.minimumValue, { - minimum: field.minimum, - }, this.input.locale)); + constraints.push( + formatUiMessage( + this.#copy.minimumValue, + { + minimum: field.minimum, + }, + this.input.locale, + ), + ); } else if (field.maximum !== undefined) { - constraints.push(formatUiMessage(this.#copy.maximumValue, { - maximum: field.maximum, - }, this.input.locale)); + constraints.push( + formatUiMessage( + this.#copy.maximumValue, + { + maximum: field.maximum, + }, + this.input.locale, + ), + ); } } else if (field.kind === 'multi_select') { if (field.minItems !== undefined && field.maxItems !== undefined) { - constraints.push(formatUiMessage(this.#copy.itemRange, { - minimum: field.minItems, - maximum: field.maxItems, - }, this.input.locale)); + constraints.push( + formatUiMessage( + this.#copy.itemRange, + { + minimum: field.minItems, + maximum: field.maxItems, + }, + this.input.locale, + ), + ); } else if (field.minItems !== undefined) { - constraints.push(formatUiMessage(this.#copy.minimumItems, { - minimum: field.minItems, - }, this.input.locale)); + constraints.push( + formatUiMessage( + this.#copy.minimumItems, + { + minimum: field.minItems, + }, + this.input.locale, + ), + ); } else if (field.maxItems !== undefined) { - constraints.push(formatUiMessage(this.#copy.maximumItems, { - maximum: field.maxItems, - }, this.input.locale)); + constraints.push( + formatUiMessage( + this.#copy.maximumItems, + { + maximum: field.maxItems, + }, + this.input.locale, + ), + ); } } return constraints.length === 0 ? undefined : constraints.join(' · '); diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 3075ff6c98..b673dec3e9 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -2286,10 +2286,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { ) { retainedFormDraft = undefined; } - if ( - formOverlaySessionId !== sessionId || - formOverlayRequestId !== request.requestId - ) closeFormOverlay(); + if (formOverlaySessionId !== sessionId || formOverlayRequestId !== request.requestId) + closeFormOverlay(); if (formResponseInFlightRequestId || formOverlayComponent) return; const schema = JSON.stringify(request.fields); const restoredDrafts =