@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/cli.js +3563 -3540
  3. package/dist/types/async/job-manager.d.ts +8 -0
  4. package/dist/types/config/settings-schema.d.ts +18 -8
  5. package/dist/types/config/settings.d.ts +3 -2
  6. package/dist/types/discovery/helpers.d.ts +2 -2
  7. package/dist/types/extensibility/extensions/types.d.ts +36 -0
  8. package/dist/types/irc/bus.d.ts +4 -0
  9. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +1 -0
  10. package/dist/types/modes/components/ask-dialog.d.ts +27 -0
  11. package/dist/types/modes/components/index.d.ts +2 -1
  12. package/dist/types/modes/components/model-browser.d.ts +97 -0
  13. package/dist/types/modes/components/model-hub.d.ts +51 -0
  14. package/dist/types/modes/components/pause-screen.d.ts +43 -0
  15. package/dist/types/modes/components/session-selector.d.ts +13 -0
  16. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  17. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  18. package/dist/types/modes/interactive-mode.d.ts +1 -0
  19. package/dist/types/modes/shared.d.ts +1 -1
  20. package/dist/types/modes/types.d.ts +2 -0
  21. package/dist/types/session/agent-session.d.ts +1 -1
  22. package/dist/types/session/session-context.d.ts +9 -0
  23. package/dist/types/task/executor.d.ts +26 -13
  24. package/dist/types/task/index.d.ts +12 -11
  25. package/dist/types/task/label.d.ts +4 -0
  26. package/dist/types/task/repair-args.d.ts +8 -8
  27. package/dist/types/task/types.d.ts +31 -56
  28. package/dist/types/tools/ask.d.ts +12 -0
  29. package/dist/types/tools/conflict-detect.d.ts +17 -1
  30. package/dist/types/tools/job.d.ts +16 -0
  31. package/package.json +12 -12
  32. package/src/async/job-manager.ts +9 -0
  33. package/src/commit/agentic/tools/analyze-file.ts +2 -3
  34. package/src/config/settings-schema.ts +17 -6
  35. package/src/config/settings.ts +13 -4
  36. package/src/discovery/helpers.ts +3 -4
  37. package/src/extensibility/custom-tools/loader.ts +70 -37
  38. package/src/extensibility/extensions/types.ts +46 -0
  39. package/src/irc/bus.ts +61 -20
  40. package/src/modes/components/__tests__/pause-screen.test.ts +143 -0
  41. package/src/modes/components/advisor-config.ts +30 -22
  42. package/src/modes/components/ask-dialog.ts +888 -0
  43. package/src/modes/components/index.ts +2 -1
  44. package/src/modes/components/model-browser.ts +688 -0
  45. package/src/modes/components/model-hub.ts +1655 -0
  46. package/src/modes/components/pause-screen.ts +208 -0
  47. package/src/modes/components/session-selector.ts +299 -42
  48. package/src/modes/components/tool-execution.ts +2 -0
  49. package/src/modes/controllers/event-controller.ts +8 -2
  50. package/src/modes/controllers/extension-ui-controller.ts +252 -5
  51. package/src/modes/controllers/selector-controller.ts +143 -87
  52. package/src/modes/controllers/tan-command-controller.ts +1 -1
  53. package/src/modes/interactive-mode.ts +3 -0
  54. package/src/modes/shared.ts +1 -1
  55. package/src/modes/theme/theme.ts +3 -3
  56. package/src/modes/types.ts +2 -0
  57. package/src/modes/utils/ui-helpers.ts +31 -4
  58. package/src/prompts/agents/scout.md +0 -1
  59. package/src/prompts/agents/task.md +1 -1
  60. package/src/prompts/system/subagent-system-prompt.md +1 -5
  61. package/src/prompts/system/subagent-yield-reminder.md +10 -0
  62. package/src/prompts/system/task-label.md +23 -0
  63. package/src/prompts/tools/job.md +1 -1
  64. package/src/prompts/tools/task-summary.md +3 -0
  65. package/src/prompts/tools/task.md +17 -18
  66. package/src/session/agent-session.ts +2 -1
  67. package/src/session/session-context.test.ts +73 -0
  68. package/src/session/session-context.ts +43 -26
  69. package/src/slash-commands/builtin-registry.ts +9 -0
  70. package/src/task/agents.ts +2 -0
  71. package/src/task/executor.ts +159 -46
  72. package/src/task/index.ts +377 -239
  73. package/src/task/label.ts +38 -0
  74. package/src/task/render.ts +74 -22
  75. package/src/task/repair-args.ts +20 -31
  76. package/src/task/spawn-policy.test.ts +4 -4
  77. package/src/task/types.ts +46 -66
  78. package/src/tools/ask.ts +233 -40
  79. package/src/tools/conflict-detect.ts +102 -5
  80. package/src/tools/index.ts +1 -0
  81. package/src/tools/irc.ts +20 -11
  82. package/src/tools/job.ts +158 -18
  83. package/src/tools/write.ts +70 -6
  84. package/src/vibe/runtime.ts +1 -1
  85. package/src/web/search/providers/browser-headers.ts +30 -13
  86. package/dist/types/modes/components/model-selector.d.ts +0 -37
  87. package/dist/types/tools/bash-command-fixup.d.ts +0 -3
  88. package/src/modes/components/model-selector.ts +0 -1291
  89. 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<{ tools: LoadedCustomTool[] | null; error: ToolLoadError | null }> {
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: null,
39
- error: {
40
- path: toolPath,
41
- error: "Declarative tool files (.md, .json) cannot be loaded as executable modules",
42
- source,
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: null, error: { path: toolPath, error: "Tool must export a default function", source } };
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[] = toolsArray.map(tool => ({
59
- path: toolPath,
60
- resolvedPath,
61
- tool,
62
- source,
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, error: null };
104
+ return { tools: loadedTools, errors };
66
105
  } catch (err) {
67
106
  const message = err instanceof Error ? err.message : String(err);
68
- return { tools: null, error: { path: toolPath, error: `Failed to load tool: ${message}`, source } };
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, error } = await loadTool(toolPath, this.#sharedApi.cwd, this.#sharedApi, source);
171
+ const { tools: loadedTools, errors } = await loadTool(toolPath, this.#sharedApi.cwd, this.#sharedApi, source);
172
+ this.errors.push(...errors);
133
173
 
134
- if (error) {
135
- this.errors.push(error);
136
- continue;
137
- }
138
-
139
- if (loadedTools) {
140
- for (const loadedTool of loadedTools) {
141
- // Check for name conflicts
142
- if (this.#seenNames.has(loadedTool.tool.name)) {
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 || ref.status === "aborted") {
108
- return { to: message.to, outcome: "failed", error: `Unknown or terminated agent "${message.to}".` };
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 waiter: IrcWaiter = {
195
- from: filter.from,
196
- resolve: msg => {
197
- cleanup();
198
- resolve(msg);
199
- },
200
- cancel: () => {
201
- cleanup();
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
- cleanup();
213
- reject(signal.reason instanceof Error ? signal.reason : new Error("IRC wait aborted"));
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 { ModelSelectorComponent } from "./model-selector";
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 picker = new ModelSelectorComponent(
465
- this.#tui,
466
- undefined,
467
- this.#settings,
468
- this.#modelRegistry,
469
- this.#scopedModels,
470
- (model, _role, _thinking, selector) => {
471
- const base = selector ?? `${model.provider}/${model.id}`;
472
- const efforts = getSupportedEfforts(model);
473
- if (efforts.length === 0) {
474
- this.#doc.advisors[index].model = base;
475
- this.#dirty = true;
476
- this.#showDetail(index);
477
- } else {
478
- this.#showThinkingPicker(index, base, efforts);
479
- }
480
- },
481
- () => this.#showDetail(index),
482
- { directSelect: true, pickerHint: "Pick this advisor's model · Enter / click select · Esc back" },
483
- );
484
- this.#setScreen("model", picker, "Type to search · Enter / click pick model · Esc back");
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 {