@duckmind/dm-darwin-arm64 0.33.2 → 0.33.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,348 +0,0 @@
1
- /**
2
- * dm-ask — /ask side-question slash command.
3
- *
4
- * Asks the same primary model a one-off side question using the cloned primary
5
- * conversation as context. Answer is rendered ephemerally in a bottom-slot
6
- * overlay (never enters main agent's messages). History persists per-session-file
7
- * via globalThis-keyed storage; process-scoped only (no disk persistence).
8
- */
9
-
10
- import { readFileSync } from "node:fs";
11
- import { fileURLToPath } from "node:url";
12
- import {
13
- type AssistantMessage,
14
- completeSimple,
15
- type Message,
16
- type StopReason,
17
- type UserMessage,
18
- } from "@mariozechner/pi-ai";
19
- import {
20
- convertToLlm,
21
- type ExtensionAPI,
22
- type ExtensionCommandContext,
23
- type ExtensionContext,
24
- type SessionEntry,
25
- } from "@mariozechner/pi-coding-agent";
26
- import { showAskOverlay } from "./ask-ui.js";
27
-
28
- // ---------------------------------------------------------------------------
29
- // Constants — flat named consts, grouped by concern (advisor pattern, b9428e9)
30
- // ---------------------------------------------------------------------------
31
-
32
- // Identity
33
- export const ASK_COMMAND_NAME = "ask";
34
-
35
- // Storage — globalThis-keyed survives module re-import on /new, /fork, /resume.
36
- // Lost on DM process exit (intentional — no disk persistence).
37
- export const ASK_STATE_KEY = Symbol.for("dm-ask");
38
-
39
- // Cross-session pattern hint: how many recent question-strings to inject
40
- export const CROSS_SESSION_HINT_LIMIT = 10;
41
-
42
- // Messages (static)
43
- const MSG_REQUIRES_INTERACTIVE = "/ask requires interactive mode";
44
- const MSG_USAGE = "Usage: /ask <question>";
45
- const MSG_NO_MODEL = "/ask requires an active model";
46
-
47
- // Errors (static)
48
- const ERR_EMPTY_RESPONSE = "/ask returned no text content.";
49
-
50
- // Errors (parameterized)
51
- const errMisconfigured = (label: string, err: string) => `/ask model (${label}) is misconfigured: ${err}`;
52
- const errNoApiKey = (label: string) => `/ask model (${label}) has no API key available.`;
53
- const errCallFailed = (err: string | undefined) => `/ask call failed: ${err ?? "unknown error"}`;
54
- const errCallThrew = (msg: string) => `/ask call threw: ${msg}`;
55
-
56
- // ---------------------------------------------------------------------------
57
- // Types
58
- // ---------------------------------------------------------------------------
59
-
60
- // Real messages — no fabrication. userMessage is built at call time; assistantMessage
61
- // is the unmodified completeSimple response. Stable object references across calls →
62
- // byte-identical prompt prefix on subsequent /ask invocations (cache parity).
63
- export interface AskTurn {
64
- userMessage: UserMessage;
65
- assistantMessage: AssistantMessage;
66
- }
67
-
68
- export interface AskState {
69
- histories: Map<string, AskTurn[]>;
70
- snapshots: Map<string, { messages: Message[] }>;
71
- }
72
-
73
- // ---------------------------------------------------------------------------
74
- // System prompt — loaded once at module init from prompts/ask-system.txt
75
- // ---------------------------------------------------------------------------
76
-
77
- export const ASK_SYSTEM_PROMPT = readFileSync(
78
- fileURLToPath(new URL("./prompts/ask-system.txt", import.meta.url)),
79
- "utf-8",
80
- ).trimEnd();
81
-
82
- // ---------------------------------------------------------------------------
83
- // Storage — globalThis-keyed, survives module re-import on /new, /fork, /resume.
84
- // Standard Node.js `globalThis + Symbol.for()` idiom for cross-import-graph
85
- // singleton state (used by OpenTelemetry, etc.); lost on process exit.
86
- // ---------------------------------------------------------------------------
87
-
88
- function getState(): AskState {
89
- const g = globalThis as unknown as { [k: symbol]: AskState | undefined };
90
- let state = g[ASK_STATE_KEY];
91
- if (!state) {
92
- state = {
93
- histories: new Map(),
94
- snapshots: new Map(),
95
- };
96
- g[ASK_STATE_KEY] = state;
97
- }
98
- return state;
99
- }
100
-
101
- function getSessionFile(ctx: ExtensionContext): string {
102
- return ctx.sessionManager.getSessionFile() ?? `memory:${ctx.sessionManager.getSessionId()}`;
103
- }
104
-
105
- function getSessionHistory(ctx: ExtensionContext): AskTurn[] {
106
- const key = getSessionFile(ctx);
107
- const state = getState();
108
- let turns = state.histories.get(key);
109
- if (!turns) {
110
- turns = [];
111
- state.histories.set(key, turns);
112
- }
113
- return turns;
114
- }
115
-
116
- function pushSessionTurn(ctx: ExtensionContext, turn: AskTurn): void {
117
- getSessionHistory(ctx).push(turn);
118
- }
119
-
120
- export function clearSessionHistory(ctx: ExtensionContext): void {
121
- getState().histories.set(getSessionFile(ctx), []);
122
- }
123
-
124
- function getSnapshot(ctx: ExtensionContext): { messages: Message[] } | undefined {
125
- return getState().snapshots.get(getSessionFile(ctx));
126
- }
127
-
128
- function setSnapshot(ctx: ExtensionContext, snapshot: { messages: Message[] }): void {
129
- getState().snapshots.set(getSessionFile(ctx), snapshot);
130
- }
131
-
132
- export function invalidateSnapshot(ctx: ExtensionContext): void {
133
- getState().snapshots.delete(getSessionFile(ctx));
134
- }
135
-
136
- // Extract text from a UserMessage's content.
137
- export function userMessageText(msg: UserMessage): string {
138
- if (typeof msg.content === "string") return msg.content;
139
- return msg.content
140
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
141
- .map((c) => c.text)
142
- .join("\n");
143
- }
144
-
145
- // Extract text from an AssistantMessage's content (text parts only).
146
- export function assistantMessageText(msg: AssistantMessage): string {
147
- return msg.content
148
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
149
- .map((c) => c.text)
150
- .join("\n");
151
- }
152
-
153
- // Cross-session pattern hint — last N question-strings across ALL sessions.
154
- function getCrossSessionHint(): string {
155
- const allTurns: { q: string; ts: number }[] = [];
156
- for (const turns of getState().histories.values()) {
157
- for (const t of turns) {
158
- allTurns.push({ q: userMessageText(t.userMessage), ts: t.userMessage.timestamp });
159
- }
160
- }
161
- if (allTurns.length === 0) return "";
162
- const recent = allTurns.sort((a, b) => a.ts - b.ts).slice(-CROSS_SESSION_HINT_LIMIT);
163
- const lines = recent.map((t, i) => `${i + 1}. ${t.q.replace(/\s+/g, " ").slice(0, 200)}`);
164
- return `\n\n## Recent /ask questions across sessions (oldest first)\n\n${lines.join("\n")}`;
165
- }
166
-
167
- // ---------------------------------------------------------------------------
168
- // Executor — auth, message threading, completeSimple, four StopReason branches
169
- // Modeled after rpiv-advisor/advisor.ts:225-336
170
- // ---------------------------------------------------------------------------
171
-
172
- export interface AskExecResult {
173
- ok: boolean;
174
- answer?: string;
175
- userMessage?: UserMessage;
176
- assistantMessage?: AssistantMessage;
177
- error?: string;
178
- stopReason?: StopReason;
179
- aborted?: boolean;
180
- }
181
-
182
- function readBranchMessages(ctx: ExtensionContext): Message[] {
183
- const cached = getSnapshot(ctx);
184
- if (cached) return cached.messages;
185
- // Cold start (no message_end fired yet) — fall back to live read
186
- const branch = ctx.sessionManager.getBranch() as SessionEntry[];
187
- const agentMessages = branch
188
- .filter((e): e is SessionEntry & { type: "message" } => e.type === "message")
189
- .map((e) => e.message);
190
- return convertToLlm(agentMessages);
191
- }
192
-
193
- function buildAskMessages(ctx: ExtensionContext, userMessage: UserMessage): Message[] {
194
- const branchMessages = readBranchMessages(ctx);
195
- const history = getSessionHistory(ctx);
196
- // Reusing stored real UserMessage/AssistantMessage object references across calls
197
- // preserves byte-identical prompt prefix (cache parity).
198
- const historyMessages: Message[] = history.flatMap((h) => [h.userMessage, h.assistantMessage]);
199
- return [...branchMessages, ...historyMessages, userMessage];
200
- }
201
-
202
- function buildSystemPrompt(): string {
203
- return ASK_SYSTEM_PROMPT + getCrossSessionHint();
204
- }
205
-
206
- export async function executeAsk(
207
- question: string,
208
- ctx: ExtensionContext,
209
- controller: AbortController,
210
- ): Promise<AskExecResult> {
211
- const model = ctx.model;
212
- if (!model) {
213
- return { ok: false, error: MSG_NO_MODEL };
214
- }
215
- const modelLabel = `${model.provider}:${model.id}`;
216
-
217
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
218
- if (!auth.ok) {
219
- return { ok: false, error: errMisconfigured(modelLabel, auth.error) };
220
- }
221
- if (!auth.apiKey) {
222
- return { ok: false, error: errNoApiKey(modelLabel) };
223
- }
224
-
225
- const userMessage: UserMessage = {
226
- role: "user",
227
- content: [{ type: "text", text: question }],
228
- timestamp: Date.now(),
229
- };
230
- const messages = buildAskMessages(ctx, userMessage);
231
- const systemPrompt = buildSystemPrompt();
232
-
233
- try {
234
- const response = await completeSimple(
235
- model,
236
- { systemPrompt, messages, tools: [] },
237
- {
238
- apiKey: auth.apiKey,
239
- headers: auth.headers,
240
- signal: controller.signal, // own AbortController, NOT ctx.signal (Decision 8)
241
- },
242
- );
243
-
244
- if (response.stopReason === "aborted") {
245
- return { ok: false, aborted: true, stopReason: response.stopReason };
246
- }
247
- if (response.stopReason === "error") {
248
- return {
249
- ok: false,
250
- error: errCallFailed(response.errorMessage),
251
- stopReason: response.stopReason,
252
- };
253
- }
254
-
255
- const answerText = assistantMessageText(response).trim();
256
- if (!answerText) {
257
- return { ok: false, error: ERR_EMPTY_RESPONSE, stopReason: response.stopReason };
258
- }
259
-
260
- return {
261
- ok: true,
262
- answer: answerText,
263
- userMessage,
264
- assistantMessage: response,
265
- stopReason: response.stopReason,
266
- };
267
- } catch (err) {
268
- const message = err instanceof Error ? err.message : String(err);
269
- if (controller.signal.aborted) {
270
- return { ok: false, aborted: true };
271
- }
272
- return { ok: false, error: errCallThrew(message) };
273
- }
274
- }
275
-
276
- // ---------------------------------------------------------------------------
277
- // Registrars — 3 hooks total: command + message_end snapshot + compact/tree invalidate
278
- // ---------------------------------------------------------------------------
279
-
280
- export function registerMessageEndSnapshot(pi: ExtensionAPI): void {
281
- pi.on("message_end", async (event, ctx) => {
282
- const msg = event.message;
283
- if (msg.role !== "assistant") return;
284
- if ((msg as AssistantMessage).stopReason === "toolUse") return;
285
- const branch = ctx.sessionManager.getBranch() as SessionEntry[];
286
- const agentMessages = branch
287
- .filter((e): e is SessionEntry & { type: "message" } => e.type === "message")
288
- .map((e) => e.message);
289
- setSnapshot(ctx, { messages: convertToLlm(agentMessages) });
290
- });
291
- }
292
-
293
- export function registerInvalidationHooks(pi: ExtensionAPI): void {
294
- pi.on("session_compact", async (_e, ctx) => invalidateSnapshot(ctx));
295
- pi.on("session_tree", async (_e, ctx) => invalidateSnapshot(ctx));
296
- }
297
-
298
- export function registerAskCommand(pi: ExtensionAPI): void {
299
- pi.registerCommand(ASK_COMMAND_NAME, {
300
- description: "Ask a side question without polluting the main conversation",
301
- handler: (args: string, ctx: ExtensionCommandContext) => handleAskCommand(pi, args, ctx),
302
- });
303
- }
304
-
305
- async function handleAskCommand(_pi: ExtensionAPI, args: string, ctx: ExtensionCommandContext): Promise<void> {
306
- if (!ctx.hasUI) {
307
- ctx.ui.notify(MSG_REQUIRES_INTERACTIVE, "error");
308
- return;
309
- }
310
- const question = args.trim();
311
- if (!question) {
312
- ctx.ui.notify(MSG_USAGE, "warning");
313
- return;
314
- }
315
- if (!ctx.model) {
316
- ctx.ui.notify(MSG_NO_MODEL, "error");
317
- return;
318
- }
319
-
320
- const controller = new AbortController();
321
- const historySnapshot = [...getSessionHistory(ctx)];
322
-
323
- const { overlayPromise, controllerReady } = showAskOverlay({
324
- ctx,
325
- question,
326
- history: historySnapshot,
327
- controller,
328
- onClearHistory: () => clearSessionHistory(ctx),
329
- });
330
-
331
- const overlayCtl = await controllerReady;
332
- const result = await executeAsk(question, ctx, controller);
333
-
334
- if (result.ok && result.answer && result.userMessage && result.assistantMessage) {
335
- overlayCtl.setAnswer(result.answer);
336
- pushSessionTurn(ctx, {
337
- userMessage: result.userMessage,
338
- assistantMessage: result.assistantMessage,
339
- });
340
- // No disk persistence — process-scoped only (Decision 4)
341
- } else if (result.aborted) {
342
- // User Esc'd — overlay already dismissed via done(); no further action
343
- } else if (result.error) {
344
- overlayCtl.setError(result.error);
345
- }
346
-
347
- await overlayPromise;
348
- }
@@ -1,270 +0,0 @@
1
- import type { Theme } from "@mariozechner/pi-coding-agent";
2
- import { type TUI, visibleWidth } from "@mariozechner/pi-tui";
3
- import { makeTui } from "@juicesharp/rpiv-test-utils";
4
- import { afterEach, describe, expect, it, vi } from "vitest";
5
- import type { AskTurn } from "./ask.js";
6
- import { AskOverlayController, showAskOverlay } from "./ask-ui.js";
7
-
8
- const identityTheme = {
9
- fg: (_c: string, s: string) => s,
10
- bg: (_c: string, s: string) => s,
11
- bold: (s: string) => s,
12
- strikethrough: (s: string) => s,
13
- } as unknown as Theme;
14
-
15
- function makeTurn(q: string, a = "ans"): AskTurn {
16
- return {
17
- userMessage: { role: "user", content: q, timestamp: 0 },
18
- assistantMessage: {
19
- role: "assistant",
20
- content: [{ type: "text", text: a }],
21
- api: "anthropic" as never,
22
- provider: "anthropic" as never,
23
- model: "m",
24
- usage: {} as never,
25
- stopReason: "done" as never,
26
- timestamp: 0,
27
- },
28
- };
29
- }
30
-
31
- function makeController(opts: { question?: string; history?: AskTurn[]; tui?: TUI; rows?: number } = {}) {
32
- const tui = opts.tui ?? (makeTui() as unknown as TUI);
33
- (tui as unknown as { terminal: { rows: number } }).terminal = { rows: opts.rows ?? 24 };
34
- const done = vi.fn();
35
- const controller = new AbortController();
36
- const onClearHistory = vi.fn();
37
- const ctl = new AskOverlayController(
38
- opts.question ?? "what?",
39
- opts.history ?? [],
40
- identityTheme,
41
- tui,
42
- done,
43
- controller,
44
- onClearHistory,
45
- );
46
- return { ctl, tui, done, controller, onClearHistory };
47
- }
48
-
49
- afterEach(() => {
50
- vi.restoreAllMocks();
51
- });
52
-
53
- describe("AskOverlayController — initial (pending) render", () => {
54
- it("contains the banner, echo line, pending glyph, and dismiss footer", () => {
55
- const { ctl } = makeController({ question: "hello?" });
56
- const out = ctl.render(80).join("\n");
57
- expect(out).toContain("/ask hello?");
58
- expect(out).toContain("…"); // PENDING_GLYPH
59
- expect(out).toContain("Esc to dismiss");
60
- });
61
-
62
- it("does NOT show 'scroll' or 'clear' hints when pending + no history", () => {
63
- const { ctl } = makeController({ question: "q" });
64
- const out = ctl.render(80).join("\n");
65
- expect(out).not.toContain("↑/↓ to scroll");
66
- expect(out).not.toContain("x to clear history");
67
- });
68
-
69
- it("shows 'x to clear history' hint when history is non-empty", () => {
70
- const { ctl } = makeController({ history: [makeTurn("prev")] });
71
- const out = ctl.render(80).join("\n");
72
- expect(out).toContain("x to clear history");
73
- });
74
- });
75
-
76
- describe("AskOverlayController — setAnswer", () => {
77
- it("replaces pending glyph with the answer text", () => {
78
- const { ctl, tui } = makeController();
79
- ctl.setAnswer("forty-two");
80
- const out = ctl.render(80).join("\n");
81
- expect(out).toContain("forty-two");
82
- expect(out).not.toContain("…");
83
- expect(tui.requestRender).toHaveBeenCalled();
84
- });
85
-
86
- it("enables the 'scroll' footer hint once the mode is no longer pending", () => {
87
- const { ctl } = makeController();
88
- ctl.setAnswer("a");
89
- expect(ctl.render(80).join("\n")).toContain("↑/↓ to scroll");
90
- });
91
-
92
- it("wraps multi-line answers into the answer body", () => {
93
- const { ctl } = makeController();
94
- ctl.setAnswer("line1\nline2\nline3");
95
- const out = ctl.render(80);
96
- expect(out.some((l) => l.includes("line1"))).toBe(true);
97
- expect(out.some((l) => l.includes("line2"))).toBe(true);
98
- expect(out.some((l) => l.includes("line3"))).toBe(true);
99
- });
100
- });
101
-
102
- describe("AskOverlayController — setError", () => {
103
- it("renders the error message in the answer slot", () => {
104
- const { ctl } = makeController();
105
- ctl.setError("boom: nope");
106
- const out = ctl.render(80).join("\n");
107
- expect(out).toContain("boom: nope");
108
- expect(out).not.toContain("…");
109
- });
110
- });
111
-
112
- describe("AskOverlayController — handleInput", () => {
113
- it("Esc aborts the controller and resolves done()", () => {
114
- const { ctl, controller, done } = makeController();
115
- ctl.handleInput("\u001b");
116
- expect(controller.signal.aborted).toBe(true);
117
- expect(done).toHaveBeenCalled();
118
- });
119
-
120
- it("'x' clears in-memory history and invokes onClearHistory", () => {
121
- const { ctl, onClearHistory, tui } = makeController({ history: [makeTurn("a"), makeTurn("b")] });
122
- ctl.handleInput("x");
123
- expect(onClearHistory).toHaveBeenCalledTimes(1);
124
- const out = ctl.render(80).join("\n");
125
- expect(out).not.toContain("/ask a");
126
- expect(out).not.toContain("/ask b");
127
- expect(out).not.toContain("x to clear history");
128
- expect(tui.requestRender).toHaveBeenCalled();
129
- });
130
-
131
- it("unknown keys do not abort or clear", () => {
132
- const { ctl, controller, done, onClearHistory } = makeController();
133
- ctl.handleInput("z");
134
- expect(controller.signal.aborted).toBe(false);
135
- expect(done).not.toHaveBeenCalled();
136
- expect(onClearHistory).not.toHaveBeenCalled();
137
- });
138
- });
139
-
140
- describe("AskOverlayController — scroll + clipping", () => {
141
- it("render() returns all natural lines when within maxRows", () => {
142
- const { ctl } = makeController({ rows: 100 });
143
- ctl.setAnswer("answer-body");
144
- const lines = ctl.render(80);
145
- // banner + blank + 0 history + echo + blank + 1 answer + blank + footer = 7
146
- expect(lines.length).toBe(7);
147
- });
148
-
149
- it("clips top when content overflows terminal height; scroll↑ reveals older history", () => {
150
- // Use distinct non-overlapping markers so substring matches are unambiguous.
151
- const history: AskTurn[] = Array.from({ length: 20 }, (_, i) => makeTurn(`mark-${i + 1}-end`));
152
- const { ctl } = makeController({ history, rows: 10 });
153
- ctl.setAnswer("A");
154
- const base = ctl.render(80);
155
- const maxRows = Math.floor(10 * 0.85); // 8
156
- expect(base.length).toBe(maxRows);
157
- // Bottom-anchored: footer + answer visible; earliest history hidden
158
- expect(base.join("\n")).not.toContain("mark-1-end");
159
- expect(base.join("\n")).toContain("mark-20-end");
160
- expect(base.join("\n")).toContain("Esc to dismiss");
161
- // Scroll up reveals older history at the top.
162
- ctl.handleInput("\u001b[A");
163
- const scrolled = ctl.render(80);
164
- expect(scrolled.length).toBe(maxRows);
165
- });
166
-
167
- it("scroll↓ at bottom stays clamped (no throw, still renders maxRows)", () => {
168
- const history: AskTurn[] = Array.from({ length: 20 }, (_, i) => makeTurn(`mark-${i + 1}-end`));
169
- const { ctl } = makeController({ history, rows: 10 });
170
- ctl.setAnswer("A");
171
- ctl.handleInput("\u001b[B"); // down
172
- const out = ctl.render(80);
173
- const maxRows = Math.floor(10 * 0.85);
174
- expect(out.length).toBe(maxRows);
175
- });
176
-
177
- it("invalidate() is a callable no-op", () => {
178
- const { ctl } = makeController();
179
- expect(() => ctl.invalidate()).not.toThrow();
180
- });
181
- });
182
-
183
- describe("AskOverlayController — banner + echo formatting", () => {
184
- it("banner is padded to full visible width", () => {
185
- const { ctl } = makeController({ question: "q" });
186
- const banner = ctl.render(40)[0];
187
- expect(visibleWidth(banner)).toBe(40);
188
- });
189
-
190
- it("truncates long questions in the banner with ellipsis", () => {
191
- const long = "a".repeat(200);
192
- const { ctl } = makeController({ question: long });
193
- const banner = ctl.render(40)[0];
194
- expect(visibleWidth(banner)).toBe(40);
195
- expect(banner).toContain("…");
196
- });
197
-
198
- it("history echo uses '/ask ' prefix and trims whitespace", () => {
199
- const { ctl } = makeController({ history: [makeTurn(" multi\nline q ")] });
200
- const out = ctl.render(80).join("\n");
201
- expect(out).toContain("/ask multi line q");
202
- });
203
- });
204
-
205
- describe("showAskOverlay — factory wiring", () => {
206
- it("invokes ctx.ui.custom with overlay options and resolves controllerReady with the AskOverlayController", async () => {
207
- const requestRender = vi.fn();
208
- const tui = { requestRender, terminal: { rows: 24 } } as unknown as TUI;
209
- const custom = vi.fn((factory: unknown, opts: unknown) => {
210
- const f = factory as (
211
- tui: TUI,
212
- theme: Theme,
213
- kb: undefined,
214
- done: (v: undefined) => void,
215
- ) => AskOverlayController;
216
- const ctl = f(tui, identityTheme, undefined, () => {});
217
- // Keep `opts` addressable for the assertion below.
218
- (custom as unknown as { lastOpts: unknown }).lastOpts = opts;
219
- return new Promise<void>(() => {
220
- // keep pending so we can inspect the controller
221
- void ctl;
222
- });
223
- });
224
- const ctx = { ui: { custom } } as never;
225
-
226
- const { controllerReady } = showAskOverlay({
227
- ctx,
228
- question: "q",
229
- history: [],
230
- controller: new AbortController(),
231
- onClearHistory: vi.fn(),
232
- });
233
-
234
- const ctl = await controllerReady;
235
- expect(ctl).toBeInstanceOf(AskOverlayController);
236
- expect(custom).toHaveBeenCalledTimes(1);
237
- const opts = (custom as unknown as { lastOpts: { overlay: boolean; overlayOptions: unknown } }).lastOpts;
238
- expect(opts).toMatchObject({ overlay: true });
239
- expect(opts.overlayOptions).toMatchObject({ anchor: "bottom-center" });
240
- });
241
-
242
- it("controller returned by the factory is the same one exposed via controllerReady", async () => {
243
- let factoryCtl: AskOverlayController | undefined;
244
- const custom = vi.fn((factory: unknown) => {
245
- const f = factory as (
246
- tui: TUI,
247
- theme: Theme,
248
- kb: undefined,
249
- done: (v: undefined) => void,
250
- ) => AskOverlayController;
251
- factoryCtl = f(
252
- { requestRender: vi.fn(), terminal: { rows: 24 } } as unknown as TUI,
253
- identityTheme,
254
- undefined,
255
- () => {},
256
- );
257
- return new Promise<void>(() => {});
258
- });
259
- const ctx = { ui: { custom } } as never;
260
- const { controllerReady } = showAskOverlay({
261
- ctx,
262
- question: "q",
263
- history: [],
264
- controller: new AbortController(),
265
- onClearHistory: vi.fn(),
266
- });
267
- const ctl = await controllerReady;
268
- expect(ctl).toBe(factoryCtl);
269
- });
270
- });