You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by zh...@apache.org on 2026/09/01 15:04:01 UTC
(maka) branch feat/4364-tui-form-interactions updated (1804908d63 -> c7abdacadc)
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a change to branch feat/4364-tui-form-interactions
in repository https://gitbox.apache.org/repos/asf/maka.git
omit 1804908d63 test(tui): cover form constraint copy variables
omit e8987174ea fix(tui): retain reconnecting form drafts
omit 9936b170e4 feat(tui): answer structured form interactions
omit 1fba905d60 fix(runtime): harden structured form boundaries
omit 82fbfddbc8 fix(runtime): complete form interaction adapter seam
omit 742eead099 feat(runtime): establish durable form interactions
add 49858c4f0d refactor(runtime-host): make the Host the authority for the model catalog (#4411)
add a5ede6b357 refactor(desktop): move Goal controller ownership below AppShell (#4316)
add 0d3265eaf4 fix(runtime-host): separate the persisted grant record from the authority it derives (#4427)
add 5c1bbe3445 refactor(runtime-host): derive dispatcher operation groups from spec objects (#4402)
add dfe457eac9 refactor(desktop): retire dead Task Ledger CSS and align it with SessionTodoPanel (#4396)
add 6668af3f55 ci: allocate a runner only when its inputs changed (#4461)
add afbcabdc74 fix(runtime-host): page oversized transcript Turns (#4433)
add 32a82527e8 feat(runtime): establish durable form interactions
add d4f969e860 fix(runtime): complete form interaction adapter seam
add 46096224c7 fix(runtime): harden structured form boundaries
new 5a732e779c feat(tui): answer structured form interactions
new 69a2606f58 fix(tui): retain reconnecting form drafts
new c7abdacadc test(tui): cover form constraint copy variables
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (1804908d63)
\
N -- N -- N refs/heads/feat/4364-tui-form-interactions (c7abdacadc)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.asf.yaml | 14 +-
.github/workflows/ci.yml | 219 +++---
.github/workflows/cli-package-validation.yml | 211 +++---
.github/workflows/release-windows-check.yml | 38 +-
.github/workflows/windows-recovery.yml | 120 ++-
apps/desktop/e2e/goal-dialog-budget.spec.ts | 16 +
apps/desktop/renderer-architecture.json | 11 +-
.../scripts/check-renderer-architecture.test.mjs | 46 ++
.../app-shell-session-settings-actions.test.ts | 12 +-
.../src/main/__tests__/goal-provider-scope.test.ts | 216 ++++++
.../src/main/__tests__/goals-boundary.test.ts | 171 ++++-
.../main/__tests__/model-catalog-choices.test.ts | 94 ++-
.../main/__tests__/provider-add-submission.test.ts | 8 +-
.../provider-endpoint-presentation.test.ts | 14 +-
.../runtime-host-account-connection.test.ts | 8 +-
.../__tests__/runtime-host-config-ipc-main.test.ts | 7 +-
.../runtime-host-connections-ipc-main.test.ts | 37 +-
.../runtime-host-github-copilot-ipc-main.test.ts | 9 +-
.../__tests__/runtime-host-oauth-ipc-main.test.ts | 30 +-
.../__tests__/session-health-recovery-flow.test.ts | 5 +-
.../task-submission-readiness-main.test.ts | 23 +-
.../desktop/src/main/connections-ipc-validation.ts | 12 +-
apps/desktop/src/main/onboarding-service.ts | 9 +-
.../src/main/runtime-host-account-connection.ts | 11 +-
apps/desktop/src/main/runtime-host-boot.ts | 6 +-
apps/desktop/src/main/runtime-host-client.ts | 4 +-
.../src/main/runtime-host-config-ipc-main.ts | 4 +-
.../src/main/runtime-host-connections-ipc-main.ts | 27 +-
.../src/main/task-submission-readiness-main.ts | 16 -
apps/desktop/src/preload/bridge-contract.d.ts | 4 +-
apps/desktop/src/preload/preload.ts | 2 +-
.../renderer/app-shell-session-settings-actions.ts | 9 +-
apps/desktop/src/renderer/app-shell.tsx | 26 +-
apps/desktop/src/renderer/chat-composer-region.tsx | 56 +-
apps/desktop/src/renderer/chat-message-surface.tsx | 50 +-
apps/desktop/src/renderer/features/goals/README.md | 36 +-
apps/desktop/src/renderer/features/goals/index.ts | 8 +-
.../desktop/src/renderer/features/goals/testing.ts | 1 +
.../src/renderer/features/goals/ui/goal-host.tsx | 8 +-
.../renderer/features/goals/ui/goal-provider.tsx | 121 +++
apps/desktop/src/renderer/maka-tokens.css | 5 +-
apps/desktop/src/renderer/model-catalog-choices.ts | 101 +--
.../settings/daily-review-settings-page.tsx | 6 +-
.../renderer/settings/general-settings-page.tsx | 12 +-
.../src/renderer/settings/provider-add-form.tsx | 14 +-
.../renderer/settings/provider-add-submission.ts | 4 +-
.../renderer/settings/provider-catalog-page.tsx | 8 +-
.../settings/provider-connection-detail.tsx | 20 +-
.../settings/provider-enabled-model-manager.tsx | 2 +-
.../settings/provider-endpoint-presentation.ts | 7 +-
.../src/renderer/settings/provider-panel-shared.ts | 8 +-
.../src/renderer/settings/providers-panel.tsx | 8 +-
.../renderer/settings/settings-snapshot-cache.ts | 7 +-
.../src/renderer/settings/settings-surface.tsx | 8 +-
.../renderer/settings/subagent-settings-page.tsx | 49 +-
.../src/renderer/settings/use-connection-detail.ts | 59 +-
apps/desktop/src/renderer/styles.css | 2 +-
.../src/renderer/styles/session-todo-panel.css | 94 +++
apps/desktop/src/renderer/styles/task-ledger.css | 135 ----
apps/desktop/src/renderer/use-shell-chat-model.ts | 7 +-
.../src/shared/desktop-connection-snapshot.ts | 4 +-
apps/desktop/stories/onboarding.stories.tsx | 1 -
.../stories/settings/provider-settings.stories.tsx | 12 +-
.../stories/settings/settings-pages.stories.tsx | 16 +-
docs/architecture/runtime-host-architecture.md | 3 +-
docs/astryx-surface-file-inventory.md | 5 +-
docs/astryx-surface-file-inventory.paths | 3 +-
docs/model-metadata-firstscreen-optimization.md | 16 +-
package.json | 2 +-
packages/cli/src/__tests__/pi-tui-runner.test.ts | 118 ++-
.../src/__tests__/runtime-host-onboarding.test.ts | 58 +-
.../src/__tests__/runtime-host-run-command.test.ts | 2 +
packages/cli/src/onboarding-catalog.ts | 8 +-
packages/cli/src/pi-tui-contracts.ts | 21 +-
packages/cli/src/pi-tui-pickers.ts | 20 +-
packages/cli/src/pi-tui-runner.ts | 49 +-
packages/cli/src/runtime-host-cli-context.ts | 5 +-
packages/cli/src/runtime-host-onboarding.ts | 29 +-
packages/cli/src/runtime-host-task-readiness.ts | 5 +-
packages/cli/src/runtime-host-tui-command.ts | 1 -
packages/cli/src/runtime-host-tui-context.ts | 20 +-
.../core/src/__tests__/llm-connections.test.ts | 72 +-
packages/core/src/__tests__/model-catalog.test.ts | 222 +++---
packages/core/src/__tests__/model-metadata.test.ts | 21 +-
packages/core/src/__tests__/model-thinking.test.ts | 1 -
packages/core/src/__tests__/provider-auth.test.ts | 123 +---
.../__tests__/provider-catalog-contract.test.ts | 31 +-
packages/core/src/chat-model-choice.ts | 61 +-
packages/core/src/connection-readiness.ts | 37 +-
packages/core/src/llm-connections.ts | 214 +++---
packages/core/src/model-catalog.ts | 808 ++++++++-------------
packages/core/src/model-facts.ts | 24 +-
packages/core/src/model-metadata.ts | 67 +-
packages/core/src/model-thinking.ts | 4 +-
packages/core/src/onboarding.ts | 7 +-
packages/core/src/provider-auth.ts | 400 ++--------
packages/core/src/provider-registry.ts | 469 +++---------
packages/core/src/runtime-policy.ts | 2 +
.../src/runtime-policy/connection-catalog-codec.ts | 23 +-
.../runtime-policy/model-catalog-entry-codec.ts | 82 +++
packages/core/src/session-send-projection.ts | 7 +-
packages/core/src/task-submission-readiness.ts | 7 +-
.../access-credential-grant-migration.test.ts | 186 +++++
.../__tests__/access-credential-metadata.test.ts | 4 +-
.../src/__tests__/bootstrap-runtime-policy.test.ts | 28 +-
.../src/__tests__/catalog-reader.test.ts | 2 +
.../__tests__/execution-model-composition.test.ts | 4 +-
.../src/__tests__/hosted-execution-client.test.ts | 3 +
.../src/__tests__/hosted-execution-target.test.ts | 3 +
.../__tests__/runtime-policy-coordinator.test.ts | 34 +-
.../src/__tests__/session-transcript-pager.test.ts | 162 ++++-
packages/runtime-host/src/client/catalog-reader.ts | 29 +-
packages/runtime-host/src/client/index.ts | 2 +
packages/runtime-host/src/protocol/index.ts | 11 +-
.../runtime-host/src/protocol/runtime-policy.ts | 152 ++--
.../runtime-host/src/server/access-authority.ts | 27 +-
.../src/server/access-credential-metadata.ts | 10 +-
.../src/server/access-credential-store.ts | 215 ++++--
.../src/server/bootstrap-runtime-policy.ts | 16 +-
.../src/server/connection-effect-coordinator.ts | 10 +-
.../src/server/execution-composition.ts | 11 +
.../src/server/execution-model-authority.ts | 4 +-
.../src/server/operation-dispatcher.ts | 329 +++------
.../src/server/runtime-policy-coordinator.ts | 33 +-
.../src/server/session-transcript-pager.ts | 12 +-
.../src/__tests__/model-factory-thinking.test.ts | 8 +-
.../src/__tests__/provider-conformance.test.ts | 8 +-
.../src/__tests__/provider-contract-matrix.test.ts | 6 +-
.../src/__tests__/provider-contract-matrix.ts | 34 +-
packages/runtime/src/model-fetcher.ts | 24 +-
packages/runtime/src/model-runtime.ts | 4 +-
packages/runtime/src/test-connection.ts | 21 +-
.../src/__tests__/runtime-policy-stores.test.ts | 14 +-
packages/storage/src/runtime-policy-stores.ts | 1 -
.../runtime-policy/connection-catalog-document.ts | 44 +-
packages/storage/src/runtime-policy/coordinator.ts | 52 +-
.../src/runtime-policy/onboarding-transaction.ts | 4 +-
packages/storage/src/runtime-policy/operations.ts | 15 +-
packages/ui/src/chat-view.tsx | 23 +-
packages/ui/src/components.tsx | 12 +
packages/ui/src/composer.tsx | 29 +-
packages/ui/src/goal-projection-context.ts | 63 ++
packages/ui/src/session-todo-panel.tsx | 14 +-
packages/ui/src/styles.css | 2 +-
scripts/asf-source-workflow-policy.test.mjs | 2 +-
scripts/check-app-shell-hooks.mjs | 1 -
scripts/ci-test-plan.mjs | 56 +-
scripts/ci-test-plan.test.mjs | 643 +---------------
...t-plan.test.mjs => ci-workflow-policy.test.mjs} | 664 ++++++-----------
scripts/prepare-windows-upgrade-baseline.mjs | 23 +-
scripts/prepare-windows-upgrade-baseline.test.mjs | 136 ++++
scripts/qualify-released-cli-state-root.mjs | 147 +++-
scripts/qualify-released-cli-state-root.test.mjs | 60 +-
scripts/release-cli-workflow-policy.test.mjs | 65 +-
scripts/released-cli-state-root-fixture.mjs | 74 ++
scripts/update-test-feed-wiring.test.mjs | 67 ++
scripts/windows-package-source-closure.mjs | 23 +-
scripts/windows-package-source-closure.test.mjs | 82 ++-
158 files changed, 4629 insertions(+), 4434 deletions(-)
create mode 100644 apps/desktop/src/main/__tests__/goal-provider-scope.test.ts
create mode 100644 apps/desktop/src/renderer/features/goals/ui/goal-provider.tsx
create mode 100644 apps/desktop/src/renderer/styles/session-todo-panel.css
delete mode 100644 apps/desktop/src/renderer/styles/task-ledger.css
create mode 100644 packages/core/src/runtime-policy/model-catalog-entry-codec.ts
create mode 100644 packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts
create mode 100644 packages/ui/src/goal-projection-context.ts
copy scripts/{ci-test-plan.test.mjs => ci-workflow-policy.test.mjs} (56%)
create mode 100644 scripts/prepare-windows-upgrade-baseline.test.mjs
create mode 100644 scripts/update-test-feed-wiring.test.mjs
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 03/03: test(tui): cover form constraint copy variables
Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a commit to branch feat/4364-tui-form-interactions
in repository https://gitbox.apache.org/repos/asf/maka.git
commit c7abdacadcefd733cb0033c8a56b11d4214d4767
Author: me2seeks <me...@gmail.com>
AuthorDate: Tue Sep 1 16:24:22 2026 +0800
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 0a1ef6b4ea..f5599ee508 100644
--- a/packages/cli/src/__tests__/tui-copy-catalog.test.ts
+++ b/packages/cli/src/__tests__/tui-copy-catalog.test.ts
@@ -34,6 +34,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;
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 01/03: feat(tui): answer structured form interactions
Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a commit to branch feat/4364-tui-form-interactions
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 5a732e779c41e87ef8cf458da555900ff12f1c88
Author: me2seeks <me...@gmail.com>
AuthorDate: Tue Sep 1 00:53:49 2026 +0800
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
---
packages/cli/src/__tests__/pi-transcript.test.ts | 33 +-
.../src/__tests__/pi-tui-form-interaction.test.ts | 245 ++++++++++
packages/cli/src/__tests__/pi-tui-runner.test.ts | 186 ++++++++
.../src/__tests__/runtime-host-run-command.test.ts | 61 +++
.../__tests__/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 +-
packages/cli/src/runtime-host-session-channel.ts | 2 +
packages/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(-)
diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts
index 83e67374a2..95ba9fd710 100644
--- a/packages/cli/src/__tests__/pi-transcript.test.ts
+++ b/packages/cli/src/__tests__/pi-transcript.test.ts
@@ -2089,7 +2089,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,
@@ -2105,6 +2105,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({
@@ -2118,7 +2129,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(
@@ -2132,7 +2143,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 25310bba0c..d654c67e78 100644
--- a/packages/cli/src/__tests__/pi-tui-runner.test.ts
+++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts
@@ -37,6 +37,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,
@@ -1916,6 +1917,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();
@@ -8468,6 +8592,68 @@ class UserQuestionPromptDriver 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<MakaPreparedSessionTurn> {
+ return prepareTestPrompt(this, prompt);
+ }
+ async *promptEvents(_prompt: string): AsyncIterable<SessionEvent> {
+ 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<void>((resolve) => {
+ this.release = resolve;
+ });
+ yield { type: 'complete', id: 'complete-1', turnId: 'turn-1', ts: 2, stopReason: 'end_turn' };
+ }
+ async respondToUserForm(response: InteractionFormResponse): Promise<void> {
+ this.responses.push(response);
+ this.release?.();
+ }
+ async stop(): Promise<void> {
+ 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 dd5eb30cb2..8e46612f60 100644
--- a/packages/cli/src/__tests__/runtime-host-run-command.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-run-command.test.ts
@@ -729,6 +729,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,
@@ -1241,6 +1268,20 @@ async function* questionEvents(turnId: string): AsyncIterable<SessionEvent> {
],
};
}
+
+async function* formEvents(turnId: string): AsyncIterable<SessionEvent> {
+ 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,
@@ -1259,6 +1300,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 a925f859b9..7c30e685c5 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(
@@ -2716,12 +2758,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'
@@ -3051,6 +3105,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 1ef16e170d..490764ee72 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',
@@ -1442,6 +1453,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<TuiFormCopy>()(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<number>();
+ #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<EditMode, { kind: 'choice' }>): 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<EditMode, { kind: 'multi' }>): 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<TuiFormDraft>): 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<EditMode, { kind: 'choice' }>, 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<EditMode, { kind: 'multi' }>, 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 cc6172fa4e..a4a88e4753 100644
--- a/packages/cli/src/pi-tui-runner.ts
+++ b/packages/cli/src/pi-tui-runner.ts
@@ -107,6 +107,7 @@ import {
createMakaPiTranscriptState,
hasRunningUserCommand,
activeSandboxBoundaryRequest,
+ activeFormRequest,
activeUserQuestionRequest,
applyExpansionDefaultToAll,
completePendingInteraction,
@@ -123,6 +124,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';
@@ -491,6 +494,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
answers: Array<string | null>;
}
| 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
@@ -701,7 +708,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
return;
}
permissionResponseInFlightRequestId = null;
- syncUserQuestionOverlay();
+ syncInteractionOverlays();
requestRender();
}) ?? (() => {});
const unsubscribeTranscriptReplacements =
@@ -709,6 +716,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
if (closed || input.driver.getSessionId() !== sessionId) return;
if (reason === 'reconnect') {
replaceTranscript(messages, { preserveClientLocalEntries: true });
+ syncInteractionOverlays();
shellRunElapsedTicker.sync();
requestRender();
const messageIds = state.entries.flatMap((entry) =>
@@ -1393,6 +1401,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
replaceTranscript(authoritativeAttachedTurn.messages, {
preserveClientLocalEntries: true,
});
+ syncInteractionOverlays();
shellRunHydration.reset();
if (input.listShellRunUpdates) {
await shellRunHydration.hydrate(authoritativeAttachedTurn.sessionId);
@@ -1415,7 +1424,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
// 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;
@@ -1440,7 +1451,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
permissionAlerted = false;
}
shellRunElapsedTicker.sync();
- syncUserQuestionOverlay();
+ syncInteractionOverlays();
requestRender();
},
// A turn failing is worth pulling the user back, regardless of how long it
@@ -1454,7 +1465,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
appendTurnFailureToTranscript(state, error);
attention.attentionNeeded();
shellRunElapsedTicker.sync();
- syncUserQuestionOverlay();
+ syncInteractionOverlays();
requestRender();
},
}).then(
@@ -1647,6 +1658,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
}: MakaSessionSwitchResult): Promise<void> => {
adoptSessionMetadata(summary, false);
replaceTranscript(messages);
+ syncInteractionOverlays();
if (connectionIdentityNotice) {
state.entries.push({ kind: 'notice', level: 'error', text: connectionIdentityNotice });
}
@@ -2035,13 +2047,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
completePendingInteraction(state, requestId);
}
userQuestionProgress = undefined;
- syncUserQuestionOverlay();
+ syncInteractionOverlays();
requestRender();
})
.catch((error) => {
userQuestionInFlight = false;
reportError(error);
- syncUserQuestionOverlay();
+ syncInteractionOverlays();
});
};
@@ -2092,6 +2104,75 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
}
};
+ 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,
@@ -2462,7 +2543,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
for await (const event of input.driver.resumeLatest()) {
applyMakaSessionEventToTranscript(state, event);
shellRunElapsedTicker.sync();
- syncUserQuestionOverlay();
+ syncInteractionOverlays();
requestRender();
}
} catch (error) {
@@ -2688,6 +2769,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
// 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();
@@ -3741,7 +3823,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
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 62f8536e42..bd46cc19f4 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));
@@ -883,7 +887,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 36ba9707c8..e7b7cd398a 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<void> {
+ 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<void> {
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<MakaRetractedMessages>;
respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise<void>;
respondToUserQuestion?(response: UserQuestionResponse): Promise<void>;
+ respondToUserForm?(response: InteractionFormResponse): Promise<void>;
setModel(model: string, connectionSlug?: string, connectionId?: string): Promise<void>;
setThinkingLevel(level: ThinkingLevel | undefined): Promise<void>;
setPermissionMode(mode: PermissionMode): Promise<void>;
diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts
index 28bd48ba35..60e9eb5fc6 100644
--- a/packages/cli/src/tui-copy-catalog.ts
+++ b/packages/cli/src/tui-copy-catalog.ts
@@ -223,6 +223,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 5ce1b558e5..7de3de0ea5 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -137,6 +137,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",
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 02/03: fix(tui): retain reconnecting form drafts
Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a commit to branch feat/4364-tui-form-interactions
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 69a2606f583516483a6910118e20923b0f68f965
Author: me2seeks <me...@gmail.com>
AuthorDate: Tue Sep 1 16:04:58 2026 +0800
fix(tui): retain reconnecting form drafts
---
.../src/__tests__/pi-tui-form-interaction.test.ts | 32 +++++++
packages/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 d654c67e78..ad93ff79be 100644
--- a/packages/cli/src/__tests__/pi-tui-runner.test.ts
+++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts
@@ -2040,6 +2040,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();
@@ -8595,7 +8631,7 @@ class UserQuestionPromptDriver 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,
@@ -8609,7 +8645,7 @@ class FormPromptDriver extends FakeSessionDriver {
return prepareTestPrompt(this, prompt);
}
async *promptEvents(_prompt: string): AsyncIterable<SessionEvent> {
- yield {
+ const formRequest = {
type: 'form_request',
id: 'event-form',
turnId: 'turn-1',
@@ -8622,19 +8658,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<void>((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<void> {
this.responses.push(response);
- this.release?.();
+ this.wake?.('release');
}
async stop(): Promise<void> {
this.stopCalls += 1;
- this.release?.();
+ this.wake?.('release');
}
subscribeTranscriptReplacements(
listener: (
@@ -8652,6 +8693,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 a4a88e4753..2ce8e7e3dd 100644
--- a/packages/cli/src/pi-tui-runner.ts
+++ b/packages/cli/src/pi-tui-runner.ts
@@ -124,7 +124,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';
@@ -498,6 +498,16 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
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
@@ -2104,11 +2114,27 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
}
};
- 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 => {
@@ -2119,6 +2145,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
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)
@@ -2126,10 +2153,24 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
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();
})
@@ -2139,6 +2180,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
}
if (
activeFormRequest(state)?.requestId === response.requestId &&
+ formOverlaySessionId === responseSessionId &&
formOverlayRequestId === response.requestId
) {
formOverlayComponent?.setSubmissionFailed();
@@ -2157,13 +2199,44 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
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 60e9eb5fc6..6d71ff46c6 100644
--- a/packages/cli/src/tui-copy-catalog.ts
+++ b/packages/cli/src/tui-copy-catalog.ts
@@ -232,6 +232,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',
@@ -248,6 +258,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 返回检查',
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org