refactor(tui): dedupe handlers and formatter test setup

This commit is contained in:
Peter Steinberger
2026-02-22 14:05:51 +00:00
parent 66f814a0af
commit 38752338dc
20 changed files with 430 additions and 477 deletions

35
src/tui/commands.test.ts Normal file
View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { getSlashCommands, helpText, parseCommand } from "./commands.js";
describe("parseCommand", () => {
it("normalizes aliases and keeps command args", () => {
expect(parseCommand("/elev full")).toEqual({ name: "elevated", args: "full" });
});
it("returns empty name for empty input", () => {
expect(parseCommand(" ")).toEqual({ name: "", args: "" });
});
});
describe("getSlashCommands", () => {
it("provides level completions for built-in toggles", () => {
const commands = getSlashCommands();
const verbose = commands.find((command) => command.name === "verbose");
const activation = commands.find((command) => command.name === "activation");
expect(verbose?.getArgumentCompletions?.("o")).toEqual([
{ value: "on", label: "on" },
{ value: "off", label: "off" },
]);
expect(activation?.getArgumentCompletions?.("a")).toEqual([
{ value: "always", label: "always" },
]);
});
});
describe("helpText", () => {
it("includes slash command help for aliases", () => {
const output = helpText();
expect(output).toContain("/elevated <on|off|ask|full>");
expect(output).toContain("/elev <on|off|ask|full>");
});
});

View File

@@ -24,6 +24,18 @@ const COMMAND_ALIASES: Record<string, string> = {
elev: "elevated", elev: "elevated",
}; };
function createLevelCompletion(
levels: string[],
): NonNullable<SlashCommand["getArgumentCompletions"]> {
return (prefix) =>
levels
.filter((value) => value.startsWith(prefix.toLowerCase()))
.map((value) => ({
value,
label: value,
}));
}
export function parseCommand(input: string): ParsedCommand { export function parseCommand(input: string): ParsedCommand {
const trimmed = input.replace(/^\//, "").trim(); const trimmed = input.replace(/^\//, "").trim();
if (!trimmed) { if (!trimmed) {
@@ -39,6 +51,11 @@ export function parseCommand(input: string): ParsedCommand {
export function getSlashCommands(options: SlashCommandOptions = {}): SlashCommand[] { export function getSlashCommands(options: SlashCommandOptions = {}): SlashCommand[] {
const thinkLevels = listThinkingLevelLabels(options.provider, options.model); const thinkLevels = listThinkingLevelLabels(options.provider, options.model);
const verboseCompletions = createLevelCompletion(VERBOSE_LEVELS);
const reasoningCompletions = createLevelCompletion(REASONING_LEVELS);
const usageCompletions = createLevelCompletion(USAGE_FOOTER_LEVELS);
const elevatedCompletions = createLevelCompletion(ELEVATED_LEVELS);
const activationCompletions = createLevelCompletion(ACTIVATION_LEVELS);
const commands: SlashCommand[] = [ const commands: SlashCommand[] = [
{ name: "help", description: "Show slash command help" }, { name: "help", description: "Show slash command help" },
{ name: "status", description: "Show gateway status summary" }, { name: "status", description: "Show gateway status summary" },
@@ -62,56 +79,32 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman
{ {
name: "verbose", name: "verbose",
description: "Set verbose on/off", description: "Set verbose on/off",
getArgumentCompletions: (prefix) => getArgumentCompletions: verboseCompletions,
VERBOSE_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({
value,
label: value,
})),
}, },
{ {
name: "reasoning", name: "reasoning",
description: "Set reasoning on/off", description: "Set reasoning on/off",
getArgumentCompletions: (prefix) => getArgumentCompletions: reasoningCompletions,
REASONING_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({
value,
label: value,
})),
}, },
{ {
name: "usage", name: "usage",
description: "Toggle per-response usage line", description: "Toggle per-response usage line",
getArgumentCompletions: (prefix) => getArgumentCompletions: usageCompletions,
USAGE_FOOTER_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({
value,
label: value,
})),
}, },
{ {
name: "elevated", name: "elevated",
description: "Set elevated on/off/ask/full", description: "Set elevated on/off/ask/full",
getArgumentCompletions: (prefix) => getArgumentCompletions: elevatedCompletions,
ELEVATED_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({
value,
label: value,
})),
}, },
{ {
name: "elev", name: "elev",
description: "Alias for /elevated", description: "Alias for /elevated",
getArgumentCompletions: (prefix) => getArgumentCompletions: elevatedCompletions,
ELEVATED_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({
value,
label: value,
})),
}, },
{ {
name: "activation", name: "activation",
description: "Set group activation", description: "Set group activation",
getArgumentCompletions: (prefix) => getArgumentCompletions: activationCompletions,
ACTIVATION_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({
value,
label: value,
})),
}, },
{ name: "abort", description: "Abort active run" }, { name: "abort", description: "Abort active run" },
{ name: "new", description: "Reset the session" }, { name: "new", description: "Reset the session" },

View File

@@ -1,21 +1,12 @@
import { Container, Markdown, Spacer } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js";
import { markdownTheme, theme } from "../theme/theme.js"; import { MarkdownMessageComponent } from "./markdown-message.js";
export class AssistantMessageComponent extends Container {
private body: Markdown;
export class AssistantMessageComponent extends MarkdownMessageComponent {
constructor(text: string) { constructor(text: string) {
super(); super(text, 0, {
this.body = new Markdown(text, 1, 0, markdownTheme, {
// Keep assistant body text in terminal default foreground so contrast // Keep assistant body text in terminal default foreground so contrast
// follows the user's terminal theme (dark or light). // follows the user's terminal theme (dark or light).
color: (line) => theme.assistantText(line), color: (line) => theme.assistantText(line),
}); });
this.addChild(new Spacer(1));
this.addChild(this.body);
}
setText(text: string) {
this.body.setText(text);
} }
} }

View File

@@ -0,0 +1,19 @@
import { Container, Markdown, Spacer } from "@mariozechner/pi-tui";
import { markdownTheme } from "../theme/theme.js";
type MarkdownOptions = ConstructorParameters<typeof Markdown>[4];
export class MarkdownMessageComponent extends Container {
private body: Markdown;
constructor(text: string, y: number, options?: MarkdownOptions) {
super();
this.body = new Markdown(text, 1, y, markdownTheme, options);
this.addChild(new Spacer(1));
this.addChild(this.body);
}
setText(text: string) {
this.body.setText(text);
}
}

View File

@@ -41,6 +41,28 @@ const testItems = [
]; ];
describe("SearchableSelectList", () => { describe("SearchableSelectList", () => {
function typeInput(list: SearchableSelectList, text: string) {
for (const ch of text) {
list.handleInput(ch);
}
}
function expectSelectedValueForQuery(
list: SearchableSelectList,
query: string,
expectedValue: string,
) {
typeInput(list, query);
const selected = list.getSelectedItem();
expect(selected?.value).toBe(expectedValue);
}
function expectNoMatchesForQuery(list: SearchableSelectList, query: string) {
typeInput(list, query);
const output = list.render(80);
expect(output.some((line) => line.includes("No matches"))).toBe(true);
}
function expectDescriptionVisibilityAtWidth(width: number, shouldContainDescription: boolean) { function expectDescriptionVisibilityAtWidth(width: number, shouldContainDescription: boolean) {
const items = [ const items = [
{ value: "one", label: "one", description: "desc" }, { value: "one", label: "one", description: "desc" },
@@ -93,9 +115,7 @@ describe("SearchableSelectList", () => {
const list = new SearchableSelectList(items, 5, ansiHighlightTheme); const list = new SearchableSelectList(items, 5, ansiHighlightTheme);
list.setSelectedIndex(1); // make first row non-selected so description styling is applied list.setSelectedIndex(1); // make first row non-selected so description styling is applied
for (const ch of "provider") { typeInput(list, "provider");
list.handleInput(ch);
}
const width = 80; const width = 80;
const output = list.render(width); const output = list.render(width);
@@ -111,21 +131,14 @@ describe("SearchableSelectList", () => {
]; ];
const list = new SearchableSelectList(items, 5, mockTheme); const list = new SearchableSelectList(items, 5, mockTheme);
for (const ch of "32m") { expectNoMatchesForQuery(list, "32m");
list.handleInput(ch);
}
const output = list.render(80);
expect(output.some((line) => line.includes("No matches"))).toBe(true);
}); });
it("does not corrupt ANSI sequences when highlighting multiple tokens", () => { it("does not corrupt ANSI sequences when highlighting multiple tokens", () => {
const items = [{ value: "gpt-model", label: "gpt-model" }]; const items = [{ value: "gpt-model", label: "gpt-model" }];
const list = new SearchableSelectList(items, 5, ansiHighlightTheme); const list = new SearchableSelectList(items, 5, ansiHighlightTheme);
for (const ch of "gpt m") { typeInput(list, "gpt m");
list.handleInput(ch);
}
const renderedLine = list.render(80).find((line) => stripAnsi(line).includes("gpt-model")); const renderedLine = list.render(80).find((line) => stripAnsi(line).includes("gpt-model"));
expect(renderedLine).toBeDefined(); expect(renderedLine).toBeDefined();
@@ -137,12 +150,7 @@ describe("SearchableSelectList", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme); const list = new SearchableSelectList(testItems, 5, mockTheme);
// Simulate typing "gemini" - unique enough to narrow down // Simulate typing "gemini" - unique enough to narrow down
list.handleInput("g"); typeInput(list, "gemini");
list.handleInput("e");
list.handleInput("m");
list.handleInput("i");
list.handleInput("n");
list.handleInput("i");
const selected = list.getSelectedItem(); const selected = list.getSelectedItem();
expect(selected?.value).toBe("google/gemini-pro"); expect(selected?.value).toBe("google/gemini-pro");
@@ -162,9 +170,7 @@ describe("SearchableSelectList", () => {
const list = new SearchableSelectList(items, 5, mockTheme); const list = new SearchableSelectList(items, 5, mockTheme);
// Type "opus" - should match "opus-direct" first (earliest exact substring) // Type "opus" - should match "opus-direct" first (earliest exact substring)
for (const ch of "opus") { typeInput(list, "opus");
list.handleInput(ch);
}
// First result should be "opus-direct" where "opus" appears at position 0 // First result should be "opus-direct" where "opus" appears at position 0
const selected = list.getSelectedItem(); const selected = list.getSelectedItem();
@@ -179,12 +185,7 @@ describe("SearchableSelectList", () => {
]; ];
const list = new SearchableSelectList(items, 5, mockTheme); const list = new SearchableSelectList(items, 5, mockTheme);
for (const ch of "opus") { expectSelectedValueForQuery(list, "opus", "late-label");
list.handleInput(ch);
}
const selected = list.getSelectedItem();
expect(selected?.value).toBe("late-label");
}); });
it("exact label match beats description match", () => { it("exact label match beats description match", () => {
@@ -198,9 +199,7 @@ describe("SearchableSelectList", () => {
]; ];
const list = new SearchableSelectList(items, 5, mockTheme); const list = new SearchableSelectList(items, 5, mockTheme);
for (const ch of "opus") { typeInput(list, "opus");
list.handleInput(ch);
}
// Label match should win over description match // Label match should win over description match
const selected = list.getSelectedItem(); const selected = list.getSelectedItem();
@@ -214,21 +213,14 @@ describe("SearchableSelectList", () => {
]; ];
const list = new SearchableSelectList(items, 5, mockTheme); const list = new SearchableSelectList(items, 5, mockTheme);
for (const ch of "opus") { expectSelectedValueForQuery(list, "opus", "second");
list.handleInput(ch);
}
const selected = list.getSelectedItem();
expect(selected?.value).toBe("second");
}); });
it("filters items with fuzzy matching", () => { it("filters items with fuzzy matching", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme); const list = new SearchableSelectList(testItems, 5, mockTheme);
// Simulate typing "gpt" which should match openai/gpt-4 models // Simulate typing "gpt" which should match openai/gpt-4 models
list.handleInput("g"); typeInput(list, "gpt");
list.handleInput("p");
list.handleInput("t");
const selected = list.getSelectedItem(); const selected = list.getSelectedItem();
expect(selected?.value).toContain("gpt"); expect(selected?.value).toContain("gpt");
@@ -241,9 +233,7 @@ describe("SearchableSelectList", () => {
]; ];
const list = new SearchableSelectList(items, 5, mockTheme); const list = new SearchableSelectList(items, 5, mockTheme);
for (const ch of "g4") { typeInput(list, "g4");
list.handleInput(ch);
}
const selected = list.getSelectedItem(); const selected = list.getSelectedItem();
expect(selected?.value).toBe("gpt-4"); expect(selected?.value).toBe("gpt-4");
@@ -252,9 +242,7 @@ describe("SearchableSelectList", () => {
it("highlights matches in rendered output", () => { it("highlights matches in rendered output", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme); const list = new SearchableSelectList(testItems, 5, mockTheme);
for (const ch of "gpt") { typeInput(list, "gpt");
list.handleInput(ch);
}
const output = list.render(80).join("\n"); const output = list.render(80).join("\n");
expect(output).toContain("*gpt*"); expect(output).toContain("*gpt*");
@@ -263,13 +251,7 @@ describe("SearchableSelectList", () => {
it("shows no match message when filter yields no results", () => { it("shows no match message when filter yields no results", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme); const list = new SearchableSelectList(testItems, 5, mockTheme);
// Type something that won't match expectNoMatchesForQuery(list, "xyz");
list.handleInput("x");
list.handleInput("y");
list.handleInput("z");
const output = list.render(80);
expect(output.some((line) => line.includes("No matches"))).toBe(true);
}); });
it("navigates with arrow keys", () => { it("navigates with arrow keys", () => {

View File

@@ -1,20 +1,11 @@
import { Container, Markdown, Spacer } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js";
import { markdownTheme, theme } from "../theme/theme.js"; import { MarkdownMessageComponent } from "./markdown-message.js";
export class UserMessageComponent extends Container {
private body: Markdown;
export class UserMessageComponent extends MarkdownMessageComponent {
constructor(text: string) { constructor(text: string) {
super(); super(text, 1, {
this.body = new Markdown(text, 1, 1, markdownTheme, {
bgColor: (line) => theme.userBg(line), bgColor: (line) => theme.userBg(line),
color: (line) => theme.userText(line), color: (line) => theme.userText(line),
}); });
this.addChild(new Spacer(1));
this.addChild(this.body);
}
setText(text: string) {
this.body.setText(text);
} }
} }

View File

@@ -16,6 +16,7 @@ import {
} from "../gateway/protocol/index.js"; } from "../gateway/protocol/index.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.js";
export type GatewayConnectionOptions = { export type GatewayConnectionOptions = {
url?: string; url?: string;
@@ -47,40 +48,44 @@ export type GatewaySessionList = {
modelProvider?: string | null; modelProvider?: string | null;
contextTokens?: number | null; contextTokens?: number | null;
}; };
sessions: Array<{ sessions: Array<
key: string; Pick<
sessionId?: string; SessionInfo,
updatedAt?: number | null; | "thinkingLevel"
thinkingLevel?: string; | "verboseLevel"
verboseLevel?: string; | "reasoningLevel"
reasoningLevel?: string; | "model"
sendPolicy?: string; | "contextTokens"
model?: string; | "inputTokens"
contextTokens?: number | null; | "outputTokens"
inputTokens?: number | null; | "totalTokens"
outputTokens?: number | null; | "modelProvider"
totalTokens?: number | null; | "displayName"
responseUsage?: "on" | "off" | "tokens" | "full"; > & {
modelProvider?: string; key: string;
label?: string; sessionId?: string;
displayName?: string; updatedAt?: number | null;
provider?: string; sendPolicy?: string;
groupChannel?: string; responseUsage?: ResponseUsageMode;
space?: string; label?: string;
subject?: string; provider?: string;
chatType?: string; groupChannel?: string;
lastProvider?: string; space?: string;
lastTo?: string; subject?: string;
lastAccountId?: string; chatType?: string;
derivedTitle?: string; lastProvider?: string;
lastMessagePreview?: string; lastTo?: string;
}>; lastAccountId?: string;
derivedTitle?: string;
lastMessagePreview?: string;
}
>;
}; };
export type GatewayAgentsList = { export type GatewayAgentsList = {
defaultId: string; defaultId: string;
mainKey: string; mainKey: string;
scope: "per-sender" | "global"; scope: SessionScope;
agents: Array<{ agents: Array<{
id: string; id: string;
name?: string; name?: string;

View File

@@ -66,33 +66,11 @@ describe("tui command handlers", () => {
resolveSend = resolve; resolveSend = resolve;
}), }),
); );
const addUser = vi.fn();
const requestRender = vi.fn();
const setActivityStatus = vi.fn(); const setActivityStatus = vi.fn();
const { handleCommand } = createCommandHandlers({ const { handleCommand, requestRender } = createHarness({
client: { sendChat } as never, sendChat,
chatLog: { addUser, addSystem: vi.fn() } as never,
tui: { requestRender } as never,
opts: {},
state: {
currentSessionKey: "agent:main:main",
activeChatRunId: null,
sessionInfo: {},
} as never,
deliverDefault: false,
openOverlay: vi.fn(),
closeOverlay: vi.fn(),
refreshSessionInfo: vi.fn(),
loadHistory: vi.fn(),
setSession: vi.fn(),
refreshAgents: vi.fn(),
abortActive: vi.fn(),
setActivityStatus, setActivityStatus,
formatSessionKey: vi.fn(),
applySessionInfoFromPatch: vi.fn(),
noteLocalRunId: vi.fn(),
requestExit: vi.fn(),
}); });
const pending = handleCommand("/context"); const pending = handleCommand("/context");

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { Component, TUI } from "@mariozechner/pi-tui"; import type { Component, SelectItem, TUI } from "@mariozechner/pi-tui";
import { import {
formatThinkingLevels, formatThinkingLevels,
normalizeUsageDisplay, normalizeUsageDisplay,
@@ -74,6 +74,29 @@ export function createCommandHandlers(context: CommandHandlerContext) {
await setSession(""); await setSession("");
}; };
const closeOverlayAndRender = () => {
closeOverlay();
tui.requestRender();
};
const openSelector = (
selector: {
onSelect?: (item: SelectItem) => void;
onCancel?: () => void;
},
onSelect: (value: string) => Promise<void>,
) => {
selector.onSelect = (item) => {
void (async () => {
await onSelect(item.value);
closeOverlayAndRender();
})();
};
selector.onCancel = closeOverlayAndRender;
openOverlay(selector as Component);
tui.requestRender();
};
const openModelSelector = async () => { const openModelSelector = async () => {
try { try {
const models = await client.listModels(); const models = await client.listModels();
@@ -88,29 +111,19 @@ export function createCommandHandlers(context: CommandHandlerContext) {
description: model.name && model.name !== model.id ? model.name : "", description: model.name && model.name !== model.id ? model.name : "",
})); }));
const selector = createSearchableSelectList(items, 9); const selector = createSearchableSelectList(items, 9);
selector.onSelect = (item) => { openSelector(selector, async (value) => {
void (async () => { try {
try { const result = await client.patchSession({
const result = await client.patchSession({ key: state.currentSessionKey,
key: state.currentSessionKey, model: value,
model: item.value, });
}); chatLog.addSystem(`model set to ${value}`);
chatLog.addSystem(`model set to ${item.value}`); applySessionInfoFromPatch(result);
applySessionInfoFromPatch(result); await refreshSessionInfo();
await refreshSessionInfo(); } catch (err) {
} catch (err) { chatLog.addSystem(`model set failed: ${String(err)}`);
chatLog.addSystem(`model set failed: ${String(err)}`); }
} });
closeOverlay();
tui.requestRender();
})();
};
selector.onCancel = () => {
closeOverlay();
tui.requestRender();
};
openOverlay(selector);
tui.requestRender();
} catch (err) { } catch (err) {
chatLog.addSystem(`model list failed: ${String(err)}`); chatLog.addSystem(`model list failed: ${String(err)}`);
tui.requestRender(); tui.requestRender();
@@ -130,19 +143,9 @@ export function createCommandHandlers(context: CommandHandlerContext) {
description: agent.id === state.agentDefaultId ? "default" : "", description: agent.id === state.agentDefaultId ? "default" : "",
})); }));
const selector = createSearchableSelectList(items, 9); const selector = createSearchableSelectList(items, 9);
selector.onSelect = (item) => { openSelector(selector, async (value) => {
void (async () => { await setAgent(value);
closeOverlay(); });
await setAgent(item.value);
tui.requestRender();
})();
};
selector.onCancel = () => {
closeOverlay();
tui.requestRender();
};
openOverlay(selector);
tui.requestRender();
}; };
const openSessionSelector = async () => { const openSessionSelector = async () => {
@@ -183,19 +186,9 @@ export function createCommandHandlers(context: CommandHandlerContext) {
}; };
}); });
const selector = createFilterableSelectList(items, 9); const selector = createFilterableSelectList(items, 9);
selector.onSelect = (item) => { openSelector(selector, async (value) => {
void (async () => { await setSession(value);
closeOverlay(); });
await setSession(item.value);
tui.requestRender();
})();
};
selector.onCancel = () => {
closeOverlay();
tui.requestRender();
};
openOverlay(selector);
tui.requestRender();
} catch (err) { } catch (err) {
chatLog.addSystem(`sessions list failed: ${String(err)}`); chatLog.addSystem(`sessions list failed: ${String(err)}`);
tui.requestRender(); tui.requestRender();

View File

@@ -22,6 +22,17 @@ type MockChatLog = {
}; };
type MockTui = { requestRender: MockFn }; type MockTui = { requestRender: MockFn };
function createMockChatLog(): MockChatLog & HandlerChatLog {
return {
startTool: vi.fn(),
updateToolResult: vi.fn(),
addSystem: vi.fn(),
updateAssistant: vi.fn(),
finalizeAssistant: vi.fn(),
dropAssistant: vi.fn(),
} as unknown as MockChatLog & HandlerChatLog;
}
describe("tui-event-handlers: handleAgentEvent", () => { describe("tui-event-handlers: handleAgentEvent", () => {
const makeState = (overrides?: Partial<TuiStateAccess>): TuiStateAccess => ({ const makeState = (overrides?: Partial<TuiStateAccess>): TuiStateAccess => ({
agentDefaultId: "main", agentDefaultId: "main",
@@ -47,14 +58,7 @@ describe("tui-event-handlers: handleAgentEvent", () => {
}); });
const makeContext = (state: TuiStateAccess) => { const makeContext = (state: TuiStateAccess) => {
const chatLog = { const chatLog = createMockChatLog();
startTool: vi.fn(),
updateToolResult: vi.fn(),
addSystem: vi.fn(),
updateAssistant: vi.fn(),
finalizeAssistant: vi.fn(),
dropAssistant: vi.fn(),
} as unknown as MockChatLog & HandlerChatLog;
const tui = { requestRender: vi.fn() } as unknown as MockTui & HandlerTui; const tui = { requestRender: vi.fn() } as unknown as MockTui & HandlerTui;
const setActivityStatus = vi.fn(); const setActivityStatus = vi.fn();
const loadHistory = vi.fn(); const loadHistory = vi.fn();
@@ -62,13 +66,9 @@ describe("tui-event-handlers: handleAgentEvent", () => {
const noteLocalRunId = (runId: string) => { const noteLocalRunId = (runId: string) => {
localRunIds.add(runId); localRunIds.add(runId);
}; };
const forgetLocalRunId = (runId: string) => { const forgetLocalRunId = localRunIds.delete.bind(localRunIds);
localRunIds.delete(runId); const isLocalRunId = localRunIds.has.bind(localRunIds);
}; const clearLocalRunIds = localRunIds.clear.bind(localRunIds);
const isLocalRunId = (runId: string) => localRunIds.has(runId);
const clearLocalRunIds = () => {
localRunIds.clear();
};
return { return {
chatLog, chatLog,
@@ -148,14 +148,7 @@ describe("tui-event-handlers: handleAgentEvent", () => {
}); });
it("processes lifecycle events when runId matches activeChatRunId", () => { it("processes lifecycle events when runId matches activeChatRunId", () => {
const chatLog = { const chatLog = createMockChatLog();
startTool: vi.fn(),
updateToolResult: vi.fn(),
addSystem: vi.fn(),
updateAssistant: vi.fn(),
finalizeAssistant: vi.fn(),
dropAssistant: vi.fn(),
} as unknown as HandlerChatLog;
const { tui, setActivityStatus, handleAgentEvent } = createHandlersHarness({ const { tui, setActivityStatus, handleAgentEvent } = createHandlersHarness({
state: { activeChatRunId: "run-9" }, state: { activeChatRunId: "run-9" },
chatLog, chatLog,

View File

@@ -100,6 +100,33 @@ export function createEventHandlers(context: EventHandlerContext) {
} }
}; };
const finalizeRun = (params: {
runId: string;
wasActiveRun: boolean;
status: "idle" | "error";
}) => {
noteFinalizedRun(params.runId);
clearActiveRunIfMatch(params.runId);
if (params.wasActiveRun) {
setActivityStatus(params.status);
}
void refreshSessionInfo?.();
};
const terminateRun = (params: {
runId: string;
wasActiveRun: boolean;
status: "aborted" | "error";
}) => {
streamAssembler.drop(params.runId);
sessionRuns.delete(params.runId);
clearActiveRunIfMatch(params.runId);
if (params.wasActiveRun) {
setActivityStatus(params.status);
}
void refreshSessionInfo?.();
};
const hasConcurrentActiveRun = (runId: string) => { const hasConcurrentActiveRun = (runId: string) => {
const activeRunId = state.activeChatRunId; const activeRunId = state.activeChatRunId;
if (!activeRunId || activeRunId === runId) { if (!activeRunId || activeRunId === runId) {
@@ -153,12 +180,7 @@ export function createEventHandlers(context: EventHandlerContext) {
if (!evt.message) { if (!evt.message) {
maybeRefreshHistoryForRun(evt.runId); maybeRefreshHistoryForRun(evt.runId);
chatLog.dropAssistant(evt.runId); chatLog.dropAssistant(evt.runId);
noteFinalizedRun(evt.runId); finalizeRun({ runId: evt.runId, wasActiveRun, status: "idle" });
clearActiveRunIfMatch(evt.runId);
if (wasActiveRun) {
setActivityStatus("idle");
}
void refreshSessionInfo?.();
tui.requestRender(); tui.requestRender();
return; return;
} }
@@ -168,13 +190,7 @@ export function createEventHandlers(context: EventHandlerContext) {
if (text) { if (text) {
chatLog.addSystem(text); chatLog.addSystem(text);
} }
streamAssembler.drop(evt.runId); finalizeRun({ runId: evt.runId, wasActiveRun, status: "idle" });
noteFinalizedRun(evt.runId);
clearActiveRunIfMatch(evt.runId);
if (wasActiveRun) {
setActivityStatus("idle");
}
void refreshSessionInfo?.();
tui.requestRender(); tui.requestRender();
return; return;
} }
@@ -194,36 +210,22 @@ export function createEventHandlers(context: EventHandlerContext) {
} else { } else {
chatLog.finalizeAssistant(finalText, evt.runId); chatLog.finalizeAssistant(finalText, evt.runId);
} }
noteFinalizedRun(evt.runId); finalizeRun({
clearActiveRunIfMatch(evt.runId); runId: evt.runId,
if (wasActiveRun) { wasActiveRun,
setActivityStatus(stopReason === "error" ? "error" : "idle"); status: stopReason === "error" ? "error" : "idle",
} });
// Refresh session info to update token counts in footer
void refreshSessionInfo?.();
} }
if (evt.state === "aborted") { if (evt.state === "aborted") {
const wasActiveRun = state.activeChatRunId === evt.runId; const wasActiveRun = state.activeChatRunId === evt.runId;
chatLog.addSystem("run aborted"); chatLog.addSystem("run aborted");
streamAssembler.drop(evt.runId); terminateRun({ runId: evt.runId, wasActiveRun, status: "aborted" });
sessionRuns.delete(evt.runId);
clearActiveRunIfMatch(evt.runId);
if (wasActiveRun) {
setActivityStatus("aborted");
}
void refreshSessionInfo?.();
maybeRefreshHistoryForRun(evt.runId); maybeRefreshHistoryForRun(evt.runId);
} }
if (evt.state === "error") { if (evt.state === "error") {
const wasActiveRun = state.activeChatRunId === evt.runId; const wasActiveRun = state.activeChatRunId === evt.runId;
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`); chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
streamAssembler.drop(evt.runId); terminateRun({ runId: evt.runId, wasActiveRun, status: "error" });
sessionRuns.delete(evt.runId);
clearActiveRunIfMatch(evt.runId);
if (wasActiveRun) {
setActivityStatus("error");
}
void refreshSessionInfo?.();
maybeRefreshHistoryForRun(evt.runId); maybeRefreshHistoryForRun(evt.runId);
} }
tui.requestRender(); tui.requestRender();

View File

@@ -213,20 +213,17 @@ describe("isCommandMessage", () => {
}); });
describe("sanitizeRenderableText", () => { describe("sanitizeRenderableText", () => {
it("breaks very long unbroken tokens to avoid overflow", () => { function expectTokenWidthUnderLimit(input: string) {
const input = "a".repeat(140);
const sanitized = sanitizeRenderableText(input); const sanitized = sanitizeRenderableText(input);
const longestSegment = Math.max(...sanitized.split(/\s+/).map((segment) => segment.length)); const longestSegment = Math.max(...sanitized.split(/\s+/).map((segment) => segment.length));
expect(longestSegment).toBeLessThanOrEqual(32); expect(longestSegment).toBeLessThanOrEqual(32);
}); }
it("breaks moderately long unbroken tokens to protect narrow terminals", () => { it.each([
const input = "b".repeat(90); { label: "very long", input: "a".repeat(140) },
const sanitized = sanitizeRenderableText(input); { label: "moderately long", input: "b".repeat(90) },
const longestSegment = Math.max(...sanitized.split(/\s+/).map((segment) => segment.length)); ])("breaks $label unbroken tokens to protect narrow terminals", ({ input }) => {
expectTokenWidthUnderLimit(input);
expect(longestSegment).toBeLessThanOrEqual(32);
}); });
it("preserves long filesystem paths verbatim for copy safety", () => { it("preserves long filesystem paths verbatim for copy safety", () => {

View File

@@ -173,33 +173,71 @@ export function composeThinkingAndContent(params: {
return parts.join("\n\n").trim(); return parts.join("\n\n").trim();
} }
function asMessageRecord(message: unknown): Record<string, unknown> | undefined {
if (!message || typeof message !== "object") {
return undefined;
}
return message as Record<string, unknown>;
}
function resolveMessageRecord(
message: unknown,
): { record: Record<string, unknown>; content: unknown } | undefined {
const record = asMessageRecord(message);
if (!record) {
return undefined;
}
return { record, content: record.content };
}
function formatAssistantErrorFromRecord(record: Record<string, unknown>): string {
const stopReason = typeof record.stopReason === "string" ? record.stopReason : "";
if (stopReason !== "error") {
return "";
}
const errorMessage = typeof record.errorMessage === "string" ? record.errorMessage : "";
return formatRawAssistantErrorForUi(errorMessage);
}
function collectSanitizedBlockStrings(params: {
content: unknown;
blockType: "text" | "thinking";
valueKey: "text" | "thinking";
}): string[] {
if (!Array.isArray(params.content)) {
return [];
}
const parts: string[] = [];
for (const block of params.content) {
if (!block || typeof block !== "object") {
continue;
}
const rec = block as Record<string, unknown>;
if (rec.type === params.blockType && typeof rec[params.valueKey] === "string") {
parts.push(sanitizeRenderableText(rec[params.valueKey] as string));
}
}
return parts;
}
/** /**
* Extract ONLY thinking blocks from message content. * Extract ONLY thinking blocks from message content.
* Model-agnostic: returns empty string if no thinking blocks exist. * Model-agnostic: returns empty string if no thinking blocks exist.
*/ */
export function extractThinkingFromMessage(message: unknown): string { export function extractThinkingFromMessage(message: unknown): string {
if (!message || typeof message !== "object") { const resolved = resolveMessageRecord(message);
if (!resolved) {
return ""; return "";
} }
const record = message as Record<string, unknown>; const { content } = resolved;
const content = record.content;
if (typeof content === "string") { if (typeof content === "string") {
return ""; return "";
} }
if (!Array.isArray(content)) { const parts = collectSanitizedBlockStrings({
return ""; content,
} blockType: "thinking",
valueKey: "thinking",
const parts: string[] = []; });
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const rec = block as Record<string, unknown>;
if (rec.type === "thinking" && typeof rec.thinking === "string") {
parts.push(sanitizeRenderableText(rec.thinking));
}
}
return parts.join("\n").trim(); return parts.join("\n").trim();
} }
@@ -208,47 +246,25 @@ export function extractThinkingFromMessage(message: unknown): string {
* Model-agnostic: works for any model with text content blocks. * Model-agnostic: works for any model with text content blocks.
*/ */
export function extractContentFromMessage(message: unknown): string { export function extractContentFromMessage(message: unknown): string {
if (!message || typeof message !== "object") { const resolved = resolveMessageRecord(message);
if (!resolved) {
return ""; return "";
} }
const record = message as Record<string, unknown>; const { record, content } = resolved;
const content = record.content;
if (typeof content === "string") { if (typeof content === "string") {
return sanitizeRenderableText(content).trim(); return sanitizeRenderableText(content).trim();
} }
// Check for error BEFORE returning empty for non-array content const parts = collectSanitizedBlockStrings({
if (!Array.isArray(content)) { content,
const stopReason = typeof record.stopReason === "string" ? record.stopReason : ""; blockType: "text",
if (stopReason === "error") { valueKey: "text",
const errorMessage = typeof record.errorMessage === "string" ? record.errorMessage : ""; });
return formatRawAssistantErrorForUi(errorMessage); if (parts.length > 0) {
} return parts.join("\n").trim();
return "";
} }
return formatAssistantErrorFromRecord(record);
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const rec = block as Record<string, unknown>;
if (rec.type === "text" && typeof rec.text === "string") {
parts.push(sanitizeRenderableText(rec.text));
}
}
// If no text blocks found, check for error
if (parts.length === 0) {
const stopReason = typeof record.stopReason === "string" ? record.stopReason : "";
if (stopReason === "error") {
const errorMessage = typeof record.errorMessage === "string" ? record.errorMessage : "";
return formatRawAssistantErrorForUi(errorMessage);
}
}
return parts.join("\n").trim();
} }
function extractTextBlocks(content: unknown, opts?: { includeThinking?: boolean }): string { function extractTextBlocks(content: unknown, opts?: { includeThinking?: boolean }): string {
@@ -259,25 +275,19 @@ function extractTextBlocks(content: unknown, opts?: { includeThinking?: boolean
return ""; return "";
} }
const thinkingParts: string[] = []; const textParts = collectSanitizedBlockStrings({
const textParts: string[] = []; content,
blockType: "text",
for (const block of content) { valueKey: "text",
if (!block || typeof block !== "object") { });
continue; const thinkingParts =
} opts?.includeThinking === true
const record = block as Record<string, unknown>; ? collectSanitizedBlockStrings({
if (record.type === "text" && typeof record.text === "string") { content,
textParts.push(sanitizeRenderableText(record.text)); blockType: "thinking",
} valueKey: "thinking",
if ( })
opts?.includeThinking && : [];
record.type === "thinking" &&
typeof record.thinking === "string"
) {
thinkingParts.push(sanitizeRenderableText(record.thinking));
}
}
return composeThinkingAndContent({ return composeThinkingAndContent({
thinkingText: thinkingParts.join("\n").trim(), thinkingText: thinkingParts.join("\n").trim(),
@@ -290,10 +300,10 @@ export function extractTextFromMessage(
message: unknown, message: unknown,
opts?: { includeThinking?: boolean }, opts?: { includeThinking?: boolean },
): string { ): string {
if (!message || typeof message !== "object") { const record = asMessageRecord(message);
if (!record) {
return ""; return "";
} }
const record = message as Record<string, unknown>;
const text = extractTextBlocks(record.content, opts); const text = extractTextBlocks(record.content, opts);
if (text) { if (text) {
if (record.role === "user") { if (record.role === "user") {
@@ -302,13 +312,11 @@ export function extractTextFromMessage(
return text; return text;
} }
const stopReason = typeof record.stopReason === "string" ? record.stopReason : ""; const errorText = formatAssistantErrorFromRecord(record);
if (stopReason !== "error") { if (!errorText) {
return ""; return "";
} }
return errorText;
const errorMessage = typeof record.errorMessage === "string" ? record.errorMessage : "";
return formatRawAssistantErrorForUi(errorMessage);
} }
export function isCommandMessage(message: unknown): boolean { export function isCommandMessage(message: unknown): boolean {

View File

@@ -1,53 +1,36 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
import { createEditorSubmitHandler } from "./tui.js"; import { createSubmitHarness } from "./tui-submit-test-helpers.js";
function createSubmitHarness() {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handleCommand = vi.fn();
const sendMessage = vi.fn();
const handleBangLine = vi.fn();
const handler = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
handleBangLine,
});
return { editor, handleCommand, sendMessage, handleBangLine, handler };
}
describe("createEditorSubmitHandler", () => { describe("createEditorSubmitHandler", () => {
it("adds submitted messages to editor history", () => { it("adds submitted messages to editor history", () => {
const { editor, handler } = createSubmitHarness(); const { editor, onSubmit } = createSubmitHarness();
handler("hello world"); onSubmit("hello world");
expect(editor.setText).toHaveBeenCalledWith(""); expect(editor.setText).toHaveBeenCalledWith("");
expect(editor.addToHistory).toHaveBeenCalledWith("hello world"); expect(editor.addToHistory).toHaveBeenCalledWith("hello world");
}); });
it("trims input before adding to history", () => { it("trims input before adding to history", () => {
const { editor, handler } = createSubmitHarness(); const { editor, onSubmit } = createSubmitHarness();
handler(" hi "); onSubmit(" hi ");
expect(editor.addToHistory).toHaveBeenCalledWith("hi"); expect(editor.addToHistory).toHaveBeenCalledWith("hi");
}); });
it.each(["", " "])("does not add blank submissions to history", (text) => { it.each(["", " "])("does not add blank submissions to history", (text) => {
const { editor, handler } = createSubmitHarness(); const { editor, onSubmit } = createSubmitHarness();
handler(text); onSubmit(text);
expect(editor.addToHistory).not.toHaveBeenCalled(); expect(editor.addToHistory).not.toHaveBeenCalled();
}); });
it("routes slash commands to handleCommand", () => { it("routes slash commands to handleCommand", () => {
const { editor, handleCommand, sendMessage, handler } = createSubmitHarness(); const { editor, handleCommand, sendMessage, onSubmit } = createSubmitHarness();
handler("/models"); onSubmit("/models");
expect(editor.addToHistory).toHaveBeenCalledWith("/models"); expect(editor.addToHistory).toHaveBeenCalledWith("/models");
expect(handleCommand).toHaveBeenCalledWith("/models"); expect(handleCommand).toHaveBeenCalledWith("/models");
@@ -55,9 +38,9 @@ describe("createEditorSubmitHandler", () => {
}); });
it("routes normal messages to sendMessage", () => { it("routes normal messages to sendMessage", () => {
const { editor, handleCommand, sendMessage, handler } = createSubmitHarness(); const { editor, handleCommand, sendMessage, onSubmit } = createSubmitHarness();
handler("hello"); onSubmit("hello");
expect(editor.addToHistory).toHaveBeenCalledWith("hello"); expect(editor.addToHistory).toHaveBeenCalledWith("hello");
expect(sendMessage).toHaveBeenCalledWith("hello"); expect(sendMessage).toHaveBeenCalledWith("hello");
@@ -65,9 +48,9 @@ describe("createEditorSubmitHandler", () => {
}); });
it("routes bang-prefixed lines to handleBangLine", () => { it("routes bang-prefixed lines to handleBangLine", () => {
const { handleBangLine, handler } = createSubmitHarness(); const { handleBangLine, onSubmit } = createSubmitHarness();
handler("!ls"); onSubmit("!ls");
expect(handleBangLine).toHaveBeenCalledWith("!ls"); expect(handleBangLine).toHaveBeenCalledWith("!ls");
}); });

View File

@@ -8,7 +8,7 @@ import {
import type { ChatLog } from "./components/chat-log.js"; import type { ChatLog } from "./components/chat-log.js";
import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js"; import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js";
import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js"; import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js";
import type { TuiOptions, TuiStateAccess } from "./tui-types.js"; import type { SessionInfo, TuiOptions, TuiStateAccess } from "./tui-types.js";
type SessionActionContext = { type SessionActionContext = {
client: GatewayChatClient; client: GatewayChatClient;
@@ -33,21 +33,9 @@ type SessionInfoDefaults = {
contextTokens?: number | null; contextTokens?: number | null;
}; };
type SessionInfoEntry = { type SessionInfoEntry = SessionInfo & {
thinkingLevel?: string;
verboseLevel?: string;
reasoningLevel?: string;
model?: string;
modelProvider?: string;
modelOverride?: string; modelOverride?: string;
providerOverride?: string; providerOverride?: string;
contextTokens?: number | null;
inputTokens?: number | null;
outputTokens?: number | null;
totalTokens?: number | null;
responseUsage?: "on" | "off" | "tokens" | "full";
updatedAt?: number | null;
displayName?: string;
}; };
export function createSessionActions(context: SessionActionContext) { export function createSessionActions(context: SessionActionContext) {

View File

@@ -1,6 +1,23 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { TuiStreamAssembler } from "./tui-stream-assembler.js"; import { TuiStreamAssembler } from "./tui-stream-assembler.js";
const STREAM_WITH_TOOL_BLOCKS = {
role: "assistant",
content: [
{ type: "text", text: "Before tool call" },
{ type: "tool_use", name: "search" },
{ type: "text", text: "After tool call" },
],
} as const;
const STREAM_AFTER_TOOL_BLOCKS = {
role: "assistant",
content: [
{ type: "tool_use", name: "search" },
{ type: "text", text: "After tool call" },
],
} as const;
describe("TuiStreamAssembler", () => { describe("TuiStreamAssembler", () => {
it("keeps thinking before content even when thinking arrives later", () => { it("keeps thinking before content even when thinking arrives later", () => {
const assembler = new TuiStreamAssembler(); const assembler = new TuiStreamAssembler();
@@ -92,61 +109,19 @@ describe("TuiStreamAssembler", () => {
it("keeps richer streamed text when final payload drops earlier blocks", () => { it("keeps richer streamed text when final payload drops earlier blocks", () => {
const assembler = new TuiStreamAssembler(); const assembler = new TuiStreamAssembler();
assembler.ingestDelta( assembler.ingestDelta("run-5", STREAM_WITH_TOOL_BLOCKS, false);
"run-5",
{
role: "assistant",
content: [
{ type: "text", text: "Before tool call" },
{ type: "tool_use", name: "search" },
{ type: "text", text: "After tool call" },
],
},
false,
);
const finalText = assembler.finalize( const finalText = assembler.finalize("run-5", STREAM_AFTER_TOOL_BLOCKS, false);
"run-5",
{
role: "assistant",
content: [
{ type: "tool_use", name: "search" },
{ type: "text", text: "After tool call" },
],
},
false,
);
expect(finalText).toBe("Before tool call\nAfter tool call"); expect(finalText).toBe("Before tool call\nAfter tool call");
}); });
it("does not regress streamed text when a delta drops boundary blocks after tool calls", () => { it("does not regress streamed text when a delta drops boundary blocks after tool calls", () => {
const assembler = new TuiStreamAssembler(); const assembler = new TuiStreamAssembler();
const first = assembler.ingestDelta( const first = assembler.ingestDelta("run-5-stream", STREAM_WITH_TOOL_BLOCKS, false);
"run-5-stream",
{
role: "assistant",
content: [
{ type: "text", text: "Before tool call" },
{ type: "tool_use", name: "search" },
{ type: "text", text: "After tool call" },
],
},
false,
);
expect(first).toBe("Before tool call\nAfter tool call"); expect(first).toBe("Before tool call\nAfter tool call");
const second = assembler.ingestDelta( const second = assembler.ingestDelta("run-5-stream", STREAM_AFTER_TOOL_BLOCKS, false);
"run-5-stream",
{
role: "assistant",
content: [
{ type: "tool_use", name: "search" },
{ type: "text", text: "After tool call" },
],
},
false,
);
expect(second).toBeNull(); expect(second).toBeNull();
}); });

View File

@@ -0,0 +1,32 @@
import { vi } from "vitest";
import { createEditorSubmitHandler } from "./tui.js";
type MockFn = ReturnType<typeof vi.fn>;
export type SubmitHarness = {
editor: {
setText: MockFn;
addToHistory: MockFn;
};
handleCommand: MockFn;
sendMessage: MockFn;
handleBangLine: MockFn;
onSubmit: (text: string) => void;
};
export function createSubmitHarness(): SubmitHarness {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handleCommand = vi.fn();
const sendMessage = vi.fn();
const handleBangLine = vi.fn();
const onSubmit = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
handleBangLine,
});
return { editor, handleCommand, sendMessage, handleBangLine, onSubmit };
}

View File

@@ -24,6 +24,8 @@ export type AgentEvent = {
data?: Record<string, unknown>; data?: Record<string, unknown>;
}; };
export type ResponseUsageMode = "on" | "off" | "tokens" | "full";
export type SessionInfo = { export type SessionInfo = {
thinkingLevel?: string; thinkingLevel?: string;
verboseLevel?: string; verboseLevel?: string;
@@ -34,7 +36,7 @@ export type SessionInfo = {
inputTokens?: number | null; inputTokens?: number | null;
outputTokens?: number | null; outputTokens?: number | null;
totalTokens?: number | null; totalTokens?: number | null;
responseUsage?: "on" | "off" | "tokens" | "full"; responseUsage?: ResponseUsageMode;
updatedAt?: number | null; updatedAt?: number | null;
displayName?: string; displayName?: string;
}; };

View File

@@ -1,26 +1,6 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import { createSubmitHarness } from "./tui-submit-test-helpers.js";
createEditorSubmitHandler, import { createSubmitBurstCoalescer, shouldEnableWindowsGitBashPasteFallback } from "./tui.js";
createSubmitBurstCoalescer,
shouldEnableWindowsGitBashPasteFallback,
} from "./tui.js";
function createSubmitHarness() {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handleCommand = vi.fn();
const sendMessage = vi.fn();
const handleBangLine = vi.fn();
const onSubmit = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
handleBangLine,
});
return { editor, handleCommand, sendMessage, handleBangLine, onSubmit };
}
describe("createEditorSubmitHandler", () => { describe("createEditorSubmitHandler", () => {
it("routes lines starting with ! to handleBangLine", () => { it("routes lines starting with ! to handleBangLine", () => {

View File

@@ -91,27 +91,33 @@ describe("resolveGatewayDisconnectState", () => {
}); });
describe("createBackspaceDeduper", () => { describe("createBackspaceDeduper", () => {
it("suppresses duplicate backspace events within the dedupe window", () => { function createTimedDedupe(start = 1000) {
let now = 1000; let now = start;
const dedupe = createBackspaceDeduper({ const dedupe = createBackspaceDeduper({
dedupeWindowMs: 8, dedupeWindowMs: 8,
now: () => now, now: () => now,
}); });
return {
dedupe,
advance: (deltaMs: number) => {
now += deltaMs;
},
};
}
it("suppresses duplicate backspace events within the dedupe window", () => {
const { dedupe, advance } = createTimedDedupe();
expect(dedupe("\x7f")).toBe("\x7f"); expect(dedupe("\x7f")).toBe("\x7f");
now += 1; advance(1);
expect(dedupe("\x08")).toBe(""); expect(dedupe("\x08")).toBe("");
}); });
it("preserves backspace events outside the dedupe window", () => { it("preserves backspace events outside the dedupe window", () => {
let now = 1000; const { dedupe, advance } = createTimedDedupe();
const dedupe = createBackspaceDeduper({
dedupeWindowMs: 8,
now: () => now,
});
expect(dedupe("\x7f")).toBe("\x7f"); expect(dedupe("\x7f")).toBe("\x7f");
now += 10; advance(10);
expect(dedupe("\x7f")).toBe("\x7f"); expect(dedupe("\x7f")).toBe("\x7f");
}); });