@oh-my-pi/pi-coding-agent 15.10.1 → 15.10.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 (102) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/types/cli/startup-cwd.d.ts +2 -0
  3. package/dist/types/commands/launch.d.ts +3 -0
  4. package/dist/types/config/keybindings.d.ts +2 -2
  5. package/dist/types/config/model-provider-priority.d.ts +1 -0
  6. package/dist/types/config/model-resolver.d.ts +4 -1
  7. package/dist/types/config/settings.d.ts +7 -2
  8. package/dist/types/debug/report-bundle.d.ts +3 -0
  9. package/dist/types/edit/file-snapshot-store.d.ts +18 -10
  10. package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
  11. package/dist/types/extensibility/extensions/types.d.ts +4 -1
  12. package/dist/types/lsp/client.d.ts +10 -0
  13. package/dist/types/main.d.ts +3 -9
  14. package/dist/types/mcp/tool-bridge.d.ts +2 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  16. package/dist/types/modes/components/status-line.d.ts +2 -0
  17. package/dist/types/modes/controllers/event-controller.d.ts +17 -0
  18. package/dist/types/modes/interactive-mode.d.ts +1 -0
  19. package/dist/types/modes/magic-keywords.d.ts +1 -1
  20. package/dist/types/modes/markdown-prose.d.ts +1 -1
  21. package/dist/types/modes/types.d.ts +3 -0
  22. package/dist/types/modes/workflow.d.ts +3 -3
  23. package/dist/types/session/auth-storage.d.ts +1 -1
  24. package/dist/types/session/session-manager.d.ts +5 -2
  25. package/dist/types/task/executor.d.ts +10 -0
  26. package/dist/types/tools/eval.d.ts +8 -0
  27. package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
  28. package/dist/types/tools/github-cache.d.ts +12 -0
  29. package/dist/types/tools/path-utils.d.ts +8 -0
  30. package/dist/types/tools/search.d.ts +2 -2
  31. package/dist/types/tools/yield.d.ts +8 -0
  32. package/package.json +9 -9
  33. package/src/cli/args.ts +3 -1
  34. package/src/cli/dry-balance-cli.ts +2 -4
  35. package/src/cli/startup-cwd.ts +68 -0
  36. package/src/commands/launch.ts +3 -0
  37. package/src/commit/model-selection.ts +3 -2
  38. package/src/config/model-provider-priority.ts +55 -0
  39. package/src/config/model-registry.ts +4 -22
  40. package/src/config/model-resolver.ts +39 -7
  41. package/src/config/settings.ts +86 -41
  42. package/src/debug/index.ts +8 -0
  43. package/src/debug/raw-sse-buffer.ts +7 -4
  44. package/src/debug/report-bundle.ts +9 -0
  45. package/src/edit/file-snapshot-store.ts +33 -1
  46. package/src/edit/hashline/filesystem.ts +2 -1
  47. package/src/eval/__tests__/llm-bridge.test.ts +20 -0
  48. package/src/eval/js/context-manager.ts +32 -15
  49. package/src/eval/llm-bridge.ts +14 -3
  50. package/src/eval/py/__tests__/prelude.test.ts +19 -0
  51. package/src/eval/py/executor.ts +23 -11
  52. package/src/eval/py/prelude.py +1 -1
  53. package/src/extensibility/extensions/types.ts +10 -1
  54. package/src/internal-urls/docs-index.generated.ts +3 -3
  55. package/src/lsp/client.ts +23 -11
  56. package/src/lsp/config.ts +11 -1
  57. package/src/lsp/index.ts +61 -9
  58. package/src/main.ts +91 -65
  59. package/src/mcp/tool-bridge.ts +2 -0
  60. package/src/memories/index.ts +2 -2
  61. package/src/modes/components/custom-editor.ts +143 -111
  62. package/src/modes/components/model-selector.ts +59 -13
  63. package/src/modes/components/oauth-selector.ts +33 -7
  64. package/src/modes/components/status-line.ts +19 -4
  65. package/src/modes/components/tips.txt +1 -1
  66. package/src/modes/components/user-message.ts +1 -1
  67. package/src/modes/controllers/event-controller.ts +26 -0
  68. package/src/modes/controllers/input-controller.ts +46 -7
  69. package/src/modes/interactive-mode.ts +107 -20
  70. package/src/modes/magic-keywords.ts +1 -1
  71. package/src/modes/markdown-prose.ts +1 -1
  72. package/src/modes/theme/shimmer.ts +20 -9
  73. package/src/modes/types.ts +3 -0
  74. package/src/modes/workflow.ts +10 -10
  75. package/src/prompts/system/workflow-notice.md +1 -1
  76. package/src/prompts/tools/bash.md +9 -0
  77. package/src/prompts/tools/browser.md +1 -1
  78. package/src/prompts/tools/eval.md +2 -1
  79. package/src/prompts/tools/read.md +2 -2
  80. package/src/sdk.ts +26 -9
  81. package/src/session/agent-session.ts +37 -12
  82. package/src/session/auth-storage.ts +2 -0
  83. package/src/session/session-manager.ts +96 -23
  84. package/src/task/executor.ts +71 -36
  85. package/src/task/render.ts +3 -4
  86. package/src/tools/bash.ts +7 -0
  87. package/src/tools/browser/tab-supervisor.ts +13 -1
  88. package/src/tools/browser/tab-worker.ts +33 -4
  89. package/src/tools/eval.ts +13 -2
  90. package/src/tools/find.ts +7 -0
  91. package/src/tools/gh-cache-invalidation.ts +200 -0
  92. package/src/tools/github-cache.ts +25 -0
  93. package/src/tools/inspect-image.ts +2 -2
  94. package/src/tools/path-utils.ts +28 -2
  95. package/src/tools/plan-mode-guard.ts +52 -7
  96. package/src/tools/read.ts +25 -12
  97. package/src/tools/search.ts +38 -3
  98. package/src/tools/write.ts +2 -2
  99. package/src/tools/yield.ts +10 -1
  100. package/src/utils/commit-message-generator.ts +2 -2
  101. package/src/utils/enhanced-paste.ts +30 -2
  102. package/src/web/search/providers/codex.ts +37 -8
@@ -1,4 +1,4 @@
1
- import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui";
1
+ import { addKeyAliases, canonicalKeyId, Editor, type KeyId, parseKey, parseKittySequence } from "@oh-my-pi/pi-tui";
2
2
  import type { AppKeybinding } from "../../config/keybindings";
3
3
  import { imageReferenceHyperlink, renderImageReferences } from "../image-references";
4
4
  import { highlightMagicKeywords } from "../magic-keywords";
@@ -47,6 +47,14 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
47
47
  "app.clipboard.copyPrompt": ["alt+shift+c"],
48
48
  };
49
49
 
50
+ function buildMatchKeys(keys: readonly KeyId[]): Set<string> {
51
+ const matchKeys = new Set<string>();
52
+ for (const key of keys) {
53
+ addKeyAliases(matchKeys, key);
54
+ }
55
+ return matchKeys;
56
+ }
57
+
50
58
  const BRACKETED_PASTE_START = "\x1b[200~";
51
59
  const BRACKETED_PASTE_END = "\x1b[201~";
52
60
  const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
@@ -68,7 +76,7 @@ export function extractBracketedImagePastePath(data: string): string | undefined
68
76
  export class CustomEditor extends Editor {
69
77
  imageLinks?: readonly (string | undefined)[];
70
78
 
71
- /** Gradient-highlight the "ultrathink" / "orchestrate" / "workflow" keywords as the user types
79
+ /** Gradient-highlight the "ultrathink" / "orchestrate" / "workflowz" keywords as the user types
72
80
  * them, skipping any occurrence inside code spans, fenced blocks, or XML sections. Also make
73
81
  * pasted image placeholders visually distinct and hyperlink them once their blob file exists. */
74
82
  decorateText = (text: string): string =>
@@ -108,21 +116,38 @@ export class CustomEditor extends Editor {
108
116
 
109
117
  /** Custom key handlers from extensions and non-built-in app actions. */
110
118
  #customKeyHandlers = new Map<KeyId, () => void>();
119
+ #customMatchKeys = new Map<string, () => void>();
111
120
  #actionKeys = new Map<ConfigurableEditorAction, KeyId[]>(
112
121
  Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [action as ConfigurableEditorAction, [...keys]]),
113
122
  );
123
+ #actionMatchKeys = new Map<ConfigurableEditorAction, Set<string>>(
124
+ Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [
125
+ action as ConfigurableEditorAction,
126
+ buildMatchKeys(keys),
127
+ ]),
128
+ );
114
129
 
115
130
  setActionKeys(action: ConfigurableEditorAction, keys: KeyId[]): void {
116
131
  this.#actionKeys.set(action, [...keys]);
132
+ this.#rebuildActionMatchKeys(action);
133
+ }
134
+
135
+ #rebuildActionMatchKeys(action: ConfigurableEditorAction): void {
136
+ this.#actionMatchKeys.set(action, buildMatchKeys(this.#actionKeys.get(action) ?? []));
117
137
  }
118
138
 
119
- #matchesAction(data: string, action: ConfigurableEditorAction): boolean {
120
- const keys = this.#actionKeys.get(action);
121
- if (!keys) return false;
122
- for (const key of keys) {
123
- if (matchesKey(data, key)) return true;
139
+ #rebuildCustomMatchKeys(): void {
140
+ this.#customMatchKeys.clear();
141
+ for (const [keyId, handler] of this.#customKeyHandlers) {
142
+ for (const alias of buildMatchKeys([keyId])) {
143
+ // Preserve current iteration behavior: the first registered handler for colliding aliases wins.
144
+ if (!this.#customMatchKeys.has(alias)) this.#customMatchKeys.set(alias, handler);
145
+ }
124
146
  }
125
- return false;
147
+ }
148
+
149
+ #matchesAction(canonical: string | undefined, action: ConfigurableEditorAction): boolean {
150
+ return canonical !== undefined && (this.#actionMatchKeys.get(action)?.has(canonical) ?? false);
126
151
  }
127
152
 
128
153
  /**
@@ -130,6 +155,7 @@ export class CustomEditor extends Editor {
130
155
  */
131
156
  setCustomKeyHandler(key: KeyId, handler: () => void): void {
132
157
  this.#customKeyHandlers.set(key, handler);
158
+ this.#rebuildCustomMatchKeys();
133
159
  }
134
160
 
135
161
  /**
@@ -137,6 +163,7 @@ export class CustomEditor extends Editor {
137
163
  */
138
164
  removeCustomKeyHandler(key: KeyId): void {
139
165
  this.#customKeyHandlers.delete(key);
166
+ this.#rebuildCustomMatchKeys();
140
167
  }
141
168
 
142
169
  /**
@@ -144,11 +171,12 @@ export class CustomEditor extends Editor {
144
171
  */
145
172
  clearCustomKeyHandlers(): void {
146
173
  this.#customKeyHandlers.clear();
174
+ this.#rebuildCustomMatchKeys();
147
175
  }
148
176
 
149
177
  handleInput(data: string): void {
150
- const parsed = parseKittySequence(data);
151
- if (parsed && (parsed.modifier & 64) !== 0 && this.onCapsLock) {
178
+ const kittyParsed = parseKittySequence(data);
179
+ if (kittyParsed && (kittyParsed.modifier & 64) !== 0 && this.onCapsLock) {
152
180
  // Caps Lock is modifier bit 64
153
181
  this.onCapsLock();
154
182
  return;
@@ -160,125 +188,129 @@ export class CustomEditor extends Editor {
160
188
  return;
161
189
  }
162
190
 
163
- // Intercept configured image paste (async - fires and handles result)
164
- if (this.#matchesAction(data, "app.clipboard.pasteImage") && this.onPasteImage) {
165
- void this.onPasteImage();
166
- return;
167
- }
191
+ const parsedKey = parseKey(data);
192
+ const canonical = parsedKey !== undefined ? canonicalKeyId(parsedKey) : undefined;
168
193
 
169
- // Intercept configured raw text paste (fires and handles result)
170
- if (this.#matchesAction(data, "app.clipboard.pasteTextRaw") && this.onPasteTextRaw) {
171
- this.onPasteTextRaw();
172
- return;
173
- }
194
+ if (canonical !== undefined) {
195
+ // Intercept configured image paste (async - fires and handles result)
196
+ if (this.#matchesAction(canonical, "app.clipboard.pasteImage") && this.onPasteImage) {
197
+ void this.onPasteImage();
198
+ return;
199
+ }
174
200
 
175
- // Intercept configured external editor shortcut
176
- if (this.#matchesAction(data, "app.editor.external") && this.onExternalEditor) {
177
- this.onExternalEditor();
178
- return;
179
- }
201
+ // Intercept configured raw text paste (fires and handles result)
202
+ if (this.#matchesAction(canonical, "app.clipboard.pasteTextRaw") && this.onPasteTextRaw) {
203
+ this.onPasteTextRaw();
204
+ return;
205
+ }
180
206
 
181
- // Intercept configured temporary model selector shortcut
182
- if (this.#matchesAction(data, "app.model.selectTemporary") && this.onSelectModelTemporary) {
183
- this.onSelectModelTemporary();
184
- return;
185
- }
207
+ // Intercept configured external editor shortcut
208
+ if (this.#matchesAction(canonical, "app.editor.external") && this.onExternalEditor) {
209
+ this.onExternalEditor();
210
+ return;
211
+ }
186
212
 
187
- // Intercept configured display reset shortcut
188
- if (this.#matchesAction(data, "app.display.reset") && this.onDisplayReset) {
189
- this.onDisplayReset();
190
- return;
191
- }
213
+ // Intercept configured temporary model selector shortcut
214
+ if (this.#matchesAction(canonical, "app.model.selectTemporary") && this.onSelectModelTemporary) {
215
+ this.onSelectModelTemporary();
216
+ return;
217
+ }
192
218
 
193
- // Intercept configured suspend shortcut
194
- if (this.#matchesAction(data, "app.suspend") && this.onSuspend) {
195
- this.onSuspend();
196
- return;
197
- }
219
+ // Intercept configured display reset shortcut
220
+ if (this.#matchesAction(canonical, "app.display.reset") && this.onDisplayReset) {
221
+ this.onDisplayReset();
222
+ return;
223
+ }
198
224
 
199
- // Intercept configured thinking block visibility toggle
200
- if (this.#matchesAction(data, "app.thinking.toggle") && this.onToggleThinking) {
201
- this.onToggleThinking();
202
- return;
203
- }
225
+ // Intercept configured suspend shortcut
226
+ if (this.#matchesAction(canonical, "app.suspend") && this.onSuspend) {
227
+ this.onSuspend();
228
+ return;
229
+ }
204
230
 
205
- // Intercept configured model selector shortcut
206
- if (this.#matchesAction(data, "app.model.select") && this.onSelectModel) {
207
- this.onSelectModel();
208
- return;
209
- }
231
+ // Intercept configured thinking block visibility toggle
232
+ if (this.#matchesAction(canonical, "app.thinking.toggle") && this.onToggleThinking) {
233
+ this.onToggleThinking();
234
+ return;
235
+ }
210
236
 
211
- // Intercept configured history search shortcut
212
- if (this.#matchesAction(data, "app.history.search") && this.onHistorySearch) {
213
- this.onHistorySearch();
214
- return;
215
- }
237
+ // Intercept configured model selector shortcut
238
+ if (this.#matchesAction(canonical, "app.model.select") && this.onSelectModel) {
239
+ this.onSelectModel();
240
+ return;
241
+ }
216
242
 
217
- // Intercept configured tool output expansion shortcut
218
- if (this.#matchesAction(data, "app.tools.expand") && this.onExpandTools) {
219
- this.onExpandTools();
220
- return;
221
- }
243
+ // Intercept configured history search shortcut
244
+ if (this.#matchesAction(canonical, "app.history.search") && this.onHistorySearch) {
245
+ this.onHistorySearch();
246
+ return;
247
+ }
222
248
 
223
- // Intercept configured backward model cycling (check before forward cycling)
224
- if (this.#matchesAction(data, "app.model.cycleBackward") && this.onCycleModelBackward) {
225
- this.onCycleModelBackward();
226
- return;
227
- }
249
+ // Intercept configured tool output expansion shortcut
250
+ if (this.#matchesAction(canonical, "app.tools.expand") && this.onExpandTools) {
251
+ this.onExpandTools();
252
+ return;
253
+ }
228
254
 
229
- // Intercept configured forward model cycling
230
- if (this.#matchesAction(data, "app.model.cycleForward") && this.onCycleModelForward) {
231
- this.onCycleModelForward();
232
- return;
233
- }
255
+ // Intercept configured backward model cycling (check before forward cycling)
256
+ if (this.#matchesAction(canonical, "app.model.cycleBackward") && this.onCycleModelBackward) {
257
+ this.onCycleModelBackward();
258
+ return;
259
+ }
234
260
 
235
- // Intercept configured thinking level cycling
236
- if (this.#matchesAction(data, "app.thinking.cycle") && this.onCycleThinkingLevel) {
237
- this.onCycleThinkingLevel();
238
- return;
239
- }
261
+ // Intercept configured forward model cycling
262
+ if (this.#matchesAction(canonical, "app.model.cycleForward") && this.onCycleModelForward) {
263
+ this.onCycleModelForward();
264
+ return;
265
+ }
240
266
 
241
- // Intercept configured interrupt shortcut.
242
- // When the autocomplete popup is visible, ESC's first job is to dismiss
243
- // the popup — let super.handleInput() route it to #cancelAutocomplete().
244
- // The user can press ESC again afterward to fire the global interrupt
245
- // handler. This matches the standard TUI/IDE pattern and prevents a
246
- // single ESC from both closing an @ completion and aborting an active
247
- // agent run (#1655).
248
- if (this.#matchesAction(data, "app.interrupt") && this.onEscape && !this.isShowingAutocomplete()) {
249
- this.onEscape();
250
- return;
251
- }
267
+ // Intercept configured thinking level cycling
268
+ if (this.#matchesAction(canonical, "app.thinking.cycle") && this.onCycleThinkingLevel) {
269
+ this.onCycleThinkingLevel();
270
+ return;
271
+ }
252
272
 
253
- // Intercept configured clear shortcut
254
- if (this.#matchesAction(data, "app.clear") && this.onClear) {
255
- this.onClear();
256
- return;
257
- }
273
+ // Intercept configured interrupt shortcut.
274
+ // When the autocomplete popup is visible, ESC's first job is to dismiss
275
+ // the popup — let super.handleInput() route it to #cancelAutocomplete().
276
+ // The user can press ESC again afterward to fire the global interrupt
277
+ // handler. This matches the standard TUI/IDE pattern and prevents a
278
+ // single ESC from both closing an @ completion and aborting an active
279
+ // agent run (#1655).
280
+ if (this.#matchesAction(canonical, "app.interrupt") && this.onEscape && !this.isShowingAutocomplete()) {
281
+ this.onEscape();
282
+ return;
283
+ }
258
284
 
259
- // Intercept configured exit shortcut. Always consume the shortcut so it
260
- // never reaches the parent handler; firing onExit is the controller's
261
- // chance to snapshot the current text as a draft before shutting down.
262
- if (this.#matchesAction(data, "app.exit")) {
263
- this.onExit?.();
264
- return;
265
- }
285
+ // Intercept configured clear shortcut
286
+ if (this.#matchesAction(canonical, "app.clear") && this.onClear) {
287
+ this.onClear();
288
+ return;
289
+ }
266
290
 
267
- // Intercept configured dequeue shortcut (restore queued message to editor)
268
- if (this.#matchesAction(data, "app.message.dequeue") && this.onDequeue) {
269
- this.onDequeue();
270
- return;
271
- }
291
+ // Intercept configured exit shortcut. Always consume the shortcut so it
292
+ // never reaches the parent handler; firing onExit is the controller's
293
+ // chance to snapshot the current text as a draft before shutting down.
294
+ if (this.#matchesAction(canonical, "app.exit")) {
295
+ this.onExit?.();
296
+ return;
297
+ }
272
298
 
273
- // Intercept configured copy-prompt shortcut
274
- if (this.#matchesAction(data, "app.clipboard.copyPrompt") && this.onCopyPrompt) {
275
- this.onCopyPrompt();
276
- return;
277
- }
299
+ // Intercept configured dequeue shortcut (restore queued message to editor)
300
+ if (this.#matchesAction(canonical, "app.message.dequeue") && this.onDequeue) {
301
+ this.onDequeue();
302
+ return;
303
+ }
278
304
 
279
- // Check custom key handlers (extensions)
280
- for (const [keyId, handler] of this.#customKeyHandlers) {
281
- if (matchesKey(data, keyId)) {
305
+ // Intercept configured copy-prompt shortcut
306
+ if (this.#matchesAction(canonical, "app.clipboard.copyPrompt") && this.onCopyPrompt) {
307
+ this.onCopyPrompt();
308
+ return;
309
+ }
310
+
311
+ // Check custom key handlers (extensions)
312
+ const handler = this.#customMatchKeys.get(canonical);
313
+ if (handler) {
282
314
  handler();
283
315
  return;
284
316
  }
@@ -17,7 +17,7 @@ import {
17
17
  import { formatNumber } from "@oh-my-pi/pi-utils";
18
18
  import type { ModelRegistry } from "../../config/model-registry";
19
19
  import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS, MODEL_ROLES } from "../../config/model-registry";
20
- import { resolveModelRoleValue } from "../../config/model-resolver";
20
+ import { getModelMatchPreferences, resolveModelRoleValue } from "../../config/model-resolver";
21
21
  import type { Settings } from "../../config/settings";
22
22
  import { type ThemeColor, theme } from "../../modes/theme/theme";
23
23
  import { matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
@@ -31,6 +31,25 @@ function makeInvertedBadge(label: string, color: ThemeColor): string {
31
31
  return `${bgAnsi}\x1b[30m ${label} \x1b[39m\x1b[49m`;
32
32
  }
33
33
 
34
+ function makeAutoSelectedBadge(label: string, color: ThemeColor): string {
35
+ return `${theme.fg("dim", "[")}${theme.fg(color, label)}${theme.fg("dim", " auto]")}`;
36
+ }
37
+
38
+ function makeRoleBadgeToken(label: string, color: ThemeColor, assigned: RoleAssignment): string {
39
+ if (assigned.autoSelected) {
40
+ const badge = makeAutoSelectedBadge(label, color);
41
+ if (assigned.thinkingLevel === ThinkingLevel.Inherit) {
42
+ return badge;
43
+ }
44
+ const thinkingLabel = getConfiguredThinkingLevelMetadata(assigned.thinkingLevel).label;
45
+ return `${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`;
46
+ }
47
+
48
+ const badge = makeInvertedBadge(label, color);
49
+ const thinkingLabel = getConfiguredThinkingLevelMetadata(assigned.thinkingLevel).label;
50
+ return `${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`;
51
+ }
52
+
34
53
  function normalizeSearchText(value: string): string {
35
54
  return value
36
55
  .toLowerCase()
@@ -86,6 +105,7 @@ interface ScopedModelItem {
86
105
  interface RoleAssignment {
87
106
  model: Model;
88
107
  thinkingLevel: ConfiguredThinkingLevel;
108
+ autoSelected: boolean;
89
109
  }
90
110
 
91
111
  type RoleSelectCallback = (
@@ -271,12 +291,17 @@ export class ModelSelectorComponent extends Container {
271
291
  });
272
292
  }
273
293
 
274
- #loadRoleModels(): void {
294
+ #loadRoleModels(autoCandidateModels?: ReadonlyArray<Model>): void {
295
+ const nextRoles = {} as Record<string, RoleAssignment | undefined>;
275
296
  const allModels = this.#modelRegistry.getAll();
276
- const matchPreferences = { usageOrder: this.#settings.getStorage()?.getModelUsageOrder() };
277
- for (const role of getKnownRoleIds(this.#settings)) {
297
+ const matchPreferences = getModelMatchPreferences(this.#settings);
298
+ const knownRoles = getKnownRoleIds(this.#settings);
299
+ const configuredRoles = new Set<string>();
300
+
301
+ for (const role of knownRoles) {
278
302
  const roleValue = this.#settings.getModelRole(role);
279
303
  if (!roleValue) continue;
304
+ configuredRoles.add(role);
280
305
 
281
306
  const resolved = resolveModelRoleValue(roleValue, allModels, {
282
307
  settings: this.#settings,
@@ -284,15 +309,39 @@ export class ModelSelectorComponent extends Container {
284
309
  modelRegistry: this.#modelRegistry,
285
310
  });
286
311
  if (resolved.model) {
287
- this.#roles[role] = {
312
+ nextRoles[role] = {
288
313
  model: resolved.model,
289
314
  thinkingLevel:
290
315
  resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined
291
316
  ? resolved.thinkingLevel
292
317
  : ThinkingLevel.Inherit,
318
+ autoSelected: false,
293
319
  };
294
320
  }
295
321
  }
322
+
323
+ if (autoCandidateModels && autoCandidateModels.length > 0) {
324
+ const candidates = [...autoCandidateModels];
325
+ for (const role of knownRoles) {
326
+ if (configuredRoles.has(role)) continue;
327
+ const resolved = resolveModelRoleValue(`pi/${role}`, candidates, {
328
+ settings: this.#settings,
329
+ matchPreferences,
330
+ modelRegistry: this.#modelRegistry,
331
+ });
332
+ if (!resolved.model) continue;
333
+ nextRoles[role] = {
334
+ model: resolved.model,
335
+ thinkingLevel:
336
+ resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined
337
+ ? resolved.thinkingLevel
338
+ : ThinkingLevel.Inherit,
339
+ autoSelected: true,
340
+ };
341
+ }
342
+ }
343
+
344
+ this.#roles = nextRoles;
296
345
  }
297
346
 
298
347
  /**
@@ -427,6 +476,7 @@ export class ModelSelectorComponent extends Container {
427
476
  }
428
477
 
429
478
  const candidates = models.map(item => item.model);
479
+ this.#loadRoleModels(candidates);
430
480
  const canonicalRecords = this.#modelRegistry.getCanonicalModels({
431
481
  availableOnly: this.#scopedModels.length === 0,
432
482
  candidates,
@@ -871,25 +921,21 @@ export class ModelSelectorComponent extends Container {
871
921
  const isDisabled = this.#isItemDisabled(item);
872
922
  const disabledSuffix = this.#formatContextLimitSuffix(item.model);
873
923
 
874
- // Build role badges (inverted: color as background, black text)
924
+ // Build role badges. Solid badges are configured; outlined badges are auto-selected defaults.
875
925
  const roleBadgeTokens: string[] = [];
876
926
  for (const role of MODEL_ROLE_IDS) {
877
927
  const { tag, color } = getRoleInfo(role, this.#settings);
878
928
  const assigned = this.#roles[role];
879
929
  if (!tag || !assigned || !modelsAreEqual(assigned.model, item.model)) continue;
880
930
 
881
- const badge = makeInvertedBadge(tag, color ?? "success");
882
- const thinkingLabel = getConfiguredThinkingLevelMetadata(assigned.thinkingLevel).label;
883
- roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`);
931
+ roleBadgeTokens.push(makeRoleBadgeToken(tag, color ?? "success", assigned));
884
932
  }
885
933
  // Custom role badges
886
934
  for (const [role, assigned] of Object.entries(this.#roles)) {
887
935
  if (role in MODEL_ROLES || !assigned || !modelsAreEqual(assigned.model, item.model)) continue;
888
936
  const roleInfo = getRoleInfo(role, this.#settings);
889
937
  const badgeLabel = roleInfo.tag ?? roleInfo.name;
890
- const badge = makeInvertedBadge(badgeLabel, roleInfo.color ?? "muted");
891
- const thinkingLabel = getConfiguredThinkingLevelMetadata(assigned.thinkingLevel).label;
892
- roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`);
938
+ roleBadgeTokens.push(makeRoleBadgeToken(badgeLabel, roleInfo.color ?? "muted", assigned));
893
939
  }
894
940
  const badgeText = roleBadgeTokens.length > 0 ? ` ${roleBadgeTokens.join(" ")}` : "";
895
941
 
@@ -1184,7 +1230,7 @@ export class ModelSelectorComponent extends Container {
1184
1230
  const selectedThinkingLevel = thinkingLevel ?? this.#getCurrentRoleThinkingLevel(role);
1185
1231
 
1186
1232
  // Update local state for UI
1187
- this.#roles[role] = { model: item.model, thinkingLevel: selectedThinkingLevel };
1233
+ this.#roles[role] = { model: item.model, thinkingLevel: selectedThinkingLevel, autoSelected: false };
1188
1234
 
1189
1235
  // Notify caller (for updating agent state if needed)
1190
1236
  this.#onSelectCallback(item.model, role, selectedThinkingLevel, item.selector);
@@ -11,10 +11,20 @@ import {
11
11
  } from "@oh-my-pi/pi-tui";
12
12
  import { theme } from "../../modes/theme/theme";
13
13
  import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
14
- import type { AuthStorage } from "../../session/auth-storage";
14
+ import type { AuthStorage, CredentialOriginKind } from "../../session/auth-storage";
15
15
  import { DynamicBorder } from "./dynamic-border";
16
16
 
17
17
  const OAUTH_SELECTOR_MAX_VISIBLE = 10;
18
+
19
+ /** Compact, human-readable tag for each credential-origin leg. */
20
+ const ORIGIN_LABELS: Record<CredentialOriginKind, string> = {
21
+ runtime: "--api-key",
22
+ config: "config",
23
+ oauth: "login",
24
+ api_key: "api key",
25
+ env: "env",
26
+ fallback: "custom provider",
27
+ };
18
28
  /**
19
29
  * Component that renders an OAuth provider selector.
20
30
  */
@@ -146,20 +156,34 @@ export class OAuthSelectorComponent extends Container {
146
156
  }
147
157
  }
148
158
 
159
+ /**
160
+ * Muted provenance suffix (" (env: COPILOT_GITHUB_TOKEN)", " (login)", …) so
161
+ * the list distinguishes a real login from an env var aliasing the provider.
162
+ */
163
+ #getSourceLabel(providerId: string): string {
164
+ const origin = this.#authStorage.getCredentialOrigin(providerId);
165
+ if (!origin) return "";
166
+ const detail = origin.kind === "env" && origin.envVar ? `env: ${origin.envVar}` : ORIGIN_LABELS[origin.kind];
167
+ return theme.fg("muted", ` (${detail})`);
168
+ }
169
+
149
170
  #getStatusIndicator(providerId: string): string {
150
171
  const state = this.#authState.get(providerId);
172
+ const source = this.#getSourceLabel(providerId);
151
173
  if (state === "checking") {
152
174
  const frameCount = theme.spinnerFrames.length;
153
175
  const spinner = frameCount > 0 ? theme.spinnerFrames[this.#spinnerFrame % frameCount] : theme.status.pending;
154
- return theme.fg("warning", ` ${spinner} checking`);
176
+ return theme.fg("warning", ` ${spinner} checking`) + source;
155
177
  }
156
178
  if (state === "invalid") {
157
- return theme.fg("error", ` ${theme.status.error} invalid`);
179
+ return theme.fg("error", ` ${theme.status.error} invalid`) + source;
158
180
  }
159
181
  if (state === "valid") {
160
- return theme.fg("success", ` ${theme.status.success} logged in`);
182
+ return theme.fg("success", ` ${theme.status.success} logged in`) + source;
161
183
  }
162
- return this.#hasSelectableAuth(providerId) ? theme.fg("success", ` ${theme.status.success} logged in`) : "";
184
+ return this.#hasSelectableAuth(providerId)
185
+ ? theme.fg("success", ` ${theme.status.success} logged in`) + source
186
+ : "";
163
187
  }
164
188
 
165
189
  #isSearchEnabled(): boolean {
@@ -178,8 +202,10 @@ export class OAuthSelectorComponent extends Container {
178
202
 
179
203
  #getProviderSearchText(provider: OAuthProviderInfo): string {
180
204
  let text = `${provider.name} ${provider.id}`;
181
- if (this.#hasSelectableAuth(provider.id)) {
182
- text += " logged in authenticated";
205
+ const origin = this.#authStorage.getCredentialOrigin(provider.id);
206
+ if (origin) {
207
+ text += ` logged in authenticated ${ORIGIN_LABELS[origin.kind]}`;
208
+ if (origin.envVar) text += ` ${origin.envVar}`;
183
209
  }
184
210
  if (!provider.available) {
185
211
  text += " unavailable";
@@ -40,6 +40,11 @@ export interface StatusLineSettings {
40
40
  sessionAccent?: boolean;
41
41
  }
42
42
 
43
+ export type EffectiveStatusLineSettings = Required<
44
+ Pick<StatusLineSettings, "leftSegments" | "rightSegments" | "separator" | "segmentOptions">
45
+ > &
46
+ StatusLineSettings;
47
+
43
48
  // ═══════════════════════════════════════════════════════════════════════════
44
49
  // Per-message token cache
45
50
  // ═══════════════════════════════════════════════════════════════════════════
@@ -143,6 +148,7 @@ function tokensForMessage(msg: AgentMessage): number {
143
148
 
144
149
  export class StatusLineComponent implements Component {
145
150
  #settings: StatusLineSettings = {};
151
+ #effectiveSettings: EffectiveStatusLineSettings | undefined;
146
152
  #cachedBranch: string | null | undefined = undefined;
147
153
  #cachedBranchRepoId: string | null | undefined = undefined;
148
154
  #gitWatcher: fs.FSWatcher | null = null;
@@ -204,6 +210,11 @@ export class StatusLineComponent implements Component {
204
210
 
205
211
  updateSettings(settings: StatusLineSettings): void {
206
212
  this.#settings = settings;
213
+ this.#effectiveSettings = undefined;
214
+ }
215
+
216
+ getEffectiveSettingsForTest(): EffectiveStatusLineSettings {
217
+ return this.#resolveSettings();
207
218
  }
208
219
 
209
220
  setAutoCompactEnabled(enabled: boolean): void {
@@ -594,10 +605,14 @@ export class StatusLineComponent implements Component {
594
605
  };
595
606
  }
596
607
 
597
- #resolveSettings(): Required<
598
- Pick<StatusLineSettings, "leftSegments" | "rightSegments" | "separator" | "segmentOptions">
599
- > &
600
- StatusLineSettings {
608
+ #resolveSettings(): EffectiveStatusLineSettings {
609
+ if (this.#effectiveSettings === undefined) {
610
+ this.#effectiveSettings = this.#computeEffectiveSettings();
611
+ }
612
+ return this.#effectiveSettings;
613
+ }
614
+
615
+ #computeEffectiveSettings(): EffectiveStatusLineSettings {
601
616
  const preset = this.#settings.preset ?? "default";
602
617
  const presetDef = getPreset(preset);
603
618
  const useCustomSegments = preset === "custom";
@@ -9,7 +9,7 @@ Spaghetti code? Try complaining with /omfg
9
9
  Did you know? Each kitty/tmux/cmux split keeps its own session — `omp -c` resumes the right one
10
10
  Drop the word `ultrathink` in your message for harder multi-step reasoning — watch it glow rainbow as you type
11
11
  Say `orchestrate` in your message to drive a multi-phase task with parallel subagents — watch it glow as you type
12
- Say `workflow` in your message to drive the task with parallel subagents in eval — watch it glow as you type
12
+ Say `workflowz` in your message to drive the task with parallel subagents in eval — watch it glow as you type
13
13
  Log in to several accounts of the same provider — `/login` again — and omp load-balances across them automatically
14
14
  Run `omp auth-broker serve` once and every machine pulls live tokens over the wire — refresh keys never leave the host; `omp auth-gateway` fronts it as a drop-in proxy any OpenAI-compatible client can hit
15
15
  Press alt+p (or /switch) to switch provider, and ctrl+p to cycle role models smol -> slow -> etc
@@ -15,7 +15,7 @@ export class UserMessageComponent extends Container {
15
15
  constructor(text: string, synthetic = false, imageLinks?: readonly (string | undefined)[]) {
16
16
  super();
17
17
  const bgColor = (value: string) => theme.bg("userMessageBg", value);
18
- // Paint the magic keywords ("ultrathink"/"orchestrate"/"workflow") inside the rendered
18
+ // Paint the magic keywords ("ultrathink"/"orchestrate"/"workflowz") inside the rendered
19
19
  // bubble too — matching the live editor glow. The Markdown component routes code spans and
20
20
  // fenced blocks through its own code styling (never `color`), so those are already excluded;
21
21
  // `highlightMagicKeywords` additionally restores the bubble's own foreground after each