mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentRecord } from "../src/types.js";
|
||||
|
||||
// ── Mock wrapTextWithAnsi ──────────────────────────────────────────────
|
||||
// We need to control what wrapTextWithAnsi returns to simulate the
|
||||
// upstream bug (returning lines wider than requested width).
|
||||
// vi.mock is hoisted and intercepts before conversation-viewer.ts binds
|
||||
// its import.
|
||||
|
||||
let wrapOverride: ((text: string, width: number) => string[]) | null = null;
|
||||
|
||||
vi.mock("@earendil-works/pi-tui", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@earendil-works/pi-tui")>();
|
||||
return {
|
||||
...original,
|
||||
wrapTextWithAnsi: (...args: [string, number]) => {
|
||||
if (wrapOverride) return wrapOverride(...args);
|
||||
return original.wrapTextWithAnsi(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Must import AFTER vi.mock declaration (vitest hoists vi.mock but the
|
||||
// dynamic import of the test subject must happen after)
|
||||
const { visibleWidth } = await import("@earendil-works/pi-tui");
|
||||
const { ConversationViewer } = await import("../src/ui/conversation-viewer.js");
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function mockTui(rows = 40, columns = 80) {
|
||||
return {
|
||||
terminal: { rows, columns },
|
||||
requestRender: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function mockSession(messages: any[] = []) {
|
||||
return {
|
||||
messages,
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
dispose: vi.fn(),
|
||||
getSessionStats: () => ({ tokens: { input: 0, output: 0, cacheWrite: 0 } }),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function mockRecord(overrides: Partial<AgentRecord> = {}): AgentRecord {
|
||||
return {
|
||||
id: "test-1",
|
||||
type: "general-purpose",
|
||||
description: "test agent",
|
||||
status: "running",
|
||||
toolUses: 0,
|
||||
startedAt: Date.now(),
|
||||
...overrides,
|
||||
} as AgentRecord;
|
||||
}
|
||||
|
||||
function ansiTheme() {
|
||||
return {
|
||||
fg: (_color: string, text: string) => `\x1b[38;5;240m${text}\x1b[0m`,
|
||||
bold: (text: string) => `\x1b[1m${text}\x1b[22m`,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function assertAllLinesFit(lines: string[], width: number) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const vw = visibleWidth(lines[i]);
|
||||
expect(vw, `line ${i} exceeds width (${vw} > ${width}): ${JSON.stringify(lines[i])}`).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
wrapOverride = null;
|
||||
});
|
||||
|
||||
describe("ConversationViewer cost display", () => {
|
||||
/** The header line, with a cost of `cost` on the record and showCost `on`. */
|
||||
function header(on: boolean, cost: number): string {
|
||||
const record = mockRecord({
|
||||
lifetimeUsage: { input: 1000, output: 200, cacheWrite: 0, cost },
|
||||
} as Partial<AgentRecord>);
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, 200), mockSession([]), record, undefined,
|
||||
{ fg: (_c: string, t: string) => t, bold: (t: string) => t } as any,
|
||||
vi.fn(), undefined, undefined, undefined, on,
|
||||
);
|
||||
return viewer.render(200).join("\n");
|
||||
}
|
||||
|
||||
it("shows the cost beside the token count when enabled", () => {
|
||||
// The viewer opens on finished agents, whose live activity entry is gone —
|
||||
// so this reads the record, and would show nothing if it did not.
|
||||
const out = header(true, 0.0042);
|
||||
expect(out).toContain("1.2k token");
|
||||
expect(out).toContain("~$0.0042");
|
||||
});
|
||||
|
||||
it("shows no cost when disabled", () => {
|
||||
const out = header(false, 0.0042);
|
||||
expect(out).toContain("1.2k token");
|
||||
expect(out).not.toContain("$");
|
||||
});
|
||||
|
||||
it("shows no cost for a model with no pricing data", () => {
|
||||
expect(header(true, 0)).not.toContain("$");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConversationViewer", () => {
|
||||
describe("render width safety", () => {
|
||||
const widths = [40, 80, 120, 216];
|
||||
|
||||
it("no line exceeds width with empty messages", () => {
|
||||
for (const w of widths) {
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession([]), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(viewer.render(w), w);
|
||||
}
|
||||
});
|
||||
|
||||
it("no line exceeds width with plain text messages", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "Hello, how are you?" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "I am fine, thank you for asking." }] },
|
||||
];
|
||||
for (const w of widths) {
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(viewer.render(w), w);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps bordered rows exact-width at a double-width truncation boundary", () => {
|
||||
const width = 40;
|
||||
for (let prefixLength = 0; prefixLength < width; prefixLength++) {
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, width),
|
||||
mockSession([]),
|
||||
mockRecord({ description: `${"a".repeat(prefixLength)}界more` }),
|
||||
undefined,
|
||||
ansiTheme(),
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
for (const line of viewer.render(width)) {
|
||||
expect(
|
||||
visibleWidth(line),
|
||||
`prefix ${prefixLength} produced an under-width bordered row: ${JSON.stringify(line)}`,
|
||||
).toBe(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("no line exceeds width when text is longer than viewport", () => {
|
||||
const longLine = "A".repeat(500);
|
||||
const messages = [
|
||||
{ role: "user", content: longLine },
|
||||
{ role: "assistant", content: [{ type: "text", text: longLine }] },
|
||||
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: longLine }] },
|
||||
];
|
||||
for (const w of widths) {
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(viewer.render(w), w);
|
||||
}
|
||||
});
|
||||
|
||||
it("no line exceeds width with embedded ANSI escape codes in content", () => {
|
||||
const ansiText = `\x1b[1mBold heading\x1b[22m and \x1b[31mred text\x1b[0m ${"X".repeat(300)}`;
|
||||
const messages = [
|
||||
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: ansiText }] },
|
||||
];
|
||||
for (const w of widths) {
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(viewer.render(w), w);
|
||||
}
|
||||
});
|
||||
|
||||
it("no line exceeds width with long URLs", () => {
|
||||
const url = "https://example.com/" + "a/b/c/d/e/".repeat(30) + "?q=" + "x".repeat(100);
|
||||
const messages = [
|
||||
{ role: "assistant", content: [{ type: "text", text: `Check this link: ${url}` }] },
|
||||
];
|
||||
for (const w of widths) {
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(viewer.render(w), w);
|
||||
}
|
||||
});
|
||||
|
||||
it("no line exceeds width with wide table-like content", () => {
|
||||
const header = "| " + Array.from({ length: 20 }, (_, i) => `Column${i}`).join(" | ") + " |";
|
||||
const dataRow = "| " + Array.from({ length: 20 }, () => "value123").join(" | ") + " |";
|
||||
const table = [header, dataRow, dataRow, dataRow].join("\n");
|
||||
const messages = [
|
||||
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: table }] },
|
||||
];
|
||||
for (const w of widths) {
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(viewer.render(w), w);
|
||||
}
|
||||
});
|
||||
|
||||
it("no line exceeds width with bashExecution messages", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "bashExecution", command: "cat " + "/very/long/path/".repeat(20) + "file.txt",
|
||||
output: "O".repeat(600),
|
||||
exitCode: 0, cancelled: false, truncated: false, timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
for (const w of widths) {
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(viewer.render(w), w);
|
||||
}
|
||||
});
|
||||
|
||||
it("no line exceeds width with running activity indicator", () => {
|
||||
const activity = {
|
||||
activeTools: new Map([["read", "file.ts"], ["grep", "pattern"]]),
|
||||
toolUses: 5, tokens: "10k", responseText: "R".repeat(400),
|
||||
session: { getSessionStats: () => ({ tokens: { total: 50000 } }) },
|
||||
};
|
||||
const messages = [
|
||||
{ role: "user", content: "do the thing" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "working on it" }] },
|
||||
];
|
||||
for (const w of widths) {
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession(messages), mockRecord({ status: "running" }), activity as any, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(viewer.render(w), w);
|
||||
}
|
||||
});
|
||||
|
||||
it("no line exceeds width with tool calls", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Let me check that." },
|
||||
{ type: "toolCall", toolUseId: "t1", name: "very_long_tool_name_" + "x".repeat(200), input: {} },
|
||||
],
|
||||
},
|
||||
];
|
||||
for (const w of widths) {
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(viewer.render(w), w);
|
||||
}
|
||||
});
|
||||
|
||||
it("no line exceeds width at narrow terminal", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "Hello world, this is a normal sentence." },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Sure, here's the answer." }] },
|
||||
];
|
||||
for (const w of [8, 10, 15, 20]) {
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(viewer.render(w), w);
|
||||
}
|
||||
});
|
||||
|
||||
it("no line exceeds width with mixed ANSI + unicode content", () => {
|
||||
const text = `\x1b[32m✓\x1b[0m Test passed — 日本語テスト ${"あ".repeat(50)} \x1b[33m⚠\x1b[0m`;
|
||||
const messages = [
|
||||
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text }] },
|
||||
];
|
||||
for (const w of widths) {
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(viewer.render(w), w);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("safety net against upstream wrapTextWithAnsi bugs", () => {
|
||||
// These tests call buildContentLines() directly (via the private method)
|
||||
// because render() has its own truncation via row(). The safety net in
|
||||
// buildContentLines is what prevents the TUI crash — it must clamp
|
||||
// independently of render().
|
||||
|
||||
/** Call the private buildContentLines method directly. */
|
||||
function callBuildContentLines(viewer: InstanceType<typeof ConversationViewer>, width: number): string[] {
|
||||
return (viewer as any).buildContentLines(width);
|
||||
}
|
||||
|
||||
it("mock is intercepting wrapTextWithAnsi", async () => {
|
||||
const { wrapTextWithAnsi } = await import("@earendil-works/pi-tui");
|
||||
wrapOverride = () => ["MOCK_SENTINEL"];
|
||||
expect(wrapTextWithAnsi("anything", 10)).toEqual(["MOCK_SENTINEL"]);
|
||||
wrapOverride = null;
|
||||
});
|
||||
|
||||
it("clamps overwidth lines from toolResult content", () => {
|
||||
const w = 80;
|
||||
wrapOverride = () => ["X".repeat(w + 50)];
|
||||
|
||||
const messages = [
|
||||
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: "output" }] },
|
||||
];
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(callBuildContentLines(viewer, w), w);
|
||||
});
|
||||
|
||||
it("clamps overwidth lines from user message content", () => {
|
||||
const w = 80;
|
||||
wrapOverride = () => ["Y".repeat(w + 100)];
|
||||
|
||||
const messages = [{ role: "user", content: "hello" }];
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(callBuildContentLines(viewer, w), w);
|
||||
});
|
||||
|
||||
it("clamps overwidth lines from assistant message content", () => {
|
||||
const w = 80;
|
||||
wrapOverride = () => ["Z".repeat(w + 100)];
|
||||
|
||||
const messages = [
|
||||
{ role: "assistant", content: [{ type: "text", text: "response" }] },
|
||||
];
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(callBuildContentLines(viewer, w), w);
|
||||
});
|
||||
|
||||
it("clamps overwidth lines from bashExecution output", () => {
|
||||
const w = 80;
|
||||
wrapOverride = () => ["B".repeat(w + 100)];
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: "bashExecution", command: "ls", output: "out",
|
||||
exitCode: 0, cancelled: false, truncated: false, timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(callBuildContentLines(viewer, w), w);
|
||||
});
|
||||
|
||||
it("clamps overwidth lines that also contain ANSI codes", () => {
|
||||
const w = 80;
|
||||
wrapOverride = () => [`\x1b[1m\x1b[31m${"W".repeat(w + 30)}\x1b[0m`];
|
||||
|
||||
const messages = [
|
||||
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: "output" }] },
|
||||
];
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
assertAllLinesFit(callBuildContentLines(viewer, w), w);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop key", () => {
|
||||
const W = 80;
|
||||
|
||||
it("two-press x stops a running agent (first arms, second aborts)", () => {
|
||||
const onStop = vi.fn();
|
||||
const tui = mockTui(30, W);
|
||||
const viewer = new ConversationViewer(
|
||||
tui, mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(), onStop,
|
||||
);
|
||||
|
||||
// Idle footer offers the stop affordance.
|
||||
expect(viewer.render(W).join("\n")).toContain("x stop");
|
||||
|
||||
// First press arms (no abort yet) and re-renders.
|
||||
viewer.handleInput("x");
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
expect(tui.requestRender).toHaveBeenCalled();
|
||||
expect(viewer.render(W).join("\n")).toContain("x again to STOP");
|
||||
|
||||
// Second press aborts.
|
||||
viewer.handleInput("x");
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("any other key disarms the confirm", () => {
|
||||
const onStop = vi.fn();
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(), onStop,
|
||||
);
|
||||
|
||||
viewer.handleInput("x"); // arm
|
||||
viewer.handleInput("j"); // scroll → disarm
|
||||
expect(viewer.render(W).join("\n")).toContain("x stop");
|
||||
expect(viewer.render(W).join("\n")).not.toContain("x again to STOP");
|
||||
|
||||
viewer.handleInput("x"); // arms again, does NOT stop
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not offer or perform stop once the agent is no longer running", () => {
|
||||
const onStop = vi.fn();
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, W), mockSession(), mockRecord({ status: "completed" }), undefined, ansiTheme(), vi.fn(), onStop,
|
||||
);
|
||||
|
||||
expect(viewer.render(W).join("\n")).not.toContain("x stop");
|
||||
viewer.handleInput("x");
|
||||
viewer.handleInput("x");
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no stop affordance when no onStop handler is provided (read-only history)", () => {
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
expect(viewer.render(W).join("\n")).not.toContain("x stop");
|
||||
expect(() => { viewer.handleInput("x"); viewer.handleInput("x"); }).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("steer composer", () => {
|
||||
const W = 80;
|
||||
|
||||
function makeViewer(opts: { status?: AgentRecord["status"]; onSteer?: (m: string) => void } = {}) {
|
||||
const onSteer = opts.onSteer ?? vi.fn();
|
||||
const tui = mockTui(30, W);
|
||||
const viewer = new ConversationViewer(
|
||||
tui, mockSession(), mockRecord({ status: opts.status ?? "running" }),
|
||||
undefined, ansiTheme(), vi.fn(), undefined, undefined, onSteer,
|
||||
);
|
||||
return { viewer, tui, onSteer };
|
||||
}
|
||||
|
||||
it("offers the steer affordance for a running agent and opens on Enter", () => {
|
||||
const { viewer } = makeViewer();
|
||||
expect(viewer.render(W).join("\n")).toContain("Enter steer");
|
||||
|
||||
viewer.handleInput("\r"); // Enter
|
||||
// Composer is shown (its prompt + send/cancel hint), idle footer is gone.
|
||||
const out = viewer.render(W).join("\n");
|
||||
expect(out).toContain("Enter send · Esc cancel");
|
||||
expect(out).not.toContain("Enter steer");
|
||||
});
|
||||
|
||||
it("typing then Enter sends the trimmed message and closes the composer", () => {
|
||||
const { viewer, onSteer } = makeViewer();
|
||||
viewer.handleInput("\r"); // open composer
|
||||
for (const ch of " hello ") viewer.handleInput(ch);
|
||||
viewer.handleInput("\r"); // send
|
||||
|
||||
expect(onSteer).toHaveBeenCalledWith("hello");
|
||||
expect(viewer.render(W).join("\n")).not.toContain("Enter send"); // composer closed
|
||||
});
|
||||
|
||||
it("Esc cancels the composer without sending", () => {
|
||||
const { viewer, onSteer } = makeViewer();
|
||||
viewer.handleInput("\r"); // open composer
|
||||
for (const ch of "draft") viewer.handleInput(ch);
|
||||
viewer.handleInput("\x1b"); // Esc
|
||||
|
||||
expect(onSteer).not.toHaveBeenCalled();
|
||||
expect(viewer.render(W).join("\n")).not.toContain("Enter send");
|
||||
});
|
||||
|
||||
it("an empty submit just returns (like Esc), without calling onSteer", () => {
|
||||
const { viewer, onSteer } = makeViewer();
|
||||
viewer.handleInput("\r"); // open composer
|
||||
viewer.handleInput("\r"); // empty submit
|
||||
expect(onSteer).not.toHaveBeenCalled();
|
||||
expect(viewer.render(W).join("\n")).not.toContain("Enter send"); // composer closed
|
||||
});
|
||||
|
||||
it("scroll keys are inert while composing (input owns them)", () => {
|
||||
const { viewer } = makeViewer();
|
||||
viewer.handleInput("\r"); // open composer
|
||||
// 'j' would normally scroll, but here it types into the composer.
|
||||
viewer.handleInput("j");
|
||||
expect(viewer.render(W).join("\n")).toContain("Enter send · Esc cancel");
|
||||
});
|
||||
|
||||
it("no steer affordance once the agent is no longer running", () => {
|
||||
const { viewer, onSteer } = makeViewer({ status: "completed" });
|
||||
expect(viewer.render(W).join("\n")).not.toContain("Enter steer");
|
||||
viewer.handleInput("\r");
|
||||
expect(viewer.render(W).join("\n")).not.toContain("Enter send");
|
||||
expect(onSteer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no steer affordance when no onSteer handler is provided", () => {
|
||||
const viewer = new ConversationViewer(
|
||||
mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(),
|
||||
);
|
||||
expect(viewer.render(W).join("\n")).not.toContain("Enter steer");
|
||||
expect(() => viewer.handleInput("\r")).not.toThrow();
|
||||
});
|
||||
|
||||
it("composer rows never exceed width", () => {
|
||||
for (const w of [40, 80, 120]) {
|
||||
const tui = mockTui(30, w);
|
||||
const viewer = new ConversationViewer(
|
||||
tui, mockSession(), mockRecord({ status: "running" }),
|
||||
undefined, ansiTheme(), vi.fn(), undefined, undefined, vi.fn(),
|
||||
);
|
||||
viewer.handleInput("\r"); // open composer
|
||||
for (const ch of "x".repeat(200)) viewer.handleInput(ch);
|
||||
assertAllLinesFit(viewer.render(w), w);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user