fix(tui): keep whitespace-prefixed bang input in chat (#119245)

* chore: start T06 local shell proof

* fix(tui): preserve whitespace before bang submits

* fix(tui): defer paste expansion until submit

* fix(tui): preserve bang spacing across blocked retry

* fix(tui): keep whitespace bang chat out of history

* fix(tui): preserve bang routing across trim
This commit is contained in:
Vincent Koc
2026-08-05 00:19:53 +08:00
committed by GitHub
parent 50a30b7373
commit c1a2f4dde7
6 changed files with 293 additions and 16 deletions
+44
View File
@@ -0,0 +1,44 @@
title: TUI local shell routing PTY contracts
scenario:
id: tui-local-shell-pty
surface: tui
category: tui.local-shell-execution
coverage:
primary:
- tui.bang-command-routing
- tui.approval-prompt
- tui.command-output-display
- tui.execution-environment-marker
risk: high
objective: Prove local shell routing, confirmation, output, and environment behavior through the built local TUI and a real PTY.
successCriteria:
- Whitespace-prefixed bang input remains chat input after local shell execution has already been approved.
- Column-one bang input requires explicit session approval before execution.
- Approved commands visibly render stdout, stderr, and their terminal exit status.
- Approved commands receive the TUI local shell environment marker.
codeRefs:
- src/tui/components/custom-editor.ts
- src/tui/tui-submit.ts
- src/tui/tui-local-shell.ts
- src/tui/tui-pty-local.e2e.test.ts
execution:
kind: script
path: test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts
summary: Runs exact built local-TUI PTY assertions and authenticates their fresh Vitest results as QA evidence.
timeoutMs: 360000
args: [--artifact-base, "${outputDir}", --scenario-id, "${scenarioId}"]
config:
requireBuiltCli: true
tuiPtyCases:
- coverageId: tui.bang-command-routing
testFile: src/tui/tui-pty-local.e2e.test.ts
testNamePattern: ^TUI PTY real backends keeps whitespace-prefixed bang input in chat after local shell approval$
- coverageId: tui.approval-prompt
testFile: src/tui/tui-pty-local.e2e.test.ts
testNamePattern: ^TUI PTY real backends confirms and renders local shell output and environment through a real local PTY$
- coverageId: tui.command-output-display
testFile: src/tui/tui-pty-local.e2e.test.ts
testNamePattern: ^TUI PTY real backends confirms and renders local shell output and environment through a real local PTY$
- coverageId: tui.execution-environment-marker
testFile: src/tui/tui-pty-local.e2e.test.ts
testNamePattern: ^TUI PTY real backends confirms and renders local shell output and environment through a real local PTY$
+53
View File
@@ -212,4 +212,57 @@ describe("CustomEditor", () => {
expect(onSubmit).toHaveBeenCalledWith("/help");
});
it.each([" !cmd", " !cmd\n", "!cmd\n", "\n!cmd\n"])(
"preserves %j when trimming would create executable bang input",
(input) => {
const tui = { requestRender: vi.fn() } as unknown as TUI;
const editor = new CustomEditor(tui, editorTheme);
const onSubmit = vi.fn();
editor.onSubmit = onSubmit;
editor.setText(input);
editor.handleInput("\r");
expect(onSubmit).toHaveBeenCalledExactlyOnceWith(input);
expect(editor.getText()).toBe("");
},
);
it("leaves harmless bang-prefixed multiline chat on pi-tui's normal submit path", () => {
const tui = { requestRender: vi.fn() } as unknown as TUI;
const editor = new CustomEditor(tui, editorTheme);
const onSubmit = vi.fn();
editor.onSubmit = onSubmit;
editor.setText(" \n!cmd\nnotes");
editor.handleInput("\r");
expect(onSubmit).toHaveBeenCalledExactlyOnceWith("!cmd\nnotes");
expect(editor.getText()).toBe("");
});
it("does not expand stored paste text for ordinary input", () => {
const tui = { requestRender: vi.fn() } as unknown as TUI;
const editor = new CustomEditor(tui, editorTheme);
editor.setText("draft");
const getExpandedText = vi.spyOn(editor, "getExpandedText");
editor.handleInput("x");
expect(getExpandedText).not.toHaveBeenCalled();
expect(editor.getText()).toBe("draftx");
});
it("keeps pi-tui trimming for ordinary submissions", () => {
const tui = { requestRender: vi.fn() } as unknown as TUI;
const editor = new CustomEditor(tui, editorTheme);
const onSubmit = vi.fn();
editor.onSubmit = onSubmit;
editor.setText(" ordinary message ");
editor.handleInput("\r");
expect(onSubmit).toHaveBeenCalledExactlyOnceWith("ordinary message");
});
});
+15 -1
View File
@@ -1,5 +1,6 @@
// Custom editor component handles multiline TUI input and key bindings.
import { Editor, getKeybindings, isKeyRelease, Key, matchesKey } from "@earendil-works/pi-tui";
import { trimWouldCreateExecutableBangLine } from "../tui-submit.js";
// Kitty keyboard protocol uses CSI-u sequences for AltGr on international layouts.
const KITTY_CSI_U_SUFFIX_REGEX = /^(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/u;
@@ -57,7 +58,7 @@ export class CustomEditor extends Editor {
onAltUp?: () => void;
shouldSubmitAutocomplete?: (text: string) => boolean;
/** Dispatches TUI shortcuts before falling back to normal editor input handling. */
/** Preserves text when pi-tui trimming would create an executable bang line. */
override handleInput(data: string): void {
if (isKeyRelease(data)) {
return;
@@ -130,6 +131,19 @@ export class CustomEditor extends Editor {
this.setText(this.getText());
}
if (keybindings.matches(data, "tui.input.submit") && this.onSubmit) {
const expandedText = this.getExpandedText();
if (trimWouldCreateExecutableBangLine(expandedText)) {
const onSubmit = this.onSubmit;
this.onSubmit = () => onSubmit(expandedText);
try {
super.handleInput(data);
} finally {
this.onSubmit = onSubmit;
}
return;
}
}
super.handleInput(data);
}
}
+71
View File
@@ -1126,6 +1126,77 @@ describe("TUI PTY real backends", () => {
LOCAL_TEST_TIMEOUT_MS,
);
it(
"keeps whitespace-prefixed bang input in chat after local shell approval",
async ({ onTestFinished }) => {
const fixture = await startLocalModeTui(onTestFinished, {
replyText: "T06_CHAT_RESPONSE",
});
try {
await fixture.run.waitForOutput("local ready", LOCAL_STARTUP_TIMEOUT_MS);
await fixture.run.write("!node -e \"console.log('T06_APPROVAL_PRIMER')\"\r");
await fixture.run.waitForOutput("Allow local shell commands for this session?");
await fixture.run.write("\u001b[B\r", { delay: false });
await fixture.run.waitForOutput("local shell: enabled for this session");
await fixture.run.waitForOutput("[local] T06_APPROVAL_PRIMER");
await fixture.run.waitForOutput("[local] exit 0");
const chatOffset = fixture.run.visibleOutput().length;
const command = " !node -e \"console.log('T06_UNEXPECTED_EXECUTION')\"";
await fixture.run.write(`${command}\r`);
await waitFor({
timeoutMs: LOCAL_OUTPUT_TIMEOUT_MS,
read: () => (fixture.mockModel.requests().length === 1 ? true : null),
onTimeout: () =>
new Error(`whitespace-prefixed bang did not reach chat\n${fixture.run.output()}`),
});
expect(JSON.stringify(fixture.mockModel.requests()[0]?.body)).toContain(
"T06_UNEXPECTED_EXECUTION",
);
await waitForOutputAfter(fixture.run, "T06_CHAT_RESPONSE", chatOffset);
expect(fixture.run.visibleOutput().slice(chatOffset)).not.toContain(
"[local] T06_UNEXPECTED_EXECUTION",
);
await fixture.run.write("/exit\r", { delay: false });
expect((await fixture.run.waitForExit()).exitCode).toBe(0);
} finally {
await fixture.cleanup();
}
},
LOCAL_TEST_TIMEOUT_MS,
);
it(
"confirms and renders local shell output and environment through a real local PTY",
async ({ onTestFinished }) => {
const fixture = await startLocalModeTui(onTestFinished);
try {
await fixture.run.waitForOutput("local ready", LOCAL_STARTUP_TIMEOUT_MS);
await fixture.run.write(
"!node -e \"console.log('T06_STDOUT'); console.error('T06_STDERR'); console.log('T06_ENV='+process.env.OPENCLAW_SHELL); process.exitCode=7\"\r",
);
await fixture.run.waitForOutput("Allow local shell commands for this session?");
await fixture.run.waitForOutput("Select Yes/No (arrows + Enter), Esc to cancel.");
expect(fixture.run.visibleOutput()).toContain("No");
expect(fixture.run.visibleOutput()).toContain("Yes");
await fixture.run.write("\u001b[B\r", { delay: false });
await fixture.run.waitForOutput("local shell: enabled for this session");
await fixture.run.waitForOutput("[local] T06_STDOUT");
await fixture.run.waitForOutput("[local] T06_STDERR");
await fixture.run.waitForOutput("[local] T06_ENV=tui-local");
await fixture.run.waitForOutput("[local] exit 7");
await fixture.run.write("/exit\r", { delay: false });
expect((await fixture.run.waitForExit()).exitCode).toBe(0);
} finally {
await fixture.cleanup();
}
},
LOCAL_TEST_TIMEOUT_MS,
);
function registerValidationLoopTest(mode: "gateway" | "local") {
it(
`renders safe validation-loop abort diagnostics through the real ${mode} backend`,
+15 -4
View File
@@ -8,6 +8,14 @@ import type {
export type TuiSubmitAction = "local shell" | "command" | "message";
function isExecutableBangLine(text: string): boolean {
return !text.includes("\n") && text.startsWith("!") && text !== "!";
}
export function trimWouldCreateExecutableBangLine(text: string): boolean {
return !isExecutableBangLine(text) && isExecutableBangLine(text.trim());
}
function runSubmitAction(
action: TuiSubmitAction,
run: () => Promise<void> | void,
@@ -53,6 +61,7 @@ export function createEditorSubmitHandler(params: {
const raw = text;
const value = raw.trim();
const multiline = raw.includes("\n");
const trimCreatesExecutableBangLine = trimWouldCreateExecutableBangLine(raw);
// Keep previous behavior: ignore empty/whitespace-only submissions.
if (!value) {
@@ -63,7 +72,7 @@ export function createEditorSubmitHandler(params: {
// Bash mode: only if the very first character is '!' and it's not just '!'.
// IMPORTANT: use the raw (untrimmed) text so leading spaces do NOT trigger.
// Per requirement: a lone '!' should be treated as a normal message.
if (!multiline && raw.startsWith("!") && raw !== "!") {
if (isExecutableBangLine(raw)) {
clearSubmittedEditor();
params.editor.addToHistory(raw);
runSubmitAction("local shell", () => params.handleBangLine(raw), params.onSubmitError);
@@ -82,14 +91,16 @@ export function createEditorSubmitHandler(params: {
? params.admitMessage?.(value, snapshot)
: params.admitMessage?.(value)) ?? { status: "allowed" };
if (admission.status === "blocked") {
restoreBlockedEditor(value);
restoreBlockedEditor(trimCreatesExecutableBangLine ? raw : value);
params.onBlockedMessageSubmit?.(value, admission);
return;
}
clearSubmittedEditor();
// Enable built-in editor prompt history navigation (up/down).
params.editor.addToHistory(value);
// Omit chat text whose trimmed history recall would become executable shell input.
if (!trimCreatesExecutableBangLine) {
params.editor.addToHistory(value);
}
runSubmitAction("message", () => params.sendMessage(value), params.onSubmitError);
};
}
+95 -11
View File
@@ -10,16 +10,39 @@ import {
shouldEnableWindowsGitBashPasteFallback,
} from "./tui-submit.js";
describe("createEditorSubmitHandler", () => {
it("routes lines starting with ! to handleBangLine", () => {
const { handleCommand, sendMessage, handleBangLine, onSubmit } = createSubmitHarness();
function createRealEditorSubmitHarness(
admitMessage?: NonNullable<Parameters<typeof createEditorSubmitHandler>[0]["admitMessage"]>,
) {
const tui = { requestRender: vi.fn() } as unknown as TUI;
const editor = new CustomEditor(tui, editorTheme);
const sendMessage = vi.fn();
const handleBangLine = vi.fn();
editor.onSubmit = createEditorSubmitHandler({
editor,
handleCommand: vi.fn(),
sendMessage,
handleBangLine,
onSubmitError: vi.fn(),
...(admitMessage ? { admitMessage } : {}),
});
return { editor, sendMessage, handleBangLine };
}
onSubmit("!ls");
describe("createEditorSubmitHandler", () => {
it("routes genuine bang input to local shell and history", () => {
const { editor, sendMessage, handleBangLine } = createRealEditorSubmitHarness();
editor.setText("!cmd");
editor.handleInput("\r");
expect(handleBangLine).toHaveBeenCalledTimes(1);
expect(handleBangLine).toHaveBeenCalledWith("!ls");
expect(handleBangLine).toHaveBeenCalledWith("!cmd");
expect(sendMessage).not.toHaveBeenCalled();
expect(handleCommand).not.toHaveBeenCalled();
expect(editor.getText()).toBe("");
editor.handleInput("\u001b[A");
expect(editor.getText()).toBe("!cmd");
});
it("treats a lone ! as a normal message", () => {
@@ -32,14 +55,75 @@ describe("createEditorSubmitHandler", () => {
expect(sendMessage).toHaveBeenCalledWith("!");
});
it("does not treat leading whitespace before ! as a bang command", () => {
const { editor, sendMessage, handleBangLine, onSubmit } = createSubmitHarness();
it.each([
{ name: "a whitespace-prefixed lone bang", input: " !", expected: "!" },
{
name: "bang-prefixed true multiline chat",
input: " \n!cmd\nnotes",
expected: "!cmd\nnotes",
},
])("stores, recalls, and safely resubmits $name", ({ input, expected }) => {
const { editor, sendMessage, handleBangLine } = createRealEditorSubmitHarness();
editor.setText(input);
onSubmit(" !ls");
editor.handleInput("\r");
expect(handleBangLine).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith("!ls");
expect(editor.addToHistory).toHaveBeenCalledWith("!ls");
expect(sendMessage).toHaveBeenCalledExactlyOnceWith(expected);
expect(editor.getText()).toBe("");
editor.handleInput("\u001b[A");
expect(editor.getText()).toBe(expected);
editor.handleInput("\r");
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(sendMessage).toHaveBeenNthCalledWith(2, expected);
expect(handleBangLine).not.toHaveBeenCalled();
});
it.each([" !cmd", " !cmd\n", "!cmd\n", "\n!cmd\n"])(
"keeps %j in chat and omits it from history",
(input) => {
const { editor, sendMessage, handleBangLine } = createRealEditorSubmitHarness();
editor.setText(input);
editor.handleInput("\r");
expect(sendMessage).toHaveBeenCalledExactlyOnceWith("!cmd");
expect(handleBangLine).not.toHaveBeenCalled();
expect(editor.getText()).toBe("");
editor.handleInput("\u001b[A");
expect(editor.getText()).toBe("");
editor.handleInput("\r");
expect(sendMessage).toHaveBeenCalledExactlyOnceWith("!cmd");
expect(handleBangLine).not.toHaveBeenCalled();
},
);
it("preserves whitespace bang routing across a blocked retry", () => {
const admitMessage = vi
.fn()
.mockReturnValueOnce({ status: "blocked", reason: "pending" })
.mockReturnValueOnce({ status: "allowed" });
const { editor, sendMessage, handleBangLine } = createRealEditorSubmitHarness(admitMessage);
editor.setText(" !cmd");
editor.handleInput("\r");
expect(editor.getText()).toBe(" !cmd");
expect(sendMessage).not.toHaveBeenCalled();
expect(handleBangLine).not.toHaveBeenCalled();
editor.handleInput("\r");
expect(admitMessage).toHaveBeenCalledTimes(2);
expect(sendMessage).toHaveBeenCalledExactlyOnceWith("!cmd");
expect(handleBangLine).not.toHaveBeenCalled();
expect(editor.getText()).toBe("");
});
it("trims normal messages before sending and adding to history", () => {