@oh-my-pi/pi-coding-agent 17.0.1 → 17.0.3

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 (179) hide show
  1. package/CHANGELOG.md +162 -40
  2. package/dist/cli.js +4559 -4543
  3. package/dist/types/advisor/config.d.ts +14 -6
  4. package/dist/types/advisor/runtime.d.ts +20 -11
  5. package/dist/types/autolearn/controller.d.ts +1 -0
  6. package/dist/types/config/model-discovery.d.ts +17 -0
  7. package/dist/types/config/model-resolver.d.ts +6 -2
  8. package/dist/types/config/settings-schema.d.ts +39 -10
  9. package/dist/types/config/settings.d.ts +50 -0
  10. package/dist/types/cursor.d.ts +11 -0
  11. package/dist/types/discovery/helpers.d.ts +5 -2
  12. package/dist/types/eval/agent-bridge.d.ts +7 -19
  13. package/dist/types/exec/bash-executor.d.ts +2 -0
  14. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  15. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  16. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  17. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +55 -1
  18. package/dist/types/extensibility/shared-events.d.ts +6 -0
  19. package/dist/types/extensibility/utils.d.ts +2 -2
  20. package/dist/types/lsp/client.d.ts +2 -0
  21. package/dist/types/lsp/types.d.ts +3 -0
  22. package/dist/types/mcp/transports/stdio.d.ts +42 -1
  23. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  24. package/dist/types/modes/components/agent-hub.d.ts +15 -0
  25. package/dist/types/modes/components/hook-editor.d.ts +7 -0
  26. package/dist/types/modes/components/model-hub.d.ts +5 -2
  27. package/dist/types/modes/components/session-selector.d.ts +2 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  30. package/dist/types/modes/print-mode.d.ts +1 -1
  31. package/dist/types/modes/warp-events.d.ts +24 -0
  32. package/dist/types/modes/warp-events.test.d.ts +1 -0
  33. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  34. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  35. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  36. package/dist/types/registry/persisted-agents.d.ts +3 -0
  37. package/dist/types/sdk.d.ts +27 -5
  38. package/dist/types/session/agent-session.d.ts +34 -10
  39. package/dist/types/session/session-entries.d.ts +6 -1
  40. package/dist/types/session/session-history-format.d.ts +10 -0
  41. package/dist/types/session/session-manager.d.ts +19 -0
  42. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  43. package/dist/types/task/executor.d.ts +26 -1
  44. package/dist/types/task/index.d.ts +1 -1
  45. package/dist/types/task/label.d.ts +1 -1
  46. package/dist/types/task/parallel.d.ts +14 -0
  47. package/dist/types/task/structured-subagent.d.ts +111 -0
  48. package/dist/types/task/types.d.ts +51 -0
  49. package/dist/types/telemetry-export.d.ts +34 -9
  50. package/dist/types/tools/approval.d.ts +8 -0
  51. package/dist/types/tools/bash.d.ts +2 -0
  52. package/dist/types/tools/essential-tools.d.ts +29 -0
  53. package/dist/types/tools/fetch.d.ts +4 -5
  54. package/dist/types/tools/hub/messaging.d.ts +5 -1
  55. package/dist/types/tools/image-gen.d.ts +2 -1
  56. package/dist/types/tools/index.d.ts +17 -1
  57. package/dist/types/tools/todo.d.ts +31 -0
  58. package/dist/types/tools/xdev.d.ts +11 -2
  59. package/dist/types/tui/tree-list.d.ts +7 -0
  60. package/dist/types/utils/markit.d.ts +11 -0
  61. package/dist/types/utils/title-generator.d.ts +2 -1
  62. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  63. package/dist/types/web/search/types.d.ts +1 -1
  64. package/package.json +21 -16
  65. package/src/advisor/__tests__/advisor.test.ts +1304 -42
  66. package/src/advisor/__tests__/config.test.ts +58 -2
  67. package/src/advisor/config.ts +76 -24
  68. package/src/advisor/runtime.ts +445 -92
  69. package/src/autolearn/controller.ts +23 -28
  70. package/src/cli/file-processor.ts +1 -2
  71. package/src/cli.ts +5 -1
  72. package/src/config/model-discovery.ts +81 -21
  73. package/src/config/model-registry.ts +25 -6
  74. package/src/config/model-resolver.ts +14 -7
  75. package/src/config/settings-schema.ts +46 -9
  76. package/src/config/settings.ts +405 -25
  77. package/src/cursor.ts +20 -3
  78. package/src/debug/report-bundle.ts +40 -4
  79. package/src/discovery/helpers.ts +28 -5
  80. package/src/eval/__tests__/agent-bridge.test.ts +133 -47
  81. package/src/eval/__tests__/prelude-agent.test.ts +29 -0
  82. package/src/eval/agent-bridge.ts +104 -477
  83. package/src/eval/jl/prelude.jl +7 -6
  84. package/src/eval/js/shared/prelude.txt +5 -4
  85. package/src/eval/py/prelude.py +11 -39
  86. package/src/eval/rb/prelude.rb +5 -6
  87. package/src/exec/bash-executor.ts +14 -5
  88. package/src/extensibility/custom-tools/loader.ts +3 -3
  89. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  90. package/src/extensibility/extensions/loader.ts +3 -3
  91. package/src/extensibility/extensions/managed-timers.ts +83 -0
  92. package/src/extensibility/extensions/runner.ts +26 -0
  93. package/src/extensibility/extensions/types.ts +18 -0
  94. package/src/extensibility/extensions/wrapper.ts +2 -1
  95. package/src/extensibility/hooks/loader.ts +3 -3
  96. package/src/extensibility/legacy-pi-coding-agent-shim.ts +149 -8
  97. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  98. package/src/extensibility/plugins/manager.ts +2 -2
  99. package/src/extensibility/shared-events.ts +6 -0
  100. package/src/extensibility/utils.ts +91 -25
  101. package/src/irc/bus.ts +22 -3
  102. package/src/launch/broker.ts +3 -2
  103. package/src/launch/client.ts +2 -2
  104. package/src/launch/presence.ts +2 -2
  105. package/src/lsp/client.ts +58 -1
  106. package/src/lsp/index.ts +62 -6
  107. package/src/lsp/types.ts +3 -0
  108. package/src/main.ts +11 -8
  109. package/src/mcp/manager.ts +9 -3
  110. package/src/mcp/oauth-flow.ts +20 -0
  111. package/src/mcp/transports/stdio.test.ts +269 -1
  112. package/src/mcp/transports/stdio.ts +255 -24
  113. package/src/modes/components/advisor-config.ts +65 -3
  114. package/src/modes/components/agent-hub.ts +1 -72
  115. package/src/modes/components/ask-dialog.ts +1 -1
  116. package/src/modes/components/bash-execution.ts +7 -3
  117. package/src/modes/components/eval-execution.ts +3 -1
  118. package/src/modes/components/hook-editor.ts +18 -3
  119. package/src/modes/components/model-hub.ts +138 -42
  120. package/src/modes/components/session-selector.ts +4 -0
  121. package/src/modes/components/status-line/component.test.ts +1 -0
  122. package/src/modes/components/status-line/segments.ts +21 -6
  123. package/src/modes/controllers/command-controller.ts +167 -47
  124. package/src/modes/controllers/event-controller.ts +5 -0
  125. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  126. package/src/modes/controllers/input-controller.ts +12 -12
  127. package/src/modes/controllers/selector-controller.ts +191 -31
  128. package/src/modes/interactive-mode.ts +169 -62
  129. package/src/modes/print-mode.ts +3 -3
  130. package/src/modes/rpc/host-tools.ts +2 -1
  131. package/src/modes/rpc/rpc-mode.ts +19 -4
  132. package/src/modes/warp-events.test.ts +794 -0
  133. package/src/modes/warp-events.ts +232 -0
  134. package/src/plan-mode/model-transition.test.ts +60 -0
  135. package/src/plan-mode/model-transition.ts +51 -0
  136. package/src/prompts/system/system-prompt.md +1 -1
  137. package/src/prompts/tools/eval.md +5 -2
  138. package/src/prompts/tools/read.md +1 -1
  139. package/src/prompts/tools/task.md +12 -8
  140. package/src/prompts/tools/write.md +1 -1
  141. package/src/registry/agent-lifecycle.ts +133 -18
  142. package/src/registry/persisted-agents.ts +74 -0
  143. package/src/sdk.ts +343 -122
  144. package/src/session/agent-session.ts +1359 -368
  145. package/src/session/session-entries.ts +6 -1
  146. package/src/session/session-history-format.ts +20 -5
  147. package/src/session/session-manager.ts +57 -0
  148. package/src/session/streaming-output.ts +41 -1
  149. package/src/slash-commands/builtin-registry.ts +7 -0
  150. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  151. package/src/system-prompt.ts +7 -2
  152. package/src/task/executor.ts +100 -22
  153. package/src/task/index.ts +258 -429
  154. package/src/task/label.ts +2 -0
  155. package/src/task/parallel.ts +43 -0
  156. package/src/task/persisted-revive.ts +19 -7
  157. package/src/task/structured-subagent.ts +642 -0
  158. package/src/task/types.ts +58 -0
  159. package/src/telemetry-export.ts +453 -97
  160. package/src/tools/__tests__/eval-description.test.ts +1 -1
  161. package/src/tools/approval.ts +11 -0
  162. package/src/tools/bash.ts +71 -38
  163. package/src/tools/essential-tools.ts +45 -0
  164. package/src/tools/fetch.ts +28 -105
  165. package/src/tools/gh.ts +169 -2
  166. package/src/tools/hub/messaging.ts +16 -3
  167. package/src/tools/image-gen.ts +69 -7
  168. package/src/tools/index.ts +50 -15
  169. package/src/tools/path-utils.ts +1 -0
  170. package/src/tools/read.ts +62 -29
  171. package/src/tools/todo.ts +126 -13
  172. package/src/tools/write.ts +22 -4
  173. package/src/tools/xdev.ts +14 -3
  174. package/src/tui/tree-list.ts +39 -0
  175. package/src/utils/markit.ts +12 -0
  176. package/src/utils/title-generator.ts +15 -4
  177. package/src/utils/zip.ts +16 -1
  178. package/src/web/search/providers/kimi.ts +18 -12
  179. package/src/web/search/types.ts +6 -1
@@ -16,7 +16,7 @@
16
16
  * `save` callback.
17
17
  */
18
18
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
19
- import type { Model } from "@oh-my-pi/pi-ai";
19
+ import type { Model, UsageReport } from "@oh-my-pi/pi-ai";
20
20
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
21
21
  import {
22
22
  type Component,
@@ -38,6 +38,9 @@ import {
38
38
  import type { ModelRegistry } from "../../config/model-registry";
39
39
  import { formatModelSelectorValue } from "../../config/model-resolver";
40
40
  import type { Settings } from "../../config/settings";
41
+ import type { PerAdvisorStat } from "../../session/agent-session";
42
+ import type { OAuthAccountIdentity } from "../../session/auth-storage";
43
+ import { formatCompactQuota } from "../controllers/command-controller";
41
44
  import { getSelectListTheme, theme } from "../theme/theme";
42
45
  import { HookEditorComponent } from "./hook-editor";
43
46
  import { buildBrowserItems, ModelBrowser, sortModelItems } from "./model-browser";
@@ -63,6 +66,11 @@ export interface AdvisorConfigCallbacks {
63
66
  requestRender: () => void;
64
67
  /** Surface a transient status/warning line to the user. */
65
68
  notify: (message: string) => void;
69
+ /** Live advisor usage stats; lets the preview show tokens/cost per advisor. */
70
+ getAdvisorStats?: () => PerAdvisorStat[];
71
+ getUsageReports?: () => Promise<UsageReport[] | null>;
72
+ /** Resolve the active OAuth identity for quota filtering (per-advisor account stickiness). */
73
+ resolveActiveAccount?: (provider: string, sessionId?: string) => OAuthAccountIdentity | undefined;
66
74
  }
67
75
 
68
76
  export interface AdvisorConfigDeps {
@@ -126,6 +134,8 @@ export class AdvisorConfigOverlayComponent implements Component {
126
134
  #cb: AdvisorConfigCallbacks;
127
135
  #scope: AdvisorConfigScope;
128
136
  #doc: WatchdogConfigDoc;
137
+ /** Cached usage reports (quota/window/reset) prefetched on overlay open. */
138
+ #cachedReports: UsageReport[] | null = null;
129
139
  #dirty = false;
130
140
 
131
141
  #screen: Screen = "list";
@@ -157,6 +167,16 @@ export class AdvisorConfigOverlayComponent implements Component {
157
167
  this.#doc = doc;
158
168
  this.#ensureRosterVisible();
159
169
  this.#showList();
170
+ // Prefetch usage reports for quota display; non-fatal if unavailable.
171
+ if (callbacks.getUsageReports) {
172
+ void callbacks
173
+ .getUsageReports()
174
+ .then(r => {
175
+ this.#cachedReports = r;
176
+ this.#cb.requestRender();
177
+ })
178
+ .catch(() => {});
179
+ }
160
180
  }
161
181
 
162
182
  // ───────────────────────────── render ─────────────────────────────
@@ -275,6 +295,7 @@ export class AdvisorConfigOverlayComponent implements Component {
275
295
  const lines = [
276
296
  theme.bold(advisor.name || "(unnamed)"),
277
297
  "",
298
+ `${theme.fg("dim", "Enabled:")} ${advisor.enabled === false ? "○ off" : "● on"}`,
278
299
  `${theme.fg("dim", "Model:")} ${model}`,
279
300
  `${theme.fg("dim", "Tools:")} ${tools}`,
280
301
  "",
@@ -282,6 +303,34 @@ export class AdvisorConfigOverlayComponent implements Component {
282
303
  ];
283
304
  const instr = advisor.instructions?.trim();
284
305
  lines.push(...(instr ? wrap(instr, bodyWidth) : [theme.fg("muted", "(none)")]));
306
+ // Show live usage stats when available from the session.
307
+ const liveStat = this.#cb.getAdvisorStats?.()?.find(s => s.name === (advisor.name || "default"));
308
+ if (liveStat && (liveStat.status === "running" || liveStat.status === "quota_exhausted")) {
309
+ lines.push("", theme.fg("dim", "Usage:"));
310
+ const spendParts: string[] = [
311
+ `${liveStat.tokens.input.toLocaleString()} in`,
312
+ `${liveStat.tokens.output.toLocaleString()} out`,
313
+ ];
314
+ if (liveStat.tokens.cacheRead > 0) spendParts.push(`${liveStat.tokens.cacheRead.toLocaleString()} cache`);
315
+ lines.push(theme.fg("dim", ` Tokens: ${spendParts.join(", ")}`));
316
+ if (liveStat.cost > 0) lines.push(theme.fg("dim", ` Cost: $${liveStat.cost.toFixed(4)}`));
317
+ if (liveStat.contextWindow > 0) {
318
+ const pct = Math.round((liveStat.contextTokens / liveStat.contextWindow) * 100);
319
+ lines.push(
320
+ theme.fg(
321
+ "dim",
322
+ ` Context: ${liveStat.contextTokens.toLocaleString()}/${liveStat.contextWindow.toLocaleString()} (${pct}%)`,
323
+ ),
324
+ );
325
+ }
326
+ }
327
+ const quotaProvider =
328
+ (advisor.model?.includes("/") ? advisor.model.split("/")[0] : null) ?? liveStat?.model?.provider;
329
+ if (this.#cachedReports && quotaProvider) {
330
+ const activeAccount = this.#cb.resolveActiveAccount?.(quotaProvider, liveStat?.sessionId);
331
+ const quota = formatCompactQuota(quotaProvider, this.#cachedReports, Date.now(), activeAccount);
332
+ if (quota) lines.push(theme.fg("dim", ` ${quota}`));
333
+ }
285
334
  return lines.map(line => truncateToWidth(line, bodyWidth));
286
335
  }
287
336
 
@@ -311,7 +360,8 @@ export class AdvisorConfigOverlayComponent implements Component {
311
360
  advisor.name === "default" &&
312
361
  !advisor.model?.trim() &&
313
362
  advisor.tools === undefined &&
314
- !advisor.instructions?.trim()
363
+ !advisor.instructions?.trim() &&
364
+ advisor.enabled !== false
315
365
  );
316
366
  }
317
367
 
@@ -325,7 +375,7 @@ export class AdvisorConfigOverlayComponent implements Component {
325
375
  this.#ensureRosterVisible();
326
376
  const items: SelectItem[] = this.#doc.advisors.map((advisor, index) => ({
327
377
  value: `advisor:${index}`,
328
- label: advisor.name || "(unnamed)",
378
+ label: `${advisor.enabled === false ? "○" : "●"} ${advisor.name || "(unnamed)"}`,
329
379
  description: this.#advisorSummary(advisor),
330
380
  }));
331
381
  items.push({ value: "add", label: "+ Add advisor" });
@@ -395,6 +445,11 @@ export class AdvisorConfigOverlayComponent implements Component {
395
445
  const toolsDescription = formatAdvisorTools(advisor.tools, "no tools");
396
446
  const items: SelectItem[] = [
397
447
  { value: "name", label: "Name", description: advisor.name },
448
+ {
449
+ value: "toggleEnabled",
450
+ label: "Enabled",
451
+ description: advisor.enabled === false ? "○ off" : "● on",
452
+ },
398
453
  { value: "model", label: "Model", description: modelDescription },
399
454
  ];
400
455
  if (advisor.model?.trim()) {
@@ -414,6 +469,13 @@ export class AdvisorConfigOverlayComponent implements Component {
414
469
 
415
470
  #onDetailSelect(index: number, field: string): void {
416
471
  switch (field) {
472
+ case "toggleEnabled": {
473
+ const a = this.#doc.advisors[index];
474
+ a.enabled = a.enabled === false ? undefined : false;
475
+ this.#dirty = true;
476
+ this.#showDetail(index);
477
+ return;
478
+ }
417
479
  case "name":
418
480
  this.#showNameEditor(index);
419
481
  return;
@@ -13,17 +13,15 @@
13
13
  *
14
14
  * Replaces the old SessionObserverOverlayComponent (ctrl+s observer).
15
15
  */
16
- import * as fs from "node:fs";
17
- import * as path from "node:path";
18
16
  import { type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
19
17
  import { Container, Ellipsis, matchesKey, type OverlayHandle, padding, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
20
18
  import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils";
21
- import { ADVISOR_TRANSCRIPT_FILENAME, isAdvisorTranscriptName } from "../../advisor";
22
19
  import type { KeyId } from "../../config/keybindings";
23
20
  import type { MessageRenderer } from "../../extensibility/extensions/types";
24
21
  import { IrcBus } from "../../irc/bus";
25
22
  import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
26
23
  import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
24
+ import { registerPersistedSubagents } from "../../registry/persisted-agents";
27
25
  import { USER_INTERRUPT_LABEL } from "../../session/messages";
28
26
  import { parseThinkingLevel } from "../../thinking";
29
27
  import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
@@ -100,75 +98,6 @@ function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): str
100
98
  return formatModelBadge(selector.slice(selector.indexOf("/") + 1), level);
101
99
  }
102
100
 
103
- async function registerPersistedSubagents(
104
- registry: AgentRegistry,
105
- sessionFile: string | null | undefined,
106
- ): Promise<void> {
107
- if (!sessionFile?.endsWith(".jsonl")) return;
108
- const root = sessionFile.slice(0, -6);
109
- await registerPersistedSubagentsFromDir(registry, root, undefined);
110
- }
111
-
112
- async function registerPersistedSubagentsFromDir(
113
- registry: AgentRegistry,
114
- dir: string,
115
- parentId: string | undefined,
116
- ): Promise<void> {
117
- let entries: fs.Dirent[];
118
- try {
119
- entries = await fs.promises.readdir(dir, { withFileTypes: true });
120
- } catch {
121
- return;
122
- }
123
- for (const entry of entries) {
124
- if (!entry.isFile() || !entry.name.endsWith(".jsonl") || entry.name.includes(".bak")) continue;
125
- const sessionFile = path.join(dir, entry.name);
126
- // The advisor transcript is observability-only: register it as a non-peer
127
- // `advisor` kind under its owning session so the Hub can show its read-only
128
- // transcript, but it never joins agent-facing rosters and is not revivable.
129
- if (isAdvisorTranscriptName(entry.name)) {
130
- const owner = parentId ?? MAIN_AGENT_ID;
131
- // `__advisor.jsonl` → the default advisor (no slug); `__advisor.<slug>.jsonl`
132
- // → a named advisor, keyed and labeled by its slug.
133
- const slug =
134
- entry.name === ADVISOR_TRANSCRIPT_FILENAME ? "" : entry.name.slice("__advisor.".length, -".jsonl".length);
135
- const advisorId = slug ? `${owner}/advisor:${slug}` : `${owner}/advisor`;
136
- const displayName = slug ? `advisor:${slug}` : "advisor";
137
- const existing = registry.get(advisorId);
138
- // Never clobber a non-advisor ref that happens to share this id (a freak
139
- // user task literally named `<owner>/advisor`): leave it, skip the advisor.
140
- if (existing && existing.kind !== "advisor") continue;
141
- if (existing?.sessionFile !== sessionFile) {
142
- // The id is reused across `/new`; refresh it to the current session's file.
143
- if (existing) registry.unregister(advisorId);
144
- registry.register({
145
- id: advisorId,
146
- displayName,
147
- kind: "advisor",
148
- parentId: owner,
149
- session: null,
150
- sessionFile,
151
- status: "parked",
152
- });
153
- }
154
- continue;
155
- }
156
- const id = entry.name.slice(0, -6);
157
- if (!registry.get(id)) {
158
- registry.register({
159
- id,
160
- displayName: id,
161
- kind: "sub",
162
- parentId: parentId ?? MAIN_AGENT_ID,
163
- session: null,
164
- sessionFile,
165
- status: "parked",
166
- });
167
- }
168
- await registerPersistedSubagentsFromDir(registry, path.join(dir, id), id);
169
- }
170
- }
171
-
172
101
  /** Result of one host-backed transcript read for the Agent Hub viewer. */
173
102
  export interface AgentHubRemoteTranscript {
174
103
  text: string;
@@ -556,7 +556,7 @@ export class AskDialogComponent implements Component {
556
556
  }
557
557
  const isEnter = matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n";
558
558
  const isSpace = matchesKey(keyData, "space") || keyData === " ";
559
- if (!isEnter && !isSpace) return;
559
+ if (!isEnter && !(question.multi && isSpace)) return;
560
560
  if (rowItem.kind === "other") {
561
561
  void this.#promptForCustomInput(question, state, rowItem);
562
562
  return;
@@ -146,14 +146,18 @@ export class BashExecutionComponent extends Container {
146
146
  #updateDisplay(): void {
147
147
  const availableLines = this.#outputLines;
148
148
 
149
- // Apply preview truncation based on expanded state
149
+ // Full output is shown when expanded or when sixel passthrough renders
150
+ // the raw payload; the collapsed preview shows only the tail window.
150
151
  const previewLogicalLines = availableLines.slice(-PREVIEW_LINES);
151
- const hiddenLineCount = availableLines.length - previewLogicalLines.length;
152
152
  const sixelLineMask =
153
153
  TERMINAL.imageProtocol === ImageProtocol.Sixel && isSixelPassthroughEnabled()
154
154
  ? getSixelLineMask(availableLines)
155
155
  : undefined;
156
156
  const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
157
+ const showingAllLines = this.#expanded || hasSixelOutput;
158
+ // Only the collapsed preview hides lines; when the full output is shown
159
+ // the footer must not keep advertising hidden lines / ctrl+o.
160
+ const hiddenLineCount = showingAllLines ? 0 : availableLines.length - previewLogicalLines.length;
157
161
 
158
162
  // Rebuild content container
159
163
  this.#contentContainer.clear();
@@ -163,7 +167,7 @@ export class BashExecutionComponent extends Container {
163
167
 
164
168
  // Output
165
169
  if (availableLines.length > 0) {
166
- if (this.#expanded || hasSixelOutput) {
170
+ if (showingAllLines) {
167
171
  const displayText = availableLines
168
172
  .map((line, index) => (sixelLineMask?.[index] ? line : theme.fg("muted", line)))
169
173
  .join("\n");
@@ -114,7 +114,9 @@ export class EvalExecutionComponent extends Container {
114
114
  #updateDisplay(): void {
115
115
  const availableLines = this.#outputLines;
116
116
  const previewLogicalLines = availableLines.slice(-PREVIEW_LINES);
117
- const hiddenLineCount = availableLines.length - previewLogicalLines.length;
117
+ // Only the collapsed preview hides lines; when expanded the footer must
118
+ // not keep advertising hidden lines / ctrl+o.
119
+ const hiddenLineCount = this.#expanded ? 0 : availableLines.length - previewLogicalLines.length;
118
120
 
119
121
  this.#contentContainer.clear();
120
122
 
@@ -20,6 +20,13 @@ import { DynamicBorder } from "./dynamic-border";
20
20
  export interface HookEditorOptions {
21
21
  /** When true, use prompt-style keybindings with the legacy ask prompt chrome. */
22
22
  promptStyle?: boolean;
23
+ /**
24
+ * Max rows the inner Editor may occupy. When omitted, the editor is
25
+ * bounded to the current terminal height minus the component's chrome
26
+ * (≈10 rows) so long content scrolls instead of pushing the submit
27
+ * hint out of view.
28
+ */
29
+ maxHeight?: number;
23
30
  }
24
31
 
25
32
  /** Interactive multiline dialog used by hooks and the ask tool's Other response. */
@@ -50,8 +57,11 @@ export class HookEditorComponent extends Container implements Focusable {
50
57
  this.addChild(new DynamicBorder());
51
58
  this.addChild(new Spacer(1));
52
59
 
53
- // Title
54
- this.addChild(new Text(theme.fg("accent", title), 1, 0));
60
+ // Title. Prompt-style renders the borderless editor's `> ` gutter at
61
+ // column 0, so pad the title to match; hook-style keeps the 1-col indent
62
+ // that lines up with its bordered editor body (#5313).
63
+ const chromePadX = this.#promptStyle ? 0 : 1;
64
+ this.addChild(new Text(theme.fg("accent", title), chromePadX, 0));
55
65
  this.addChild(new Spacer(1));
56
66
 
57
67
  // Editor
@@ -61,6 +71,11 @@ export class HookEditorComponent extends Container implements Focusable {
61
71
  this.#editor.setPromptGutter("> ");
62
72
  this.#editor.disableSubmit = true;
63
73
  }
74
+ // Bound the editor so long content scrolls instead of pushing the
75
+ // submit hint off-screen. Caller may override via options.maxHeight.
76
+ const termRows = this.#tui.terminal?.rows ?? process.stdout.rows ?? 40;
77
+ this.#editor.setMaxHeight(options?.maxHeight ?? Math.max(3, termRows - 12));
78
+ this.#editor.setScrollbarVisible(true);
64
79
  if (prefill) {
65
80
  this.#editor.setText(prefill);
66
81
  }
@@ -72,7 +87,7 @@ export class HookEditorComponent extends Container implements Focusable {
72
87
  const hint = this.#promptStyle
73
88
  ? "enter or ctrl+q submit esc cancel ctrl+g external editor"
74
89
  : "ctrl+q/ctrl+enter submit esc cancel ctrl+g external editor";
75
- this.addChild(new Text(theme.fg("dim", hint), 1, 0));
90
+ this.addChild(new Text(theme.fg("dim", hint), chromePadX, 0));
76
91
 
77
92
  this.addChild(new Spacer(1));
78
93
  this.addChild(new DynamicBorder());
@@ -28,6 +28,7 @@ import {
28
28
  visibleWidth,
29
29
  } from "@oh-my-pi/pi-tui";
30
30
  import type { ModelRegistry } from "../../config/model-registry";
31
+ import { type ModelRoleLookup, type ResolvedModelRoleValue, resolveModelRoleValue } from "../../config/model-resolver";
31
32
  import { getKnownRoleIds, getRoleInfo } from "../../config/model-roles";
32
33
  import type { Settings } from "../../config/settings";
33
34
  import { AUTO_THINKING, type ConfiguredThinkingLevel, getConfiguredThinkingLevelMetadata } from "../../thinking";
@@ -75,11 +76,19 @@ export interface ScopedModelItem {
75
76
  thinkingLevel?: string;
76
77
  }
77
78
 
79
+ export type ModelRoleSelectionScope = "global" | "project";
80
+
78
81
  export interface ModelHubCallbacks {
79
82
  /** Persist a role assignment. */
80
- onAssign: (model: Model, role: string, thinkingLevel: ConfiguredThinkingLevel | undefined, selector: string) => void;
83
+ onAssign: (
84
+ model: Model,
85
+ role: string,
86
+ thinkingLevel: ConfiguredThinkingLevel | undefined,
87
+ selector: string,
88
+ scope?: ModelRoleSelectionScope,
89
+ ) => void;
81
90
  /** Clear a configured role back to auto-selection. */
82
- onUnassign: (role: string) => void;
91
+ onUnassign: (role: string, scope?: ModelRoleSelectionScope) => void;
83
92
  /** Persist a `retry.fallbackChains` entry — keyed by a role, `provider/model-id`, or `provider/*`; an empty chain clears the key. */
84
93
  onFallbackChainChange?: (role: string, chain: string[]) => void;
85
94
  /** Locked provider activation: forward to the /login flow. */
@@ -111,18 +120,20 @@ interface StripChip {
111
120
  /** Pre-styled label body (without selection decoration). */
112
121
  styled: string;
113
122
  role?: string;
114
- action: "assign" | "unassign" | "fallback" | "fallbackModel" | "fallbackProvider" | "thinking";
123
+ action: "assign" | "unassign" | "fallback" | "fallbackModel" | "fallbackProvider" | "scope" | "thinking";
115
124
  thinkingLevel?: ConfiguredThinkingLevel;
125
+ scope?: ModelRoleSelectionScope;
116
126
  }
117
127
 
118
128
  type StripState =
119
129
  | {
120
- kind: "role" | "thinking";
130
+ kind: "role" | "scope" | "thinking";
121
131
  item: ModelBrowserItem;
122
132
  role?: string;
133
+ scope?: ModelRoleSelectionScope;
123
134
  chips: StripChip[];
124
135
  index: number;
125
- /** Where to land when a thinking strip closes. */
136
+ /** Where to land when a scope or thinking strip closes. */
126
137
  returnToRoles: boolean;
127
138
  }
128
139
  | {
@@ -556,6 +567,11 @@ export class ModelHubComponent implements Component {
556
567
  this.#tui.requestRender();
557
568
  }
558
569
 
570
+ /** Re-sync after an asynchronous callback finishes mutating settings. */
571
+ refreshAfterExternalMutation(): void {
572
+ this.#refreshAfterMutation();
573
+ }
574
+
559
575
  /**
560
576
  * Recompute per-provider match counts for the active query. Providers
561
577
  * without matches gray out and the scope hop skips them; a provider scope
@@ -745,23 +761,55 @@ export class ModelHubComponent implements Component {
745
761
  this.#openRoleStrip(item);
746
762
  }
747
763
 
764
+ #roleForScope(role: string, scope: ModelRoleSelectionScope): ResolvedModelRoleValue {
765
+ const roleValue =
766
+ scope === "project" ? this.#settings.getProjectModelRole(role) : this.#settings.getGlobalModelRole(role);
767
+ const allModels =
768
+ this.#scopedModels.length > 0 ? this.#scopedModels.map(scoped => scoped.model) : this.#registry.getAll();
769
+ const roleLookup: ModelRoleLookup = {
770
+ getModelRole: scopedRole =>
771
+ scope === "project"
772
+ ? (this.#settings.getProjectModelRole(scopedRole) ?? this.#settings.getGlobalModelRole(scopedRole))
773
+ : this.#settings.getGlobalModelRole(scopedRole),
774
+ };
775
+ return resolveModelRoleValue(roleValue, allModels, { settings: this.#settings, roleLookup });
776
+ }
777
+
778
+ #thinkingLevelForScope(role: string, scope: ModelRoleSelectionScope): ConfiguredThinkingLevel {
779
+ const resolved = this.#roleForScope(role, scope);
780
+ return resolved.explicitThinkingLevel ? (resolved.thinkingLevel ?? ThinkingLevel.Inherit) : ThinkingLevel.Inherit;
781
+ }
782
+
748
783
  /** Persist `role → item`, preserving a still-supported thinking level, then open the thinking strip. */
749
- #assignRole(item: ModelBrowserItem, role: string, returnToRoles: boolean): void {
784
+ #assignRole(item: ModelBrowserItem, role: string, returnToRoles: boolean, scope?: ModelRoleSelectionScope): void {
785
+ if (this.#settings.get("modelRoleStorage") === "project" && scope === undefined) {
786
+ this.#openScopeStrip(item, role, returnToRoles);
787
+ return;
788
+ }
789
+
750
790
  const current = this.#roles[role];
751
791
  let level: ConfiguredThinkingLevel = ThinkingLevel.Inherit;
752
- if (current && !current.autoSelected) {
753
- const supported = this.#thinkingOptionsFor(item.model);
754
- level = supported.includes(current.thinkingLevel) ? current.thinkingLevel : ThinkingLevel.Inherit;
755
- }
756
- this.#callbacks.onAssign(item.model, role, level, item.selector);
792
+ if (this.#settings.get("modelRoleStorage") === "project" && scope !== undefined) {
793
+ level = this.#thinkingLevelForScope(role, scope);
794
+ } else if (current && !current.autoSelected) {
795
+ level = current.thinkingLevel;
796
+ }
797
+ const supported = this.#thinkingOptionsFor(item.model);
798
+ if (!supported.includes(level)) level = ThinkingLevel.Inherit;
799
+ this.#callbacks.onAssign(item.model, role, level, item.selector, scope);
757
800
  this.#refreshAfterMutation();
758
- this.#openThinkingStrip(item, role, returnToRoles);
801
+ this.#openThinkingStrip(item, role, returnToRoles, scope);
759
802
  }
760
803
 
761
804
  #unassignRole(role: string): void {
762
805
  const assignment = this.#roles[role];
763
806
  if (!assignment || assignment.autoSelected) return;
764
- this.#callbacks.onUnassign(role);
807
+ if (this.#settings.get("modelRoleStorage") === "project") {
808
+ const source = this.#settings.getModelRoleSource(role);
809
+ this.#callbacks.onUnassign(role, source === "default" ? undefined : source);
810
+ } else {
811
+ this.#callbacks.onUnassign(role);
812
+ }
765
813
  this.#refreshAfterMutation();
766
814
  }
767
815
 
@@ -771,24 +819,32 @@ export class ModelHubComponent implements Component {
771
819
 
772
820
  #openRoleStrip(item: ModelBrowserItem): void {
773
821
  const chips: StripChip[] = [];
822
+ const scopedStorage = this.#settings.get("modelRoleStorage") === "project";
823
+ const scopes: readonly ModelRoleSelectionScope[] = scopedStorage ? ["project", "global"] : ["global"];
774
824
  for (const role of this.#visibleRoleIds()) {
775
825
  const info = getRoleInfo(role, this.#settings);
776
826
  const assignment = this.#roles[role];
777
- const assignedHere =
778
- !!assignment &&
779
- !assignment.autoSelected &&
780
- assignment.model.provider === item.model.provider &&
781
- assignment.model.id === item.model.id;
782
- const label = (info.tag ?? info.name ?? role).toLowerCase();
783
- chips.push({
784
- label,
785
- styled: assignedHere
786
- ? theme.fg(info.color ?? "muted", `${theme.status.enabled}${label}`) +
787
- theme.fg("dim", ` ${theme.status.success}`)
788
- : theme.fg(info.color ?? "muted", label),
789
- role,
790
- action: assignedHere ? "unassign" : "assign",
791
- });
827
+ for (const scope of scopes) {
828
+ const scopedModel = scopedStorage
829
+ ? this.#roleForScope(role, scope).model
830
+ : assignment && !assignment.autoSelected
831
+ ? assignment.model
832
+ : undefined;
833
+ const assignedHere =
834
+ !!scopedModel && scopedModel.provider === item.model.provider && scopedModel.id === item.model.id;
835
+ const roleLabel = (info.tag ?? info.name ?? role).toLowerCase();
836
+ const label = scopedStorage ? `${scope} ${roleLabel}` : roleLabel;
837
+ chips.push({
838
+ label,
839
+ styled: assignedHere
840
+ ? theme.fg(info.color ?? "muted", `${theme.status.enabled}${label}`) +
841
+ theme.fg("dim", ` ${theme.status.success}`)
842
+ : theme.fg(info.color ?? "muted", label),
843
+ role,
844
+ scope,
845
+ action: assignedHere ? "unassign" : "assign",
846
+ });
847
+ }
792
848
  }
793
849
  chips.push({
794
850
  label: `fallbacks:${item.model.id}`,
@@ -804,9 +860,25 @@ export class ModelHubComponent implements Component {
804
860
  this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false };
805
861
  }
806
862
 
807
- #openThinkingStrip(item: ModelBrowserItem, role: string, returnToRoles: boolean): void {
863
+ #openScopeStrip(item: ModelBrowserItem, role: string, returnToRoles: boolean): void {
864
+ const chips: StripChip[] = [
865
+ { label: "project", styled: theme.fg("accent", "project"), action: "scope", scope: "project" },
866
+ { label: "global", styled: theme.fg("muted", "global"), action: "scope", scope: "global" },
867
+ ];
868
+ this.#strip = { kind: "scope", item, role, chips, index: 0, returnToRoles };
869
+ }
870
+
871
+ #openThinkingStrip(
872
+ item: ModelBrowserItem,
873
+ role: string,
874
+ returnToRoles: boolean,
875
+ scope?: ModelRoleSelectionScope,
876
+ ): void {
808
877
  const options = this.#thinkingOptionsFor(item.model);
809
- const current = this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit;
878
+ const current =
879
+ this.#settings.get("modelRoleStorage") === "project" && scope !== undefined
880
+ ? this.#thinkingLevelForScope(role, scope)
881
+ : (this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit);
810
882
  const chips: StripChip[] = options.map(level => {
811
883
  const label = getConfiguredThinkingLevelMetadata(level).label;
812
884
  const glyph = thinkingLevelGlyph(level);
@@ -822,6 +894,7 @@ export class ModelHubComponent implements Component {
822
894
  kind: "thinking",
823
895
  item,
824
896
  role,
897
+ scope,
825
898
  chips,
826
899
  index: preselect >= 0 ? preselect : 0,
827
900
  returnToRoles,
@@ -832,7 +905,7 @@ export class ModelHubComponent implements Component {
832
905
  const strip = this.#strip;
833
906
  this.#strip = null;
834
907
  this.#chipRanges = [];
835
- if (strip?.kind === "thinking" && strip.returnToRoles) {
908
+ if ((strip?.kind === "scope" || strip?.kind === "thinking") && strip.returnToRoles) {
836
909
  this.#setActiveEntry("roles");
837
910
  this.#focus = "list";
838
911
  }
@@ -847,12 +920,16 @@ export class ModelHubComponent implements Component {
847
920
  case "assign":
848
921
  if (chip.role) {
849
922
  this.#strip = null;
850
- this.#assignRole(strip.item, chip.role, false);
923
+ this.#assignRole(strip.item, chip.role, false, chip.scope);
851
924
  }
852
925
  return;
853
926
  case "unassign":
854
927
  if (chip.role) {
855
- this.#callbacks.onUnassign(chip.role);
928
+ if (this.#settings.get("modelRoleStorage") === "project") {
929
+ this.#callbacks.onUnassign(chip.role, chip.scope);
930
+ } else {
931
+ this.#callbacks.onUnassign(chip.role);
932
+ }
856
933
  this.#refreshAfterMutation();
857
934
  }
858
935
  this.#closeStrip();
@@ -869,9 +946,21 @@ export class ModelHubComponent implements Component {
869
946
  this.#closeStrip();
870
947
  this.#startAssignFallback(`${strip.item.model.provider}/*`, null);
871
948
  return;
949
+ case "scope":
950
+ if (strip.role && chip.scope) {
951
+ this.#strip = null;
952
+ this.#assignRole(strip.item, strip.role, strip.returnToRoles, chip.scope);
953
+ }
954
+ return;
872
955
  case "thinking":
873
956
  if (strip.role && chip.thinkingLevel !== undefined) {
874
- this.#callbacks.onAssign(strip.item.model, strip.role, chip.thinkingLevel, strip.item.selector);
957
+ this.#callbacks.onAssign(
958
+ strip.item.model,
959
+ strip.role,
960
+ chip.thinkingLevel,
961
+ strip.item.selector,
962
+ strip.scope,
963
+ );
875
964
  this.#refreshAfterMutation();
876
965
  }
877
966
  this.#closeStrip();
@@ -1305,13 +1394,20 @@ export class ModelHubComponent implements Component {
1305
1394
  if (printable === "t") {
1306
1395
  const assignment = role ? this.#roles[role] : undefined;
1307
1396
  if (role && assignment) {
1397
+ const source =
1398
+ this.#settings.get("modelRoleStorage") === "project"
1399
+ ? this.#settings.getModelRoleSource(role)
1400
+ : "default";
1401
+ const scope = source === "project" || source === "global" ? source : undefined;
1402
+ const scopedModel = scope ? this.#roleForScope(role, scope).model : assignment.model;
1403
+ if (!scopedModel) return;
1308
1404
  const item: ModelBrowserItem = {
1309
- provider: assignment.model.provider,
1310
- id: assignment.model.id,
1311
- model: assignment.model,
1312
- selector: `${assignment.model.provider}/${assignment.model.id}`,
1405
+ provider: scopedModel.provider,
1406
+ id: scopedModel.id,
1407
+ model: scopedModel,
1408
+ selector: `${scopedModel.provider}/${scopedModel.id}`,
1313
1409
  };
1314
- this.#openThinkingStrip(item, role, true);
1410
+ this.#openThinkingStrip(item, role, true, scope);
1315
1411
  }
1316
1412
  return;
1317
1413
  }
@@ -1770,9 +1866,9 @@ export class ModelHubComponent implements Component {
1770
1866
  if (strip.kind === "roleName") {
1771
1867
  return "Enter create + pick model · Esc cancel";
1772
1868
  }
1773
- return strip.kind === "role"
1774
- ? "←/→ choose · Enter assign/clear · Esc cancel"
1775
- : "←/→ thinking level · Enter apply · Esc keep";
1869
+ if (strip.kind === "role") return "←/→ choose · Enter assign/clear · Esc cancel";
1870
+ if (strip.kind === "scope") return "←/→ save scope · Enter choose · Esc cancel";
1871
+ return "←/→ thinking level · Enter apply · Esc keep";
1776
1872
  }
1777
1873
  if (this.#assigning !== null) {
1778
1874
  switch (this.#assigning.kind) {
@@ -879,6 +879,10 @@ export class SessionSelectorComponent extends Container {
879
879
  lockInput(): void {
880
880
  this.#inputLocked = true;
881
881
  }
882
+ /** Re-enable input after a failed resume so the user can pick again. */
883
+ unlockInput(): void {
884
+ this.#inputLocked = false;
885
+ }
882
886
 
883
887
  /**
884
888
  * Dispose the session list explicitly: while the delete-confirmation dialog
@@ -36,6 +36,7 @@ function makeSessionWithLastMessage(lastMessage: unknown, prewalkArmed: boolean
36
36
  getPrewalkState: () => (prewalkArmed ? { target: { id: "cheap-model", provider: "openai" } } : undefined),
37
37
  getAsyncJobSnapshot: () => undefined,
38
38
  isAdvisorActive: () => false,
39
+ getAdvisorStatusOverview: () => ({ configured: false, advisors: [] }),
39
40
  isFastModeActive: () => false,
40
41
  configuredThinkingLevel: () => undefined,
41
42
  modelRegistry: {