mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 06:42:44 +00:00
refactor(tui): dedupe handlers and formatter test setup
This commit is contained in:
35
src/tui/commands.test.ts
Normal file
35
src/tui/commands.test.ts
Normal 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>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/tui/components/markdown-message.ts
Normal file
19
src/tui/components/markdown-message.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
32
src/tui/tui-submit-test-helpers.ts
Normal file
32
src/tui/tui-submit-test-helpers.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user