@oh-my-pi/pi-coding-agent 16.4.4 → 16.4.5
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.
- package/CHANGELOG.md +33 -0
- package/dist/cli.js +3563 -3540
- package/dist/types/async/job-manager.d.ts +8 -0
- package/dist/types/config/settings-schema.d.ts +18 -8
- package/dist/types/config/settings.d.ts +3 -2
- package/dist/types/discovery/helpers.d.ts +2 -2
- package/dist/types/extensibility/extensions/types.d.ts +36 -0
- package/dist/types/irc/bus.d.ts +4 -0
- package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +1 -0
- package/dist/types/modes/components/ask-dialog.d.ts +27 -0
- package/dist/types/modes/components/index.d.ts +2 -1
- package/dist/types/modes/components/model-browser.d.ts +97 -0
- package/dist/types/modes/components/model-hub.d.ts +51 -0
- package/dist/types/modes/components/pause-screen.d.ts +43 -0
- package/dist/types/modes/components/session-selector.d.ts +13 -0
- package/dist/types/modes/components/tool-execution.d.ts +2 -0
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/shared.d.ts +1 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/session-context.d.ts +9 -0
- package/dist/types/task/executor.d.ts +26 -13
- package/dist/types/task/index.d.ts +12 -11
- package/dist/types/task/label.d.ts +4 -0
- package/dist/types/task/repair-args.d.ts +8 -8
- package/dist/types/task/types.d.ts +31 -56
- package/dist/types/tools/ask.d.ts +12 -0
- package/dist/types/tools/conflict-detect.d.ts +17 -1
- package/dist/types/tools/job.d.ts +16 -0
- package/package.json +12 -12
- package/src/async/job-manager.ts +9 -0
- package/src/commit/agentic/tools/analyze-file.ts +2 -3
- package/src/config/settings-schema.ts +17 -6
- package/src/config/settings.ts +13 -4
- package/src/discovery/helpers.ts +3 -4
- package/src/extensibility/custom-tools/loader.ts +70 -37
- package/src/extensibility/extensions/types.ts +46 -0
- package/src/irc/bus.ts +61 -20
- package/src/modes/components/__tests__/pause-screen.test.ts +143 -0
- package/src/modes/components/advisor-config.ts +30 -22
- package/src/modes/components/ask-dialog.ts +888 -0
- package/src/modes/components/index.ts +2 -1
- package/src/modes/components/model-browser.ts +688 -0
- package/src/modes/components/model-hub.ts +1655 -0
- package/src/modes/components/pause-screen.ts +208 -0
- package/src/modes/components/session-selector.ts +299 -42
- package/src/modes/components/tool-execution.ts +2 -0
- package/src/modes/controllers/event-controller.ts +8 -2
- package/src/modes/controllers/extension-ui-controller.ts +252 -5
- package/src/modes/controllers/selector-controller.ts +143 -87
- package/src/modes/controllers/tan-command-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +3 -0
- package/src/modes/shared.ts +1 -1
- package/src/modes/theme/theme.ts +3 -3
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +31 -4
- package/src/prompts/agents/scout.md +0 -1
- package/src/prompts/agents/task.md +1 -1
- package/src/prompts/system/subagent-system-prompt.md +1 -5
- package/src/prompts/system/subagent-yield-reminder.md +10 -0
- package/src/prompts/system/task-label.md +23 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/prompts/tools/task-summary.md +3 -0
- package/src/prompts/tools/task.md +17 -18
- package/src/session/agent-session.ts +2 -1
- package/src/session/session-context.test.ts +73 -0
- package/src/session/session-context.ts +43 -26
- package/src/slash-commands/builtin-registry.ts +9 -0
- package/src/task/agents.ts +2 -0
- package/src/task/executor.ts +159 -46
- package/src/task/index.ts +377 -239
- package/src/task/label.ts +38 -0
- package/src/task/render.ts +74 -22
- package/src/task/repair-args.ts +20 -31
- package/src/task/spawn-policy.test.ts +4 -4
- package/src/task/types.ts +46 -66
- package/src/tools/ask.ts +233 -40
- package/src/tools/conflict-detect.ts +102 -5
- package/src/tools/index.ts +1 -0
- package/src/tools/irc.ts +20 -11
- package/src/tools/job.ts +158 -18
- package/src/tools/write.ts +70 -6
- package/src/vibe/runtime.ts +1 -1
- package/src/web/search/providers/browser-headers.ts +30 -13
- package/dist/types/modes/components/model-selector.d.ts +0 -37
- package/dist/types/tools/bash-command-fixup.d.ts +0 -3
- package/src/modes/components/model-selector.ts +0 -1291
- package/src/tools/bash-command-fixup.ts +0 -4
|
@@ -21,6 +21,34 @@ import * as typebox from "../typebox";
|
|
|
21
21
|
import { createNoOpUIContext, resolvePath, withExitGuard } from "../utils";
|
|
22
22
|
import type { CustomToolAPI, CustomToolFactory, LoadedCustomTool, ToolLoadError } from "./types";
|
|
23
23
|
|
|
24
|
+
interface LoadToolResult {
|
|
25
|
+
tools: LoadedCustomTool[];
|
|
26
|
+
errors: ToolLoadError[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isLoadableCustomTool(value: unknown): value is LoadedCustomTool["tool"] {
|
|
30
|
+
return (
|
|
31
|
+
typeof value === "object" &&
|
|
32
|
+
value !== null &&
|
|
33
|
+
"name" in value &&
|
|
34
|
+
typeof value.name === "string" &&
|
|
35
|
+
value.name.length > 0 &&
|
|
36
|
+
"description" in value &&
|
|
37
|
+
typeof value.description === "string" &&
|
|
38
|
+
"parameters" in value &&
|
|
39
|
+
"execute" in value &&
|
|
40
|
+
typeof value.execute === "function"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function invalidToolError(path: string, index: number, source: ToolLoadError["source"]): ToolLoadError {
|
|
45
|
+
return {
|
|
46
|
+
path,
|
|
47
|
+
error: `Tool factory returned invalid tool at index ${index}: expected object with string name, string description, parameters, and execute function`,
|
|
48
|
+
source,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
24
52
|
/**
|
|
25
53
|
* Load a single tool module using native Bun import.
|
|
26
54
|
*/
|
|
@@ -29,18 +57,20 @@ async function loadTool(
|
|
|
29
57
|
cwd: string,
|
|
30
58
|
sharedApi: CustomToolAPI,
|
|
31
59
|
source?: { provider: string; providerName: string; level: "user" | "project" },
|
|
32
|
-
): Promise<
|
|
60
|
+
): Promise<LoadToolResult> {
|
|
33
61
|
const resolvedPath = resolvePath(toolPath, cwd);
|
|
34
62
|
|
|
35
63
|
// Skip declarative tool files (.md, .json) - these are metadata only, not executable modules
|
|
36
64
|
if (resolvedPath.endsWith(".md") || resolvedPath.endsWith(".json")) {
|
|
37
65
|
return {
|
|
38
|
-
tools:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
66
|
+
tools: [],
|
|
67
|
+
errors: [
|
|
68
|
+
{
|
|
69
|
+
path: toolPath,
|
|
70
|
+
error: "Declarative tool files (.md, .json) cannot be loaded as executable modules",
|
|
71
|
+
source,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
44
74
|
};
|
|
45
75
|
}
|
|
46
76
|
|
|
@@ -49,23 +79,32 @@ async function loadTool(
|
|
|
49
79
|
const factory = (module.default ?? module) as CustomToolFactory;
|
|
50
80
|
|
|
51
81
|
if (typeof factory !== "function") {
|
|
52
|
-
return { tools:
|
|
82
|
+
return { tools: [], errors: [{ path: toolPath, error: "Tool must export a default function", source }] };
|
|
53
83
|
}
|
|
54
84
|
|
|
55
|
-
const toolResult = await withExitGuard(async () => factory(sharedApi));
|
|
85
|
+
const toolResult: unknown = await withExitGuard(async () => factory(sharedApi));
|
|
56
86
|
const toolsArray = Array.isArray(toolResult) ? toolResult : [toolResult];
|
|
57
87
|
|
|
58
|
-
const loadedTools: LoadedCustomTool[] =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
tool
|
|
62
|
-
|
|
63
|
-
|
|
88
|
+
const loadedTools: LoadedCustomTool[] = [];
|
|
89
|
+
const errors: ToolLoadError[] = [];
|
|
90
|
+
for (const [index, tool] of toolsArray.entries()) {
|
|
91
|
+
if (!isLoadableCustomTool(tool)) {
|
|
92
|
+
errors.push(invalidToolError(toolPath, index, source));
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
loadedTools.push({
|
|
97
|
+
path: toolPath,
|
|
98
|
+
resolvedPath,
|
|
99
|
+
tool,
|
|
100
|
+
source,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
64
103
|
|
|
65
|
-
return { tools: loadedTools,
|
|
104
|
+
return { tools: loadedTools, errors };
|
|
66
105
|
} catch (err) {
|
|
67
106
|
const message = err instanceof Error ? err.message : String(err);
|
|
68
|
-
return { tools:
|
|
107
|
+
return { tools: [], errors: [{ path: toolPath, error: `Failed to load tool: ${message}`, source }] };
|
|
69
108
|
}
|
|
70
109
|
}
|
|
71
110
|
|
|
@@ -129,28 +168,22 @@ export class CustomToolLoader {
|
|
|
129
168
|
|
|
130
169
|
async load(pathsWithSources: ToolPathWithSource[]): Promise<void> {
|
|
131
170
|
for (const { path: toolPath, source } of pathsWithSources) {
|
|
132
|
-
const { tools: loadedTools,
|
|
171
|
+
const { tools: loadedTools, errors } = await loadTool(toolPath, this.#sharedApi.cwd, this.#sharedApi, source);
|
|
172
|
+
this.errors.push(...errors);
|
|
133
173
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
this.errors.push({
|
|
144
|
-
path: toolPath,
|
|
145
|
-
error: `Tool name "${loadedTool.tool.name}" conflicts with existing tool`,
|
|
146
|
-
source,
|
|
147
|
-
});
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
this.#seenNames.add(loadedTool.tool.name);
|
|
152
|
-
this.tools.push(loadedTool);
|
|
174
|
+
for (const loadedTool of loadedTools) {
|
|
175
|
+
// Check for name conflicts
|
|
176
|
+
if (this.#seenNames.has(loadedTool.tool.name)) {
|
|
177
|
+
this.errors.push({
|
|
178
|
+
path: toolPath,
|
|
179
|
+
error: `Tool name "${loadedTool.tool.name}" conflicts with existing tool`,
|
|
180
|
+
source,
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
153
183
|
}
|
|
184
|
+
|
|
185
|
+
this.#seenNames.add(loadedTool.tool.name);
|
|
186
|
+
this.tools.push(loadedTool);
|
|
154
187
|
}
|
|
155
188
|
}
|
|
156
189
|
}
|
|
@@ -113,6 +113,46 @@ export interface ExtensionUISelectOption {
|
|
|
113
113
|
|
|
114
114
|
export type ExtensionUISelectItem = string | ExtensionUISelectOption;
|
|
115
115
|
|
|
116
|
+
export interface ExtensionAskDialogOption {
|
|
117
|
+
label: string;
|
|
118
|
+
description?: string;
|
|
119
|
+
preview?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ExtensionAskDialogQuestion {
|
|
123
|
+
id: string;
|
|
124
|
+
question: string;
|
|
125
|
+
header?: string;
|
|
126
|
+
options: ExtensionAskDialogOption[];
|
|
127
|
+
multi?: boolean;
|
|
128
|
+
recommended?: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ExtensionAskDialogResultItem {
|
|
132
|
+
id: string;
|
|
133
|
+
question: string;
|
|
134
|
+
options: string[];
|
|
135
|
+
multi: boolean;
|
|
136
|
+
selectedOptions: string[];
|
|
137
|
+
customInput?: string;
|
|
138
|
+
note?: string;
|
|
139
|
+
timedOut?: boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface ExtensionAskDialogSubmitResult {
|
|
143
|
+
kind: "submit";
|
|
144
|
+
results: ExtensionAskDialogResultItem[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Chat-redirect result: the user chose "Chat about this" instead of
|
|
148
|
+
* answering. Distinct from `undefined` (cancel) so AskTool can hand off to
|
|
149
|
+
* the chat loop rather than aborting. */
|
|
150
|
+
export interface ExtensionAskDialogChatResult {
|
|
151
|
+
kind: "chat";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type ExtensionAskDialogResult = ExtensionAskDialogSubmitResult | ExtensionAskDialogChatResult;
|
|
155
|
+
|
|
116
156
|
export function getExtensionUISelectOptionLabel(option: ExtensionUISelectItem): string {
|
|
117
157
|
return typeof option === "string" ? option : option.label;
|
|
118
158
|
}
|
|
@@ -195,6 +235,12 @@ export interface ExtensionUIContext {
|
|
|
195
235
|
/** Show a text input dialog. */
|
|
196
236
|
input(title: string, placeholder?: string, dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined>;
|
|
197
237
|
|
|
238
|
+
/** Show the rich ask dialog when the interactive TUI surface is available. */
|
|
239
|
+
askDialog?(
|
|
240
|
+
questions: ExtensionAskDialogQuestion[],
|
|
241
|
+
dialogOptions?: ExtensionUIDialogOptions,
|
|
242
|
+
): Promise<ExtensionAskDialogResult | undefined>;
|
|
243
|
+
|
|
198
244
|
/** Show a notification to the user. */
|
|
199
245
|
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
200
246
|
|
package/src/irc/bus.ts
CHANGED
|
@@ -104,8 +104,19 @@ export class IrcBus {
|
|
|
104
104
|
): Promise<IrcDeliveryReceipt> {
|
|
105
105
|
const message: IrcMessage = { ...msg, id: Snowflake.next(), ts: Date.now() };
|
|
106
106
|
const ref = this.#registry.get(message.to);
|
|
107
|
-
if (!ref
|
|
108
|
-
return {
|
|
107
|
+
if (!ref) {
|
|
108
|
+
return {
|
|
109
|
+
to: message.to,
|
|
110
|
+
outcome: "failed",
|
|
111
|
+
error: `Unknown agent "${message.to}" — check \`irc list\` for live peers.`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (ref.status === "aborted") {
|
|
115
|
+
return {
|
|
116
|
+
to: message.to,
|
|
117
|
+
outcome: "failed",
|
|
118
|
+
error: `Agent "${message.to}" was hard-aborted and cannot be messaged or revived. Its transcript remains readable at history://${message.to}.`,
|
|
119
|
+
};
|
|
109
120
|
}
|
|
110
121
|
// Advisor refs are observability-only transcripts, never messageable peers.
|
|
111
122
|
if (ref.kind === "advisor") {
|
|
@@ -175,7 +186,7 @@ export class IrcBus {
|
|
|
175
186
|
filter: { from?: string },
|
|
176
187
|
timeoutMs: number,
|
|
177
188
|
signal?: AbortSignal,
|
|
178
|
-
options?: { drainPending?: boolean },
|
|
189
|
+
options?: { drainPending?: boolean; liveness?: { registry: AgentRegistry; senderId: string } },
|
|
179
190
|
): Promise<IrcMessage | null> {
|
|
180
191
|
if (signal?.aborted) {
|
|
181
192
|
throw signal.reason instanceof Error ? signal.reason : new Error("IRC wait aborted");
|
|
@@ -190,35 +201,49 @@ export class IrcBus {
|
|
|
190
201
|
const { promise, resolve, reject } = Promise.withResolvers<IrcMessage | null>();
|
|
191
202
|
let timer: NodeJS.Timeout | undefined;
|
|
192
203
|
let onAbort: (() => void) | undefined;
|
|
204
|
+
let unsubscribeLiveness: (() => void) | undefined;
|
|
193
205
|
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
206
|
+
const liveness = options?.liveness;
|
|
207
|
+
const livenessReason = filter.from
|
|
208
|
+
? `IRC wait aborted: agent "${filter.from}" is not running`
|
|
209
|
+
: "IRC wait aborted: no running peers remain";
|
|
210
|
+
|
|
211
|
+
const settle = (
|
|
212
|
+
outcome: { kind: "message"; msg: IrcMessage } | { kind: "timeout" } | { kind: "abort"; error: Error },
|
|
213
|
+
): void => {
|
|
214
|
+
cleanup();
|
|
215
|
+
if (outcome.kind === "message") {
|
|
216
|
+
resolve(outcome.msg);
|
|
217
|
+
} else if (outcome.kind === "timeout") {
|
|
218
|
+
resolve(null);
|
|
219
|
+
} else {
|
|
220
|
+
reject(outcome.error);
|
|
221
|
+
}
|
|
203
222
|
};
|
|
223
|
+
|
|
204
224
|
const cleanup = (): void => {
|
|
205
225
|
this.#removeWaiter(agentId, waiter);
|
|
206
226
|
clearTimeout(timer);
|
|
207
227
|
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
228
|
+
unsubscribeLiveness?.();
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const waiter: IrcWaiter = {
|
|
232
|
+
from: filter.from,
|
|
233
|
+
resolve: msg => settle({ kind: "message", msg }),
|
|
234
|
+
cancel: () => cleanup(),
|
|
208
235
|
};
|
|
209
236
|
|
|
210
237
|
if (signal) {
|
|
211
|
-
onAbort = () =>
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
238
|
+
onAbort = () =>
|
|
239
|
+
settle({
|
|
240
|
+
kind: "abort",
|
|
241
|
+
error: signal.reason instanceof Error ? signal.reason : new Error("IRC wait aborted"),
|
|
242
|
+
});
|
|
215
243
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
216
244
|
}
|
|
217
245
|
if (timeoutMs > 0) {
|
|
218
|
-
timer = setTimeout(() => {
|
|
219
|
-
cleanup();
|
|
220
|
-
resolve(null);
|
|
221
|
-
}, timeoutMs);
|
|
246
|
+
timer = setTimeout(() => settle({ kind: "timeout" }), timeoutMs);
|
|
222
247
|
timer.unref?.();
|
|
223
248
|
}
|
|
224
249
|
|
|
@@ -228,6 +253,22 @@ export class IrcBus {
|
|
|
228
253
|
this.#waiters.set(agentId, waiters);
|
|
229
254
|
}
|
|
230
255
|
waiters.push(waiter);
|
|
256
|
+
|
|
257
|
+
if (liveness) {
|
|
258
|
+
const { registry, senderId } = liveness;
|
|
259
|
+
const hasRunningSender = (from?: string): boolean =>
|
|
260
|
+
registry.listVisibleTo(senderId).some(ref => ref.status === "running" && (!from || ref.id === from));
|
|
261
|
+
const check = filter.from ? () => hasRunningSender(filter.from) : () => hasRunningSender();
|
|
262
|
+
unsubscribeLiveness = registry.onChange(() => {
|
|
263
|
+
if (!check()) {
|
|
264
|
+
settle({ kind: "abort", error: new Error(livenessReason) });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
if (!check()) {
|
|
268
|
+
settle({ kind: "abort", error: new Error(livenessReason) });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
231
272
|
return promise;
|
|
232
273
|
}
|
|
233
274
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, it } from "bun:test";
|
|
2
|
+
import { agentPauseGate } from "@oh-my-pi/pi-agent-core";
|
|
3
|
+
import type { Component } from "@oh-my-pi/pi-tui";
|
|
4
|
+
import { Settings } from "../../../config/settings";
|
|
5
|
+
import { getThemeByName, setThemeInstance } from "../../theme/theme";
|
|
6
|
+
import { PauseScreenComponent, type PauseScreenHost, renderPauseScreen, runPauseScreen } from "../pause-screen";
|
|
7
|
+
|
|
8
|
+
// Strip SGR colors so assertions see visible text only.
|
|
9
|
+
const stripAnsi = (text: string): string => text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
10
|
+
|
|
11
|
+
interface FakeHost {
|
|
12
|
+
host: PauseScreenHost;
|
|
13
|
+
shown: Component[];
|
|
14
|
+
statuses: string[];
|
|
15
|
+
hiddenCount(): number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeHost(rows = 24): FakeHost {
|
|
19
|
+
const shown: Component[] = [];
|
|
20
|
+
const statuses: string[] = [];
|
|
21
|
+
let hidden = 0;
|
|
22
|
+
const host: PauseScreenHost = {
|
|
23
|
+
ui: {
|
|
24
|
+
showOverlay(component) {
|
|
25
|
+
shown.push(component);
|
|
26
|
+
return {
|
|
27
|
+
hide: () => {
|
|
28
|
+
hidden++;
|
|
29
|
+
},
|
|
30
|
+
setHidden() {},
|
|
31
|
+
isHidden: () => false,
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
setFocus() {},
|
|
35
|
+
requestRender() {},
|
|
36
|
+
terminal: { rows },
|
|
37
|
+
},
|
|
38
|
+
showStatus(message) {
|
|
39
|
+
statuses.push(message);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
return { host, shown, statuses, hiddenCount: () => hidden };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("pause screen", () => {
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
await Settings.init({ inMemory: true });
|
|
48
|
+
const loaded = await getThemeByName("dark");
|
|
49
|
+
if (!loaded) throw new Error("theme unavailable");
|
|
50
|
+
setThemeInstance(loaded);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
// The gate is process-global: never leak an engaged pause into other files.
|
|
55
|
+
agentPauseGate.resume();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("renderPauseScreen", () => {
|
|
59
|
+
it("paints exactly the requested rows with title, explainer, clock, and hint", () => {
|
|
60
|
+
const lines = renderPauseScreen(80, 24, 65_000);
|
|
61
|
+
expect(lines.length).toBe(24);
|
|
62
|
+
const text = lines.map(stripAnsi).join("\n");
|
|
63
|
+
expect(text).toContain("P A U S E D");
|
|
64
|
+
expect(text).toContain("Main agent, subagents, and advisor");
|
|
65
|
+
expect(text).toContain("paused for 1:05");
|
|
66
|
+
expect(text).toContain("esc · enter · space — resume");
|
|
67
|
+
expect(text).toContain("█".repeat(5));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("drops to the compact card on small terminals", () => {
|
|
71
|
+
const lines = renderPauseScreen(40, 10, 3_000);
|
|
72
|
+
expect(lines.length).toBe(10);
|
|
73
|
+
const text = lines.map(stripAnsi).join("\n");
|
|
74
|
+
expect(text).toContain("▌▌ P A U S E D");
|
|
75
|
+
expect(text).toContain("paused for 0:03");
|
|
76
|
+
expect(text).toContain("esc to resume");
|
|
77
|
+
expect(text).not.toContain("█".repeat(5)); // no room for the big glyph
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rolls the clock into hours past 60 minutes", () => {
|
|
81
|
+
const text = renderPauseScreen(80, 24, 3_725_000).map(stripAnsi).join("\n");
|
|
82
|
+
expect(text).toContain("paused for 1:02:05");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("displays the session name when provided in full mode", () => {
|
|
86
|
+
const lines = renderPauseScreen(80, 24, 65_000, "My Awesome Session");
|
|
87
|
+
const text = lines.map(stripAnsi).join("\n");
|
|
88
|
+
expect(text).toContain("My Awesome Session");
|
|
89
|
+
expect(text).toContain("P A U S E D");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("displays the session name when provided in compact mode", () => {
|
|
93
|
+
const lines = renderPauseScreen(40, 10, 3_000, "Compact Session Title");
|
|
94
|
+
const text = lines.map(stripAnsi).join("\n");
|
|
95
|
+
expect(text).toContain("Compact Session Title");
|
|
96
|
+
expect(text).toContain("▌▌ P A U S E D");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("runPauseScreen", () => {
|
|
101
|
+
it("engages the gate for the screen's lifetime and releases it on escape", async () => {
|
|
102
|
+
const { host, shown, statuses, hiddenCount } = makeHost();
|
|
103
|
+
expect(agentPauseGate.paused).toBe(false);
|
|
104
|
+
|
|
105
|
+
const run = runPauseScreen(host);
|
|
106
|
+
await Bun.sleep(1);
|
|
107
|
+
expect(agentPauseGate.paused).toBe(true);
|
|
108
|
+
expect(shown.length).toBe(1);
|
|
109
|
+
|
|
110
|
+
const component = shown[0];
|
|
111
|
+
expect(component).toBeInstanceOf(PauseScreenComponent);
|
|
112
|
+
if (component instanceof PauseScreenComponent) {
|
|
113
|
+
component.handleInput("\x1b"); // escape → resume
|
|
114
|
+
}
|
|
115
|
+
await run;
|
|
116
|
+
|
|
117
|
+
expect(agentPauseGate.paused).toBe(false);
|
|
118
|
+
expect(hiddenCount()).toBe(1);
|
|
119
|
+
expect(statuses.some(message => message.includes("Resumed after"))).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("treats ctrl+c as resume, never as abort-and-stay-paused", async () => {
|
|
123
|
+
const { host, shown } = makeHost();
|
|
124
|
+
const run = runPauseScreen(host);
|
|
125
|
+
await Bun.sleep(1);
|
|
126
|
+
|
|
127
|
+
const component = shown[0];
|
|
128
|
+
if (component instanceof PauseScreenComponent) {
|
|
129
|
+
component.handleInput("\x03"); // ctrl+c
|
|
130
|
+
}
|
|
131
|
+
await run;
|
|
132
|
+
expect(agentPauseGate.paused).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("is a no-op when the gate is already engaged elsewhere", async () => {
|
|
136
|
+
agentPauseGate.pause();
|
|
137
|
+
const { host, shown } = makeHost();
|
|
138
|
+
await runPauseScreen(host); // must resolve immediately, not park
|
|
139
|
+
expect(shown.length).toBe(0);
|
|
140
|
+
expect(agentPauseGate.paused).toBe(true); // foreign pause not stolen
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -40,7 +40,7 @@ import { formatModelSelectorValue } from "../../config/model-resolver";
|
|
|
40
40
|
import type { Settings } from "../../config/settings";
|
|
41
41
|
import { getSelectListTheme, theme } from "../theme/theme";
|
|
42
42
|
import { HookEditorComponent } from "./hook-editor";
|
|
43
|
-
import {
|
|
43
|
+
import { buildBrowserItems, ModelBrowser, sortModelItems } from "./model-browser";
|
|
44
44
|
import {
|
|
45
45
|
bottomBorder,
|
|
46
46
|
divider,
|
|
@@ -461,27 +461,35 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
461
461
|
}
|
|
462
462
|
|
|
463
463
|
#showModelPicker(index: number): void {
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
this.#
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
464
|
+
const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? [];
|
|
465
|
+
let models: ReadonlyArray<Model>;
|
|
466
|
+
if (this.#scopedModels.length > 0) {
|
|
467
|
+
models = this.#scopedModels.map(scoped => scoped.model);
|
|
468
|
+
} else {
|
|
469
|
+
try {
|
|
470
|
+
models = this.#modelRegistry.getAvailable();
|
|
471
|
+
} catch {
|
|
472
|
+
models = [];
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const items = buildBrowserItems(models);
|
|
476
|
+
sortModelItems(items, { mruOrder });
|
|
477
|
+
|
|
478
|
+
const picker = new ModelBrowser(this.#settings, {});
|
|
479
|
+
picker.setMruOrder(mruOrder);
|
|
480
|
+
picker.setItems(items);
|
|
481
|
+
picker.onActivate = item => {
|
|
482
|
+
const efforts = getSupportedEfforts(item.model);
|
|
483
|
+
if (efforts.length === 0) {
|
|
484
|
+
this.#doc.advisors[index].model = item.selector;
|
|
485
|
+
this.#dirty = true;
|
|
486
|
+
this.#showDetail(index);
|
|
487
|
+
} else {
|
|
488
|
+
this.#showThinkingPicker(index, item.selector, efforts);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
picker.onCancel = () => this.#showDetail(index);
|
|
492
|
+
this.#setScreen("model", picker, "Type to search · Enter / click twice picks · Esc back");
|
|
485
493
|
}
|
|
486
494
|
|
|
487
495
|
#showThinkingPicker(index: number, selector: string, efforts: readonly string[]): void {
|