@oh-my-pi/pi-coding-agent 17.0.0 → 17.0.2

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 (202) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/cli.js +4006 -3966
  3. package/dist/types/advisor/advise-tool.d.ts +5 -2
  4. package/dist/types/advisor/config.d.ts +14 -6
  5. package/dist/types/advisor/runtime.d.ts +20 -11
  6. package/dist/types/autolearn/controller.d.ts +1 -0
  7. package/dist/types/cli-commands.d.ts +14 -1
  8. package/dist/types/config/model-discovery.d.ts +17 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/model-resolver.d.ts +6 -2
  11. package/dist/types/config/models-config-schema.d.ts +10 -0
  12. package/dist/types/config/models-config.d.ts +6 -0
  13. package/dist/types/config/settings-schema.d.ts +32 -7
  14. package/dist/types/config/settings.d.ts +50 -0
  15. package/dist/types/cursor.d.ts +11 -0
  16. package/dist/types/discovery/helpers.d.ts +5 -2
  17. package/dist/types/discovery/substitute-plugin-root.d.ts +20 -7
  18. package/dist/types/exec/bash-executor.d.ts +2 -0
  19. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  20. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  22. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +40 -0
  23. package/dist/types/extensibility/shared-events.d.ts +6 -0
  24. package/dist/types/extensibility/utils.d.ts +2 -2
  25. package/dist/types/internal-urls/agent-protocol.d.ts +1 -0
  26. package/dist/types/lib/xai-http.d.ts +11 -0
  27. package/dist/types/mcp/transports/stdio.d.ts +13 -1
  28. package/dist/types/modes/acp/acp-agent.d.ts +3 -0
  29. package/dist/types/modes/acp/acp-mode.d.ts +6 -2
  30. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  31. package/dist/types/modes/components/assistant-message.d.ts +2 -0
  32. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +1 -0
  33. package/dist/types/modes/components/custom-editor.d.ts +23 -1
  34. package/dist/types/modes/components/hook-editor.d.ts +16 -2
  35. package/dist/types/modes/components/login-dialog.test.d.ts +1 -0
  36. package/dist/types/modes/components/model-hub.d.ts +5 -2
  37. package/dist/types/modes/components/session-selector.d.ts +4 -0
  38. package/dist/types/modes/components/tool-execution.d.ts +8 -1
  39. package/dist/types/modes/components/tool-execution.test.d.ts +1 -0
  40. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  41. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  42. package/dist/types/modes/interactive-mode.d.ts +6 -0
  43. package/dist/types/modes/noninteractive-dispose.test.d.ts +1 -0
  44. package/dist/types/modes/print-mode.d.ts +1 -1
  45. package/dist/types/modes/types.d.ts +10 -0
  46. package/dist/types/modes/utils/transcript-render-helpers.d.ts +2 -2
  47. package/dist/types/modes/warp-events.d.ts +24 -0
  48. package/dist/types/modes/warp-events.test.d.ts +1 -0
  49. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  50. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  51. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  52. package/dist/types/sdk.d.ts +13 -2
  53. package/dist/types/session/agent-session.d.ts +43 -11
  54. package/dist/types/session/session-history-format.d.ts +10 -0
  55. package/dist/types/session/session-manager.d.ts +14 -0
  56. package/dist/types/session/streaming-output.d.ts +2 -0
  57. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  58. package/dist/types/slash-commands/types.d.ts +4 -4
  59. package/dist/types/system-prompt.d.ts +1 -1
  60. package/dist/types/task/label.d.ts +1 -1
  61. package/dist/types/telemetry-export.d.ts +34 -9
  62. package/dist/types/tools/approval.d.ts +8 -0
  63. package/dist/types/tools/bash.d.ts +2 -0
  64. package/dist/types/tools/browser/registry.d.ts +7 -3
  65. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  66. package/dist/types/tools/essential-tools.d.ts +29 -0
  67. package/dist/types/tools/image-gen.d.ts +2 -1
  68. package/dist/types/tools/index.d.ts +4 -1
  69. package/dist/types/tools/manage-skill.d.ts +2 -0
  70. package/dist/types/tools/xdev.d.ts +11 -2
  71. package/dist/types/utils/title-generator.d.ts +2 -1
  72. package/dist/types/web/search/index.d.ts +2 -0
  73. package/dist/types/web/search/providers/base.d.ts +3 -0
  74. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  75. package/dist/types/web/search/types.d.ts +1 -1
  76. package/package.json +21 -16
  77. package/src/advisor/__tests__/advisor.test.ts +1358 -86
  78. package/src/advisor/__tests__/config.test.ts +58 -2
  79. package/src/advisor/advise-tool.ts +7 -3
  80. package/src/advisor/config.ts +76 -24
  81. package/src/advisor/runtime.ts +445 -92
  82. package/src/autolearn/controller.ts +23 -28
  83. package/src/cli/bench-cli.ts +4 -1
  84. package/src/cli/grep-cli.ts +2 -1
  85. package/src/cli-commands.ts +57 -23
  86. package/src/cli.ts +5 -1
  87. package/src/config/model-discovery.ts +81 -21
  88. package/src/config/model-registry.ts +34 -6
  89. package/src/config/model-resolver.ts +57 -12
  90. package/src/config/models-config-schema.ts +1 -0
  91. package/src/config/settings-schema.ts +43 -6
  92. package/src/config/settings.ts +405 -25
  93. package/src/cursor.ts +20 -3
  94. package/src/debug/report-bundle.ts +40 -4
  95. package/src/discovery/codex.ts +14 -4
  96. package/src/discovery/helpers.ts +28 -5
  97. package/src/discovery/substitute-plugin-root.ts +23 -7
  98. package/src/exec/bash-executor.ts +14 -5
  99. package/src/export/html/template.js +2 -0
  100. package/src/extensibility/custom-tools/loader.ts +3 -3
  101. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  102. package/src/extensibility/extensions/loader.ts +3 -3
  103. package/src/extensibility/extensions/managed-timers.ts +83 -0
  104. package/src/extensibility/extensions/runner.ts +26 -0
  105. package/src/extensibility/extensions/types.ts +18 -0
  106. package/src/extensibility/extensions/wrapper.ts +2 -1
  107. package/src/extensibility/hooks/loader.ts +3 -3
  108. package/src/extensibility/legacy-pi-coding-agent-shim.ts +96 -1
  109. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  110. package/src/extensibility/plugins/manager.ts +2 -2
  111. package/src/extensibility/shared-events.ts +6 -0
  112. package/src/extensibility/utils.ts +91 -25
  113. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +73 -0
  114. package/src/internal-urls/agent-protocol.ts +73 -39
  115. package/src/irc/bus.ts +22 -3
  116. package/src/launch/broker.ts +3 -2
  117. package/src/launch/client.ts +2 -2
  118. package/src/launch/presence.ts +2 -2
  119. package/src/lib/xai-http.ts +28 -2
  120. package/src/lsp/client.ts +1 -1
  121. package/src/main.ts +11 -8
  122. package/src/mcp/manager.ts +9 -3
  123. package/src/mcp/transports/stdio.ts +103 -23
  124. package/src/modes/acp/acp-agent.ts +28 -9
  125. package/src/modes/acp/acp-event-mapper.ts +8 -0
  126. package/src/modes/acp/acp-mode.ts +18 -4
  127. package/src/modes/components/advisor-config.ts +65 -3
  128. package/src/modes/components/ask-dialog.ts +1 -1
  129. package/src/modes/components/assistant-message.ts +35 -14
  130. package/src/modes/components/chat-transcript-builder.ts +2 -0
  131. package/src/modes/components/custom-editor-plugin-ctor.test.ts +36 -0
  132. package/src/modes/components/custom-editor.ts +62 -1
  133. package/src/modes/components/hook-editor.ts +35 -5
  134. package/src/modes/components/login-dialog.test.ts +56 -0
  135. package/src/modes/components/login-dialog.ts +7 -3
  136. package/src/modes/components/model-hub.ts +138 -42
  137. package/src/modes/components/plan-review-overlay.ts +51 -10
  138. package/src/modes/components/session-selector.ts +10 -0
  139. package/src/modes/components/status-line/component.test.ts +1 -0
  140. package/src/modes/components/status-line/component.ts +2 -4
  141. package/src/modes/components/status-line/segments.ts +21 -6
  142. package/src/modes/components/tool-execution.test.ts +101 -0
  143. package/src/modes/components/tool-execution.ts +117 -9
  144. package/src/modes/components/transcript-container.ts +30 -2
  145. package/src/modes/controllers/command-controller-shared.ts +1 -1
  146. package/src/modes/controllers/command-controller.ts +168 -48
  147. package/src/modes/controllers/event-controller.ts +6 -0
  148. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  149. package/src/modes/controllers/input-controller.ts +12 -12
  150. package/src/modes/controllers/selector-controller.ts +208 -46
  151. package/src/modes/interactive-mode.ts +205 -65
  152. package/src/modes/noninteractive-dispose.test.ts +60 -0
  153. package/src/modes/print-mode.ts +10 -5
  154. package/src/modes/rpc/host-tools.ts +2 -1
  155. package/src/modes/rpc/rpc-mode.ts +31 -7
  156. package/src/modes/theme/theme.ts +2 -2
  157. package/src/modes/types.ts +10 -0
  158. package/src/modes/utils/interactive-context-helpers.ts +3 -1
  159. package/src/modes/utils/transcript-render-helpers.ts +3 -2
  160. package/src/modes/warp-events.test.ts +794 -0
  161. package/src/modes/warp-events.ts +232 -0
  162. package/src/plan-mode/model-transition.test.ts +60 -0
  163. package/src/plan-mode/model-transition.ts +51 -0
  164. package/src/prompts/system/system-prompt.md +1 -1
  165. package/src/registry/agent-lifecycle.ts +133 -18
  166. package/src/sdk.ts +227 -44
  167. package/src/session/agent-session.ts +1364 -382
  168. package/src/session/session-history-format.ts +20 -5
  169. package/src/session/session-listing.ts +6 -5
  170. package/src/session/session-loader.ts +9 -0
  171. package/src/session/session-manager.ts +48 -0
  172. package/src/session/session-persistence.ts +11 -0
  173. package/src/session/streaming-output.ts +3 -1
  174. package/src/slash-commands/builtin-registry.ts +9 -0
  175. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  176. package/src/slash-commands/types.ts +4 -4
  177. package/src/system-prompt.ts +2 -2
  178. package/src/task/executor.ts +1 -1
  179. package/src/task/label.ts +2 -0
  180. package/src/telemetry-export.ts +453 -97
  181. package/src/tools/approval.ts +11 -0
  182. package/src/tools/bash.ts +71 -38
  183. package/src/tools/browser/registry.ts +9 -2
  184. package/src/tools/browser/tab-supervisor.ts +65 -10
  185. package/src/tools/browser.ts +4 -3
  186. package/src/tools/essential-tools.ts +45 -0
  187. package/src/tools/eval.ts +3 -0
  188. package/src/tools/gh.ts +169 -2
  189. package/src/tools/image-gen.ts +624 -513
  190. package/src/tools/index.ts +10 -6
  191. package/src/tools/manage-skill.ts +5 -3
  192. package/src/tools/output-meta.ts +7 -0
  193. package/src/tools/path-utils.ts +8 -5
  194. package/src/tools/read.ts +48 -3
  195. package/src/tools/write.ts +22 -4
  196. package/src/tools/xdev.ts +14 -3
  197. package/src/utils/title-generator.ts +15 -4
  198. package/src/web/search/index.ts +21 -6
  199. package/src/web/search/providers/base.ts +3 -0
  200. package/src/web/search/providers/kimi.ts +18 -12
  201. package/src/web/search/providers/xai.ts +40 -9
  202. package/src/web/search/types.ts +6 -1
@@ -7,7 +7,7 @@
7
7
  * (Ctrl+Q / Ctrl+Enter) submits, bordered popup
8
8
  * - Prompt-style (ask): Enter submits, Shift+Enter inserts newline, legacy ask chrome
9
9
  */
10
- import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
10
+ import { Container, Editor, type Focusable, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
11
11
  import { getEditorTheme, theme } from "../../modes/theme/theme";
12
12
  import {
13
13
  matchesAppExternalEditor,
@@ -20,14 +20,24 @@ 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
- export class HookEditorComponent extends Container {
32
+ /** Interactive multiline dialog used by hooks and the ask tool's Other response. */
33
+ export class HookEditorComponent extends Container implements Focusable {
26
34
  #editor: Editor;
27
35
  #onSubmitCallback: (value: string) => void;
28
36
  #onCancelCallback: () => void;
29
37
  #tui: TUI;
30
38
  #promptStyle: boolean;
39
+ /** Focus state mirrored to the nested editor during rendering. */
40
+ focused = false;
31
41
 
32
42
  constructor(
33
43
  tui: TUI,
@@ -47,8 +57,11 @@ export class HookEditorComponent extends Container {
47
57
  this.addChild(new DynamicBorder());
48
58
  this.addChild(new Spacer(1));
49
59
 
50
- // Title
51
- 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));
52
65
  this.addChild(new Spacer(1));
53
66
 
54
67
  // Editor
@@ -58,6 +71,11 @@ export class HookEditorComponent extends Container {
58
71
  this.#editor.setPromptGutter("> ");
59
72
  this.#editor.disableSubmit = true;
60
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);
61
79
  if (prefill) {
62
80
  this.#editor.setText(prefill);
63
81
  }
@@ -69,12 +87,24 @@ export class HookEditorComponent extends Container {
69
87
  const hint = this.#promptStyle
70
88
  ? "enter or ctrl+q submit esc cancel ctrl+g external editor"
71
89
  : "ctrl+q/ctrl+enter submit esc cancel ctrl+g external editor";
72
- this.addChild(new Text(theme.fg("dim", hint), 1, 0));
90
+ this.addChild(new Text(theme.fg("dim", hint), chromePadX, 0));
73
91
 
74
92
  this.addChild(new Spacer(1));
75
93
  this.addChild(new DynamicBorder());
76
94
  }
77
95
 
96
+ /** Keep the nested editor's software/hardware cursor mode aligned with the dialog focus target. */
97
+ setUseTerminalCursor(useTerminalCursor: boolean): void {
98
+ if (this.#editor.getUseTerminalCursor() === useTerminalCursor) return;
99
+ this.#editor.setUseTerminalCursor(useTerminalCursor);
100
+ }
101
+
102
+ /** Render the dialog after forwarding its focus state to the nested editor. */
103
+ override render(width: number): readonly string[] {
104
+ this.#editor.focused = this.focused;
105
+ return super.render(width);
106
+ }
107
+
78
108
  handleInput(keyData: string): void {
79
109
  if (this.#promptStyle) {
80
110
  this.#handlePromptStyleInput(keyData);
@@ -0,0 +1,56 @@
1
+ import { beforeAll, describe, expect, it } from "bun:test";
2
+ import type { TUI } from "@oh-my-pi/pi-tui";
3
+ import { initTheme } from "../theme/theme";
4
+ import { LoginDialogComponent } from "./login-dialog";
5
+
6
+ /** Minimal TUI stub — the dialog only calls requestRender/setFocus. */
7
+ function makeDialog(): LoginDialogComponent {
8
+ const tui = { requestRender() {}, setFocus() {} } as unknown as TUI;
9
+ return new LoginDialogComponent(tui, "openai-codex", () => {});
10
+ }
11
+
12
+ describe("LoginDialogComponent manual code input", () => {
13
+ beforeAll(async () => {
14
+ await initTheme();
15
+ });
16
+
17
+ it("captures a pasted fallback redirect URL and resolves on submit", async () => {
18
+ // Regression for #5339: paste-code providers (Codex) route the fallback
19
+ // URL through the focused dialog. Without a mounted input, the paste is
20
+ // dropped and login never completes.
21
+ const dialog = makeDialog();
22
+ dialog.showProgress("Waiting for callback");
23
+
24
+ const pending = dialog.showManualInput("Paste the authorization code:");
25
+ expect(dialog.render(80).join("\n")).toContain("Paste the authorization code");
26
+
27
+ const url = "http://localhost:1455/auth/callback?code=THECODE&state=abc";
28
+ dialog.pasteText(url);
29
+ dialog.handleInput("\r");
30
+
31
+ expect(await pending).toBe(url);
32
+ });
33
+
34
+ it("reuses the mounted input across re-prompts instead of stacking duplicates", async () => {
35
+ // The OAuth callback loop re-invokes onManualCodeInput after an invalid
36
+ // paste; the second prompt must not append a duplicate input/hint block.
37
+ const dialog = makeDialog();
38
+ dialog.showProgress("Waiting for callback");
39
+
40
+ const first = dialog.showManualInput("Paste the code:");
41
+ dialog.handleInput("garbage");
42
+ dialog.handleInput("\r");
43
+ expect(await first).toBe("garbage");
44
+
45
+ const second = dialog.showManualInput("Paste the code:");
46
+ const rendered = dialog.render(80).join("\n");
47
+ expect(rendered.split("Paste the code:").length - 1).toBe(1);
48
+ // A stale value from the first attempt must not leak into the retry.
49
+ expect(rendered).not.toContain("garbage");
50
+
51
+ const url = "http://localhost:1455/auth/callback?code=OK&state=abc";
52
+ dialog.pasteText(url);
53
+ dialog.handleInput("\r");
54
+ expect(await second).toBe(url);
55
+ });
56
+ });
@@ -108,12 +108,16 @@ export class LoginDialogComponent extends Container {
108
108
  * Show input for manual code/URL entry (for callback server providers)
109
109
  */
110
110
  showManualInput(prompt: string): Promise<string> {
111
- this.#contentContainer.addChild(new Spacer(1));
112
- this.#contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0));
111
+ // Invalid pastes re-prompt (the OAuth callback loop calls this again), so
112
+ // reuse the already-mounted input instead of stacking duplicate prompt and
113
+ // hint lines beneath the dialog. Reset the value so each retry starts clean.
113
114
  if (!this.#contentContainer.children.includes(this.#input)) {
115
+ this.#contentContainer.addChild(new Spacer(1));
116
+ this.#contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0));
114
117
  this.#contentContainer.addChild(this.#input);
118
+ this.#contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0));
115
119
  }
116
- this.#contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0));
120
+ this.#input.setValue("");
117
121
  this.#tui.requestRender();
118
122
 
119
123
  const { promise, resolve, reject } = Promise.withResolvers<string>();
@@ -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) {
@@ -138,6 +138,8 @@ export class PlanReviewOverlay implements Component {
138
138
  #tocCursor = 0;
139
139
  #sidebarShown = false;
140
140
  #pendingScrollToToc = false;
141
+ /** Last meaningful relative body position, retained while a frame cannot scroll. */
142
+ #scrollProgress = 0;
141
143
 
142
144
  // Click hit-testing, rebuilt every render. Keys are 0-based rendered-line
143
145
  // indices (== screen rows, since the fullscreen overlay paints from row 0).
@@ -196,6 +198,7 @@ export class PlanReviewOverlay implements Component {
196
198
  setPlanContent(planContent: string): void {
197
199
  this.#setSections(planContent);
198
200
  this.#scrollView.scrollToTop();
201
+ this.#scrollProgress = 0;
199
202
  this.#tocCursor = 0;
200
203
  // A wholesale external-editor swap supersedes prior in-overlay deletions.
201
204
  this.#deleted = [];
@@ -343,6 +346,7 @@ export class PlanReviewOverlay implements Component {
343
346
  if (event.wheel !== null) {
344
347
  // Scroll wheel: three rows per notch.
345
348
  this.#scrollView.scroll(event.wheel * 3);
349
+ this.#captureScrollProgress();
346
350
  return true;
347
351
  }
348
352
  if (event.release) return true;
@@ -445,13 +449,21 @@ export class PlanReviewOverlay implements Component {
445
449
  // drops into the actions ("next step"); scrolling off the top steps back up
446
450
  // to the ToC.
447
451
  if (matchesSelectUp(data) || matchesKey(data, "k")) {
448
- if (this.#scrollView.getScrollOffset() <= 0 && this.#sidebarShown) this.#setFocus("toc");
449
- else this.#scrollView.scroll(-1);
452
+ if (this.#scrollView.getScrollOffset() <= 0 && this.#sidebarShown) {
453
+ this.#setFocus("toc");
454
+ } else {
455
+ this.#scrollView.scroll(-1);
456
+ this.#captureScrollProgress();
457
+ }
450
458
  return;
451
459
  }
452
460
  if (matchesSelectDown(data) || matchesKey(data, "j")) {
453
- if (this.#scrollView.getScrollOffset() >= this.#scrollView.getMaxScrollOffset()) this.#setFocus("actions");
454
- else this.#scrollView.scroll(1);
461
+ if (this.#scrollView.getScrollOffset() >= this.#scrollView.getMaxScrollOffset()) {
462
+ this.#setFocus("actions");
463
+ } else {
464
+ this.#scrollView.scroll(1);
465
+ this.#captureScrollProgress();
466
+ }
455
467
  return;
456
468
  }
457
469
  this.#handleBodyScroll(data);
@@ -464,9 +476,19 @@ export class PlanReviewOverlay implements Component {
464
476
  * before this runs, so here it only ever sees the paging/fast keys.
465
477
  */
466
478
  #handleBodyScroll(data: string): void {
467
- if (this.#scrollView.handleScrollKey(data)) return;
468
- if (data === "g") this.#scrollView.scrollToTop();
469
- else if (data === "G") this.#scrollView.scrollToBottom();
479
+ if (this.#scrollView.handleScrollKey(data)) {
480
+ if (matchesKey(data, "home")) this.#scrollProgress = 0;
481
+ else if (matchesKey(data, "end")) this.#scrollProgress = 1;
482
+ else this.#captureScrollProgress();
483
+ return;
484
+ }
485
+ if (data === "g") {
486
+ this.#scrollView.scrollToTop();
487
+ this.#scrollProgress = 0;
488
+ } else if (data === "G") {
489
+ this.#scrollView.scrollToBottom();
490
+ this.#scrollProgress = 1;
491
+ }
470
492
  }
471
493
 
472
494
  #handleToc(data: string): void {
@@ -517,7 +539,10 @@ export class PlanReviewOverlay implements Component {
517
539
  const sectionIndex = this.#toc[this.#tocCursor];
518
540
  if (sectionIndex === undefined) return;
519
541
  const offset = this.#sectionOffsets[sectionIndex];
520
- if (offset !== undefined) this.#scrollView.setScrollOffset(offset);
542
+ if (offset !== undefined) {
543
+ this.#scrollView.setScrollOffset(offset);
544
+ this.#captureScrollProgress();
545
+ }
521
546
  }
522
547
 
523
548
  /** Greatest ToC position whose section starts at or above the scroll offset. */
@@ -690,6 +715,23 @@ export class PlanReviewOverlay implements Component {
690
715
  return parts.join(sep);
691
716
  }
692
717
 
718
+ /**
719
+ * Retain relative progress across reflow frames. A non-scrollable intermediate
720
+ * frame has no meaningful offset, so it must not erase the last scroll position.
721
+ */
722
+ #captureScrollProgress(): void {
723
+ const maxOffset = this.#scrollView.getMaxScrollOffset();
724
+ if (maxOffset > 0) this.#scrollProgress = this.#scrollView.getScrollOffset() / maxOffset;
725
+ }
726
+
727
+ #layoutBody(lines: readonly string[], height: number): void {
728
+ this.#captureScrollProgress();
729
+ this.#scrollView.setLines(lines);
730
+ this.#scrollView.setHeight(height);
731
+ const maxOffset = this.#scrollView.getMaxScrollOffset();
732
+ if (maxOffset > 0) this.#scrollView.setScrollOffset(Math.round(this.#scrollProgress * maxOffset));
733
+ }
734
+
693
735
  /** Build the concatenated body lines and record each section's start row. */
694
736
  #buildBody(bodyContentWidth: number): string[] {
695
737
  const lines: string[] = [];
@@ -807,8 +849,7 @@ export class PlanReviewOverlay implements Component {
807
849
  const regionRows = Math.max(MIN_BODY_ROWS, termHeight - chrome);
808
850
 
809
851
  const bodyLines = this.#buildBody(bodyContentWidth);
810
- this.#scrollView.setLines(bodyLines);
811
- this.#scrollView.setHeight(regionRows);
852
+ this.#layoutBody(bodyLines, regionRows);
812
853
  if (this.#pendingScrollToToc) {
813
854
  this.#pendingScrollToToc = false;
814
855
  this.#scrubBodyToToc();
@@ -760,6 +760,7 @@ export class SessionSelectorComponent extends Container {
760
760
  #globalSessions: SessionInfo[] | null = null;
761
761
  #scope: "folder" | "all" = "folder";
762
762
  #toggling = false;
763
+ #inputLocked = false;
763
764
  // 0-based line where the session list begins within this component's own
764
765
  // render, captured each frame. The fullscreen picker overlay paints from
765
766
  // screen row 0, so a mouse row maps to `row - #listLineOffset` inside the
@@ -874,6 +875,14 @@ export class SessionSelectorComponent extends Container {
874
875
  setOnRequestRender(callback: () => void): void {
875
876
  this.#onRequestRender = callback;
876
877
  }
878
+ /** Ignore input after selection while the host resumes the session. */
879
+ lockInput(): void {
880
+ this.#inputLocked = true;
881
+ }
882
+ /** Re-enable input after a failed resume so the user can pick again. */
883
+ unlockInput(): void {
884
+ this.#inputLocked = false;
885
+ }
877
886
 
878
887
  /**
879
888
  * Dispose the session list explicitly: while the delete-confirmation dialog
@@ -972,6 +981,7 @@ export class SessionSelectorComponent extends Container {
972
981
  }
973
982
 
974
983
  handleInput(keyData: string): void {
984
+ if (this.#inputLocked) return;
975
985
  if (keyData.startsWith("\x1b[<")) {
976
986
  this.#handleMouse(keyData);
977
987
  return;
@@ -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: {
@@ -1334,10 +1334,8 @@ export class StatusLineComponent implements Component {
1334
1334
  return [];
1335
1335
  }
1336
1336
 
1337
- const sortedStatuses = Array.from(this.#hookStatuses.entries())
1337
+ return Array.from(this.#hookStatuses.entries())
1338
1338
  .sort(([a], [b]) => a.localeCompare(b))
1339
- .map(([, text]) => sanitizeStatusText(text));
1340
- const hookLine = sortedStatuses.join(" ");
1341
- return [truncateToWidth(hookLine, width)];
1339
+ .map(([, text]) => truncateToWidth(sanitizeStatusText(text), width));
1342
1340
  }
1343
1341
  }