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..bdae0f633f --- /dev/null +++ b/packages/cli/src/__tests__/pi-tui-form-interaction.test.ts @@ -0,0 +1,279 @@ +/* + * 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 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[] = []; + 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..2c633b4642 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,169 @@ 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('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(); @@ -9002,6 +9166,76 @@ class LongOptionsQuestionDriver extends FakeSessionDriver { } } +class FormPromptDriver extends FakeSessionDriver { + readonly responses: InteractionFormResponse[] = []; + stopCalls = 0; + private wake: ((action: 'replay' | '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 { + const formRequest = { + 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 }, + ], + } 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.wake?.('release'); + } + async stop(): Promise { + this.stopCalls += 1; + this.wake?.('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'); + } + } + replayForm(): void { + this.wake?.('replay'); + } +} + 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/__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; 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..1b5051cecd --- /dev/null +++ b/packages/cli/src/pi-tui-form-interaction.ts @@ -0,0 +1,653 @@ +/* + * 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 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; + 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 initialDrafts?: readonly TuiFormDraft[]; + readonly onRespond: (response: InteractionFormResponse) => void; + }, + ) { + this.#copy = FORM_COPY[input.locale]; + 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; + this.#replaceDraft(this.#mode.index, { value }); + }; + } + + invalidate(): void { + this.#editor.invalidate(); + } + + setSubmissionFailed(): void { + this.#submitting = false; + } + + snapshotDrafts(): readonly TuiFormDraft[] { + return cloneTuiFormDrafts(this.#drafts); + } + + 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 (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( + 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, + ), + ...this.#fieldDetails(field, 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), + ...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; + 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), + ...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; + 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); + } + + #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 { + 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..b673dec3e9 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, 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'; import { MakaAutocompleteAboveEditorComponent } from './tui-autocomplete-layout.js'; @@ -516,6 +519,20 @@ 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 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 @@ -726,7 +743,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return; } permissionResponseInFlightRequestId = null; - syncUserQuestionOverlay(); + syncInteractionOverlays(); requestRender(); }) ?? (() => {}); const unsubscribeTranscriptReplacements = @@ -734,6 +751,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 +1509,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 +1532,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 +1560,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 +1574,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { appendTurnFailureToTranscript(state, error); attention.attentionNeeded(); shellRunElapsedTicker.sync(); - syncUserQuestionOverlay(); + syncInteractionOverlays(); requestRender(); }, }).then( @@ -1717,6 +1738,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 +2130,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 +2190,136 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } }; + 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 => { + if (formResponseInFlightRequestId) return; + const respond = input.driver.respondToUserForm; + if (!respond) { + formOverlayComponent?.setSubmissionFailed(); + 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) + .then(() => { + if (formResponseInFlightRequestId === response.requestId) { + formResponseInFlightRequestId = undefined; + } + if ( + input.driver.getSessionId() === responseSessionId && + activeFormRequest(state)?.requestId === response.requestId + ) { + completePendingInteraction(state, response.requestId); + } + if ( + retainedFormDraft?.sessionId === responseSessionId && + retainedFormDraft.requestId === response.requestId + ) { + retainedFormDraft = undefined; + } + if ( + formOverlaySessionId === responseSessionId && + formOverlayRequestId === response.requestId + ) { + closeFormOverlay(false); + } + syncInteractionOverlays(); + requestRender(); + }) + .catch((error) => { + if (formResponseInFlightRequestId === response.requestId) { + formResponseInFlightRequestId = undefined; + } + if ( + activeFormRequest(state)?.requestId === response.requestId && + formOverlaySessionId === responseSessionId && + formOverlayRequestId === response.requestId + ) { + formOverlayComponent?.setSubmissionFailed(); + } else { + closeFormOverlay(); + syncInteractionOverlays(); + } + reportError(error); + requestRender(); + }); + }; + + const syncFormOverlay = (): void => { + const request = activeFormRequest(state); + if (!request) { + closeFormOverlay(); + return; + } + 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); + }; + + const syncInteractionOverlays = (): void => { + syncUserQuestionOverlay(); + syncFormOverlay(); + }; + const showSelectPicker = ( title: string, rightLabel: string, @@ -2563,7 +2715,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { applyMakaSessionEventToTranscript(state, event); ctxRefresher?.observe(event); shellRunElapsedTicker.sync(); - syncUserQuestionOverlay(); + syncInteractionOverlays(); requestRender(); } } catch (error) { @@ -2789,6 +2941,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 +4057,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..3cfdd9cf26 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -253,6 +253,60 @@ 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", + 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', + 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: '该值不符合字段约束', + 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 返回检查', + 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",