322 lines
8.3 KiB
TypeScript
322 lines
8.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import { setupEditorSubmitHandler } from "./input-controller.js";
|
|
|
|
type HostOptions = {
|
|
knownSlashCommands?: string[];
|
|
};
|
|
|
|
function getSlashCommandName(text: string): string {
|
|
const trimmed = text.trim();
|
|
const spaceIndex = trimmed.indexOf(" ");
|
|
return spaceIndex === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIndex);
|
|
}
|
|
|
|
function createHost(options: HostOptions = {}) {
|
|
const prompted: string[] = [];
|
|
const promptOptions: unknown[] = [];
|
|
const errors: string[] = [];
|
|
const warnings: string[] = [];
|
|
const history: string[] = [];
|
|
const knownSlashCommands = new Set(options.knownSlashCommands ?? []);
|
|
let editorText = "";
|
|
let settingsOpened = 0;
|
|
let aborts = 0;
|
|
let pendingDisplayUpdates = 0;
|
|
let renderRequests = 0;
|
|
|
|
const editor = {
|
|
setText(text: string) {
|
|
editorText = text;
|
|
},
|
|
getText() {
|
|
return editorText;
|
|
},
|
|
addToHistory(text: string) {
|
|
history.push(text);
|
|
},
|
|
};
|
|
|
|
const host = {
|
|
defaultEditor: editor as typeof editor & {
|
|
onSubmit?: (text: string) => Promise<void>;
|
|
},
|
|
editor,
|
|
session: {
|
|
isBashRunning: false,
|
|
isCompacting: false,
|
|
isStreaming: false,
|
|
prompt: async (text: string, options?: unknown) => {
|
|
prompted.push(text);
|
|
promptOptions.push(options);
|
|
},
|
|
abort: async () => {
|
|
aborts += 1;
|
|
},
|
|
},
|
|
ui: {
|
|
requestRender() {
|
|
renderRequests += 1;
|
|
},
|
|
},
|
|
getSlashCommandContext: () => ({
|
|
showSettingsSelector: () => {
|
|
settingsOpened += 1;
|
|
},
|
|
}),
|
|
handleBashCommand: async () => {},
|
|
showWarning(message: string) {
|
|
warnings.push(message);
|
|
},
|
|
showError(message: string) {
|
|
errors.push(message);
|
|
},
|
|
updateEditorBorderColor() {},
|
|
isExtensionCommand() {
|
|
return false;
|
|
},
|
|
isKnownSlashCommand(text: string) {
|
|
return knownSlashCommands.has(getSlashCommandName(text));
|
|
},
|
|
queueCompactionMessage() {},
|
|
updatePendingMessagesDisplay() {
|
|
pendingDisplayUpdates += 1;
|
|
},
|
|
flushPendingBashComponents() {},
|
|
contextualTips: {
|
|
evaluate: () => undefined,
|
|
recordBashIncluded() {},
|
|
},
|
|
getContextPercent: () => undefined,
|
|
};
|
|
|
|
setupEditorSubmitHandler(host as any);
|
|
|
|
return {
|
|
host: host as typeof host & {
|
|
defaultEditor: typeof editor & {
|
|
onSubmit: (text: string) => Promise<void>;
|
|
};
|
|
},
|
|
prompted,
|
|
promptOptions,
|
|
errors,
|
|
warnings,
|
|
history,
|
|
getEditorText: () => editorText,
|
|
getSettingsOpened: () => settingsOpened,
|
|
getAborts: () => aborts,
|
|
getPendingDisplayUpdates: () => pendingDisplayUpdates,
|
|
getRenderRequests: () => renderRequests,
|
|
};
|
|
}
|
|
|
|
test("input-controller: built-in slash commands stay in TUI dispatch", async () => {
|
|
const { host, prompted, errors, getSettingsOpened, getEditorText } =
|
|
createHost();
|
|
|
|
await host.defaultEditor.onSubmit("/settings");
|
|
|
|
assert.equal(
|
|
getSettingsOpened(),
|
|
1,
|
|
"built-in /settings should open the settings selector",
|
|
);
|
|
assert.deepEqual(
|
|
prompted,
|
|
[],
|
|
"built-in slash commands should not reach session.prompt",
|
|
);
|
|
assert.deepEqual(
|
|
errors,
|
|
[],
|
|
"built-in slash commands should not show errors",
|
|
);
|
|
assert.equal(
|
|
getEditorText(),
|
|
"",
|
|
"built-in slash commands should clear the editor after handling",
|
|
);
|
|
});
|
|
|
|
test("input-controller: extension slash commands fall through to session.prompt", async () => {
|
|
const { host, prompted, errors, history } = createHost({
|
|
knownSlashCommands: ["sf"],
|
|
});
|
|
|
|
await host.defaultEditor.onSubmit("/sf help");
|
|
|
|
assert.deepEqual(
|
|
prompted,
|
|
["/sf help"],
|
|
"known extension slash commands should reach session.prompt",
|
|
);
|
|
assert.deepEqual(
|
|
errors,
|
|
[],
|
|
"known extension slash commands should not show unknown-command errors",
|
|
);
|
|
assert.deepEqual(
|
|
history,
|
|
["/sf help"],
|
|
"known extension slash commands should still be added to history",
|
|
);
|
|
});
|
|
|
|
test("input-controller: prompt template slash commands fall through to session.prompt", async () => {
|
|
const { host, prompted, errors } = createHost({
|
|
knownSlashCommands: ["daily"],
|
|
});
|
|
|
|
await host.defaultEditor.onSubmit("/daily focus area");
|
|
|
|
assert.deepEqual(prompted, ["/daily focus area"]);
|
|
assert.deepEqual(errors, []);
|
|
});
|
|
|
|
test("input-controller: skill slash commands fall through to session.prompt", async () => {
|
|
const { host, prompted, errors } = createHost({
|
|
knownSlashCommands: ["skill:create-skill"],
|
|
});
|
|
|
|
await host.defaultEditor.onSubmit("/skill:create-skill routing bug");
|
|
|
|
assert.deepEqual(prompted, ["/skill:create-skill routing bug"]);
|
|
assert.deepEqual(errors, []);
|
|
});
|
|
|
|
test("input-controller: disabled skill slash commands stay unknown", async () => {
|
|
const { host, prompted, errors } = createHost();
|
|
|
|
await host.defaultEditor.onSubmit("/skill:create-skill routing bug");
|
|
|
|
assert.deepEqual(prompted, []);
|
|
assert.deepEqual(errors, [
|
|
"Unknown command: /skill:create-skill. Use slash autocomplete to see available commands.",
|
|
]);
|
|
});
|
|
|
|
test("input-controller: /export prefix does not swallow unrelated slash commands", async () => {
|
|
const { host, prompted, errors } = createHost();
|
|
|
|
await host.defaultEditor.onSubmit("/exportfoo");
|
|
|
|
assert.deepEqual(prompted, []);
|
|
assert.deepEqual(errors, [
|
|
"Unknown command: /exportfoo. Use slash autocomplete to see available commands.",
|
|
]);
|
|
});
|
|
|
|
test("input-controller: truly unknown slash commands stop before session.prompt", async () => {
|
|
const { host, prompted, errors, getEditorText } = createHost();
|
|
|
|
await host.defaultEditor.onSubmit("/definitely-not-a-command");
|
|
|
|
assert.deepEqual(
|
|
prompted,
|
|
[],
|
|
"unknown slash commands should not reach session.prompt",
|
|
);
|
|
assert.deepEqual(errors, [
|
|
"Unknown command: /definitely-not-a-command. Use slash autocomplete to see available commands.",
|
|
]);
|
|
assert.equal(
|
|
getEditorText(),
|
|
"",
|
|
"unknown slash commands should clear the editor after showing the error",
|
|
);
|
|
});
|
|
|
|
test("input-controller: absolute file paths are not treated as slash commands (#3478)", async () => {
|
|
const { host, prompted, errors } = createHost();
|
|
|
|
await host.defaultEditor.onSubmit("/Users/name/Desktop/screenshot.png");
|
|
|
|
assert.deepEqual(
|
|
errors,
|
|
[],
|
|
"file paths should not trigger unknown command error",
|
|
);
|
|
assert.deepEqual(
|
|
prompted,
|
|
["/Users/name/Desktop/screenshot.png"],
|
|
"file paths should be sent as plain input",
|
|
);
|
|
});
|
|
|
|
test("input-controller: Linux absolute paths are not treated as slash commands (#3478)", async () => {
|
|
const { host, prompted, errors } = createHost();
|
|
|
|
await host.defaultEditor.onSubmit("/home/user/documents/file.txt");
|
|
|
|
assert.deepEqual(
|
|
errors,
|
|
[],
|
|
"Linux paths should not trigger unknown command error",
|
|
);
|
|
assert.deepEqual(
|
|
prompted,
|
|
["/home/user/documents/file.txt"],
|
|
"Linux paths should be sent as plain input",
|
|
);
|
|
});
|
|
|
|
test("input-controller: /tmp paths are not treated as slash commands (#3478)", async () => {
|
|
const { host, prompted, errors } = createHost();
|
|
|
|
await host.defaultEditor.onSubmit("/tmp/some-file.log");
|
|
|
|
assert.deepEqual(errors, []);
|
|
assert.deepEqual(prompted, ["/tmp/some-file.log"]);
|
|
});
|
|
|
|
test("input-controller: dot aborts streaming instead of steering", async () => {
|
|
const {
|
|
host,
|
|
prompted,
|
|
history,
|
|
getAborts,
|
|
getEditorText,
|
|
getPendingDisplayUpdates,
|
|
getRenderRequests,
|
|
} = createHost();
|
|
host.session.isStreaming = true;
|
|
|
|
await host.defaultEditor.onSubmit(".");
|
|
|
|
assert.equal(getAborts(), 1, "dot should abort the active stream");
|
|
assert.deepEqual(prompted, [], "dot should not be sent as a steering prompt");
|
|
assert.deepEqual(history, ["."], "dot abort should remain in input history");
|
|
assert.equal(getEditorText(), "", "dot abort should clear the editor");
|
|
assert.equal(getPendingDisplayUpdates(), 1);
|
|
assert.equal(getRenderRequests(), 1);
|
|
});
|
|
|
|
test("input-controller: normal input while streaming is buffered as steering", async () => {
|
|
const {
|
|
host,
|
|
prompted,
|
|
promptOptions,
|
|
history,
|
|
getAborts,
|
|
getEditorText,
|
|
getPendingDisplayUpdates,
|
|
getRenderRequests,
|
|
} = createHost();
|
|
host.session.isStreaming = true;
|
|
|
|
await host.defaultEditor.onSubmit("use the simpler parser");
|
|
|
|
assert.equal(getAborts(), 0, "normal streaming input must not abort");
|
|
assert.deepEqual(prompted, ["use the simpler parser"]);
|
|
assert.deepEqual(promptOptions, [{ streamingBehavior: "steer" }]);
|
|
assert.deepEqual(history, ["use the simpler parser"]);
|
|
assert.equal(
|
|
getEditorText(),
|
|
"",
|
|
"streaming steering should clear the editor",
|
|
);
|
|
assert.equal(getPendingDisplayUpdates(), 1);
|
|
assert.equal(getRenderRequests(), 1);
|
|
});
|