@oh-my-pi/pi-coding-agent 1.340.0 → 2.0.1337

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 (153) hide show
  1. package/CHANGELOG.md +115 -1
  2. package/README.md +1 -1
  3. package/examples/custom-tools/subagent/index.ts +1 -1
  4. package/package.json +5 -3
  5. package/src/cli/args.ts +13 -6
  6. package/src/cli/file-processor.ts +3 -3
  7. package/src/cli/list-models.ts +2 -2
  8. package/src/cli/plugin-cli.ts +1 -1
  9. package/src/cli/session-picker.ts +2 -2
  10. package/src/cli.ts +1 -1
  11. package/src/config.ts +3 -3
  12. package/src/core/agent-session.ts +189 -29
  13. package/src/core/bash-executor.ts +50 -10
  14. package/src/core/compaction/branch-summarization.ts +5 -5
  15. package/src/core/compaction/compaction.ts +3 -3
  16. package/src/core/compaction/index.ts +3 -3
  17. package/src/core/custom-commands/bundled/review/index.ts +156 -0
  18. package/src/core/custom-commands/index.ts +15 -0
  19. package/src/core/custom-commands/loader.ts +232 -0
  20. package/src/core/custom-commands/types.ts +112 -0
  21. package/src/core/custom-tools/index.ts +3 -3
  22. package/src/core/custom-tools/loader.ts +10 -8
  23. package/src/core/custom-tools/types.ts +11 -6
  24. package/src/core/custom-tools/wrapper.ts +2 -1
  25. package/src/core/exec.ts +22 -12
  26. package/src/core/export-html/index.ts +5 -5
  27. package/src/core/file-mentions.ts +54 -0
  28. package/src/core/hooks/index.ts +5 -5
  29. package/src/core/hooks/loader.ts +21 -16
  30. package/src/core/hooks/runner.ts +6 -6
  31. package/src/core/hooks/tool-wrapper.ts +2 -2
  32. package/src/core/hooks/types.ts +12 -15
  33. package/src/core/index.ts +6 -6
  34. package/src/core/logger.ts +112 -0
  35. package/src/core/mcp/client.ts +3 -3
  36. package/src/core/mcp/config.ts +1 -1
  37. package/src/core/mcp/index.ts +12 -12
  38. package/src/core/mcp/loader.ts +2 -2
  39. package/src/core/mcp/manager.ts +6 -6
  40. package/src/core/mcp/tool-bridge.ts +3 -3
  41. package/src/core/mcp/transports/http.ts +1 -1
  42. package/src/core/mcp/transports/index.ts +2 -2
  43. package/src/core/mcp/transports/stdio.ts +1 -1
  44. package/src/core/messages.ts +22 -0
  45. package/src/core/model-registry.ts +2 -2
  46. package/src/core/model-resolver.ts +103 -2
  47. package/src/core/plugins/doctor.ts +1 -1
  48. package/src/core/plugins/index.ts +6 -6
  49. package/src/core/plugins/installer.ts +4 -4
  50. package/src/core/plugins/loader.ts +4 -9
  51. package/src/core/plugins/manager.ts +5 -5
  52. package/src/core/plugins/paths.ts +3 -3
  53. package/src/core/sdk.ts +127 -52
  54. package/src/core/session-manager.ts +123 -20
  55. package/src/core/settings-manager.ts +106 -22
  56. package/src/core/skills.ts +5 -5
  57. package/src/core/slash-commands.ts +60 -45
  58. package/src/core/system-prompt.ts +6 -6
  59. package/src/core/title-generator.ts +94 -0
  60. package/src/core/tools/bash.ts +33 -157
  61. package/src/core/tools/context.ts +2 -2
  62. package/src/core/tools/edit-diff.ts +5 -5
  63. package/src/core/tools/edit.ts +60 -9
  64. package/src/core/tools/exa/company.ts +3 -3
  65. package/src/core/tools/exa/index.ts +16 -17
  66. package/src/core/tools/exa/linkedin.ts +3 -3
  67. package/src/core/tools/exa/mcp-client.ts +9 -9
  68. package/src/core/tools/exa/render.ts +5 -5
  69. package/src/core/tools/exa/researcher.ts +3 -3
  70. package/src/core/tools/exa/search.ts +6 -5
  71. package/src/core/tools/exa/types.ts +5 -6
  72. package/src/core/tools/exa/websets.ts +3 -3
  73. package/src/core/tools/find.ts +3 -3
  74. package/src/core/tools/grep.ts +6 -5
  75. package/src/core/tools/index.ts +114 -40
  76. package/src/core/tools/ls.ts +4 -4
  77. package/src/core/tools/lsp/client.ts +204 -108
  78. package/src/core/tools/lsp/config.ts +709 -35
  79. package/src/core/tools/lsp/edits.ts +2 -2
  80. package/src/core/tools/lsp/index.ts +432 -30
  81. package/src/core/tools/lsp/render.ts +2 -2
  82. package/src/core/tools/lsp/rust-analyzer.ts +3 -3
  83. package/src/core/tools/lsp/types.ts +5 -0
  84. package/src/core/tools/lsp/utils.ts +1 -1
  85. package/src/core/tools/notebook.ts +1 -1
  86. package/src/core/tools/output.ts +175 -0
  87. package/src/core/tools/read.ts +7 -7
  88. package/src/core/tools/renderers.ts +92 -13
  89. package/src/core/tools/review.ts +268 -0
  90. package/src/core/tools/task/agents.ts +1 -1
  91. package/src/core/tools/task/bundled-agents/explore.md +1 -1
  92. package/src/core/tools/task/bundled-agents/reviewer.md +53 -38
  93. package/src/core/tools/task/discovery.ts +2 -2
  94. package/src/core/tools/task/executor.ts +145 -28
  95. package/src/core/tools/task/index.ts +78 -30
  96. package/src/core/tools/task/model-resolver.ts +72 -13
  97. package/src/core/tools/task/parallel.ts +1 -1
  98. package/src/core/tools/task/render.ts +219 -30
  99. package/src/core/tools/task/subprocess-tool-registry.ts +89 -0
  100. package/src/core/tools/task/types.ts +36 -2
  101. package/src/core/tools/web-fetch.ts +5 -3
  102. package/src/core/tools/web-search/auth.ts +1 -1
  103. package/src/core/tools/web-search/index.ts +17 -15
  104. package/src/core/tools/web-search/providers/anthropic.ts +2 -2
  105. package/src/core/tools/web-search/providers/exa.ts +3 -5
  106. package/src/core/tools/web-search/providers/perplexity.ts +1 -1
  107. package/src/core/tools/web-search/render.ts +3 -3
  108. package/src/core/tools/write.ts +70 -7
  109. package/src/index.ts +33 -17
  110. package/src/main.ts +60 -34
  111. package/src/migrations.ts +3 -3
  112. package/src/modes/index.ts +5 -5
  113. package/src/modes/interactive/components/armin.ts +1 -1
  114. package/src/modes/interactive/components/assistant-message.ts +1 -1
  115. package/src/modes/interactive/components/bash-execution.ts +4 -4
  116. package/src/modes/interactive/components/bordered-loader.ts +2 -2
  117. package/src/modes/interactive/components/branch-summary-message.ts +2 -2
  118. package/src/modes/interactive/components/compaction-summary-message.ts +2 -2
  119. package/src/modes/interactive/components/diff.ts +1 -1
  120. package/src/modes/interactive/components/dynamic-border.ts +1 -1
  121. package/src/modes/interactive/components/footer.ts +5 -5
  122. package/src/modes/interactive/components/hook-editor.ts +2 -2
  123. package/src/modes/interactive/components/hook-input.ts +2 -2
  124. package/src/modes/interactive/components/hook-message.ts +3 -3
  125. package/src/modes/interactive/components/hook-selector.ts +2 -2
  126. package/src/modes/interactive/components/model-selector.ts +341 -41
  127. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  128. package/src/modes/interactive/components/plugin-settings.ts +4 -4
  129. package/src/modes/interactive/components/queue-mode-selector.ts +2 -2
  130. package/src/modes/interactive/components/session-selector.ts +24 -11
  131. package/src/modes/interactive/components/settings-defs.ts +51 -3
  132. package/src/modes/interactive/components/settings-selector.ts +13 -16
  133. package/src/modes/interactive/components/show-images-selector.ts +2 -2
  134. package/src/modes/interactive/components/theme-selector.ts +2 -2
  135. package/src/modes/interactive/components/thinking-selector.ts +2 -2
  136. package/src/modes/interactive/components/tool-execution.ts +44 -8
  137. package/src/modes/interactive/components/tree-selector.ts +5 -5
  138. package/src/modes/interactive/components/user-message-selector.ts +2 -2
  139. package/src/modes/interactive/components/user-message.ts +1 -1
  140. package/src/modes/interactive/components/welcome.ts +42 -5
  141. package/src/modes/interactive/interactive-mode.ts +169 -48
  142. package/src/modes/interactive/theme/theme.ts +8 -7
  143. package/src/modes/print-mode.ts +4 -3
  144. package/src/modes/rpc/rpc-client.ts +4 -4
  145. package/src/modes/rpc/rpc-mode.ts +21 -11
  146. package/src/modes/rpc/rpc-types.ts +3 -3
  147. package/src/utils/changelog.ts +2 -2
  148. package/src/utils/clipboard.ts +1 -1
  149. package/src/utils/shell-snapshot.ts +218 -0
  150. package/src/utils/shell.ts +93 -13
  151. package/src/utils/tools-manager.ts +1 -1
  152. package/examples/custom-tools/subagent/agents/reviewer.md +0 -35
  153. package/src/core/tools/exa/logger.ts +0 -56
@@ -1,10 +1,10 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, type FSWatcher, readFileSync, watch } from "node:fs";
3
+ import { dirname, join } from "node:path";
1
4
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
5
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
3
- import { execSync } from "child_process";
4
- import { existsSync, type FSWatcher, readFileSync, watch } from "fs";
5
- import { dirname, join } from "path";
6
- import type { AgentSession } from "../../../core/agent-session.js";
7
- import { theme } from "../theme/theme.js";
6
+ import type { AgentSession } from "../../../core/agent-session";
7
+ import { theme } from "../theme/theme";
8
8
 
9
9
  // Nerd Font icons (matching Claude/statusline-nerd.sh)
10
10
  const ICONS = {
@@ -7,8 +7,8 @@ import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
9
  import { Container, Editor, isCtrlG, isEscape, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
10
- import { getEditorTheme, theme } from "../theme/theme.js";
11
- import { DynamicBorder } from "./dynamic-border.js";
10
+ import { getEditorTheme, theme } from "../theme/theme";
11
+ import { DynamicBorder } from "./dynamic-border";
12
12
 
13
13
  export class HookEditorComponent extends Container {
14
14
  private editor: Editor;
@@ -3,8 +3,8 @@
3
3
  */
4
4
 
5
5
  import { Container, Input, isEnter, isEscape, Spacer, Text } from "@oh-my-pi/pi-tui";
6
- import { theme } from "../theme/theme.js";
7
- import { DynamicBorder } from "./dynamic-border.js";
6
+ import { theme } from "../theme/theme";
7
+ import { DynamicBorder } from "./dynamic-border";
8
8
 
9
9
  export class HookInputComponent extends Container {
10
10
  private input: Input;
@@ -1,9 +1,9 @@
1
1
  import type { TextContent } from "@oh-my-pi/pi-ai";
2
2
  import type { Component } from "@oh-my-pi/pi-tui";
3
3
  import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
4
- import type { HookMessageRenderer } from "../../../core/hooks/types.js";
5
- import type { HookMessage } from "../../../core/messages.js";
6
- import { getMarkdownTheme, theme } from "../theme/theme.js";
4
+ import type { HookMessageRenderer } from "../../../core/hooks/types";
5
+ import type { HookMessage } from "../../../core/messages";
6
+ import { getMarkdownTheme, theme } from "../theme/theme";
7
7
 
8
8
  /**
9
9
  * Component that renders a custom message entry from hooks.
@@ -4,8 +4,8 @@
4
4
  */
5
5
 
6
6
  import { Container, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, Text } from "@oh-my-pi/pi-tui";
7
- import { theme } from "../theme/theme.js";
8
- import { DynamicBorder } from "./dynamic-border.js";
7
+ import { theme } from "../theme/theme";
8
+ import { DynamicBorder } from "./dynamic-border";
9
9
 
10
10
  export class HookSelectorComponent extends Container {
11
11
  private options: string[];
@@ -1,10 +1,25 @@
1
1
  import { type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
2
- import { Container, Input, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
3
- import type { ModelRegistry } from "../../../core/model-registry.js";
4
- import type { SettingsManager } from "../../../core/settings-manager.js";
5
- import { fuzzyFilter } from "../../../utils/fuzzy.js";
6
- import { theme } from "../theme/theme.js";
7
- import { DynamicBorder } from "./dynamic-border.js";
2
+ import {
3
+ Container,
4
+ Input,
5
+ isArrowDown,
6
+ isArrowLeft,
7
+ isArrowRight,
8
+ isArrowUp,
9
+ isEnter,
10
+ isEscape,
11
+ isShiftTab,
12
+ isTab,
13
+ Spacer,
14
+ Text,
15
+ type TUI,
16
+ } from "@oh-my-pi/pi-tui";
17
+ import type { ModelRegistry } from "../../../core/model-registry";
18
+ import { parseModelString } from "../../../core/model-resolver";
19
+ import type { SettingsManager } from "../../../core/settings-manager";
20
+ import { fuzzyFilter } from "../../../utils/fuzzy";
21
+ import { theme } from "../theme/theme";
22
+ import { DynamicBorder } from "./dynamic-border";
8
23
 
9
24
  interface ModelItem {
10
25
  provider: string;
@@ -17,31 +32,63 @@ interface ScopedModelItem {
17
32
  thinkingLevel: string;
18
33
  }
19
34
 
35
+ type ModelRole = "default" | "smol" | "slow";
36
+
37
+ interface MenuAction {
38
+ label: string;
39
+ role: ModelRole;
40
+ }
41
+
42
+ const MENU_ACTIONS: MenuAction[] = [
43
+ { label: "Set as Default", role: "default" },
44
+ { label: "Set as Smol (Fast)", role: "smol" },
45
+ { label: "Set as Slow (Thinking)", role: "slow" },
46
+ ];
47
+
48
+ const ALL_TAB = "ALL";
49
+
20
50
  /**
21
- * Component that renders a model selector with search
51
+ * Component that renders a model selector with provider tabs and context menu.
52
+ * - Tab/Arrow Left/Right: Switch between provider tabs
53
+ * - Arrow Up/Down: Navigate model list
54
+ * - Enter: Open context menu to select action
55
+ * - Escape: Close menu or selector
22
56
  */
23
57
  export class ModelSelectorComponent extends Container {
24
58
  private searchInput: Input;
59
+ private headerContainer: Container;
25
60
  private listContainer: Container;
61
+ private menuContainer: Container;
26
62
  private allModels: ModelItem[] = [];
27
63
  private filteredModels: ModelItem[] = [];
28
64
  private selectedIndex: number = 0;
29
65
  private currentModel?: Model<any>;
66
+ private defaultModel?: Model<any>;
67
+ private smolModel?: Model<any>;
68
+ private slowModel?: Model<any>;
30
69
  private settingsManager: SettingsManager;
31
70
  private modelRegistry: ModelRegistry;
32
- private onSelectCallback: (model: Model<any>) => void;
71
+ private onSelectCallback: (model: Model<any>, role: string) => void;
33
72
  private onCancelCallback: () => void;
34
73
  private errorMessage?: string;
35
74
  private tui: TUI;
36
75
  private scopedModels: ReadonlyArray<ScopedModelItem>;
37
76
 
77
+ // Tab state
78
+ private providers: string[] = [ALL_TAB];
79
+ private activeTabIndex: number = 0;
80
+
81
+ // Context menu state
82
+ private isMenuOpen: boolean = false;
83
+ private menuSelectedIndex: number = 0;
84
+
38
85
  constructor(
39
86
  tui: TUI,
40
87
  currentModel: Model<any> | undefined,
41
88
  settingsManager: SettingsManager,
42
89
  modelRegistry: ModelRegistry,
43
90
  scopedModels: ReadonlyArray<ScopedModelItem>,
44
- onSelect: (model: Model<any>) => void,
91
+ onSelect: (model: Model<any>, role: string) => void,
45
92
  onCancel: () => void,
46
93
  ) {
47
94
  super();
@@ -54,6 +101,9 @@ export class ModelSelectorComponent extends Container {
54
101
  this.onSelectCallback = onSelect;
55
102
  this.onCancelCallback = onCancel;
56
103
 
104
+ // Load current role assignments from settings
105
+ this._loadRoleModels();
106
+
57
107
  // Add top border
58
108
  this.addChild(new DynamicBorder());
59
109
  this.addChild(new Spacer(1));
@@ -66,12 +116,18 @@ export class ModelSelectorComponent extends Container {
66
116
  this.addChild(new Text(theme.fg("warning", hintText), 0, 0));
67
117
  this.addChild(new Spacer(1));
68
118
 
119
+ // Create header container for tab bar
120
+ this.headerContainer = new Container();
121
+ this.addChild(this.headerContainer);
122
+
123
+ this.addChild(new Spacer(1));
124
+
69
125
  // Create search input
70
126
  this.searchInput = new Input();
71
127
  this.searchInput.onSubmit = () => {
72
- // Enter on search input selects the first filtered item
128
+ // Enter on search input opens menu if we have a selection
73
129
  if (this.filteredModels[this.selectedIndex]) {
74
- this.handleSelect(this.filteredModels[this.selectedIndex].model);
130
+ this.openMenu();
75
131
  }
76
132
  };
77
133
  this.addChild(this.searchInput);
@@ -82,6 +138,10 @@ export class ModelSelectorComponent extends Container {
82
138
  this.listContainer = new Container();
83
139
  this.addChild(this.listContainer);
84
140
 
141
+ // Create menu container (hidden by default)
142
+ this.menuContainer = new Container();
143
+ this.addChild(this.menuContainer);
144
+
85
145
  this.addChild(new Spacer(1));
86
146
 
87
147
  // Add bottom border
@@ -89,12 +149,46 @@ export class ModelSelectorComponent extends Container {
89
149
 
90
150
  // Load models and do initial render
91
151
  this.loadModels().then(() => {
152
+ this.buildProviderTabs();
153
+ this.updateTabBar();
92
154
  this.updateList();
93
155
  // Request re-render after models are loaded
94
156
  this.tui.requestRender();
95
157
  });
96
158
  }
97
159
 
160
+ private _loadRoleModels(): void {
161
+ const roles = this.settingsManager.getModelRoles();
162
+ const allModels = this.modelRegistry.getAll();
163
+
164
+ // Load default model
165
+ const defaultStr = roles.default;
166
+ if (defaultStr) {
167
+ const parsed = parseModelString(defaultStr);
168
+ if (parsed) {
169
+ this.defaultModel = allModels.find((m) => m.provider === parsed.provider && m.id === parsed.id);
170
+ }
171
+ }
172
+
173
+ // Load smol model
174
+ const smolStr = roles.smol;
175
+ if (smolStr) {
176
+ const parsed = parseModelString(smolStr);
177
+ if (parsed) {
178
+ this.smolModel = allModels.find((m) => m.provider === parsed.provider && m.id === parsed.id);
179
+ }
180
+ }
181
+
182
+ // Load slow model
183
+ const slowStr = roles.slow;
184
+ if (slowStr) {
185
+ const parsed = parseModelString(slowStr);
186
+ if (parsed) {
187
+ this.slowModel = allModels.find((m) => m.provider === parsed.provider && m.id === parsed.id);
188
+ }
189
+ }
190
+ }
191
+
98
192
  private async loadModels(): Promise<void> {
99
193
  let models: ModelItem[];
100
194
 
@@ -131,13 +225,15 @@ export class ModelSelectorComponent extends Container {
131
225
  }
132
226
  }
133
227
 
134
- // Sort: current model first, then by provider
228
+ // Sort: current model first, then by provider, then by id
135
229
  models.sort((a, b) => {
136
230
  const aIsCurrent = modelsAreEqual(this.currentModel, a.model);
137
231
  const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
138
232
  if (aIsCurrent && !bIsCurrent) return -1;
139
233
  if (!aIsCurrent && bIsCurrent) return 1;
140
- return a.provider.localeCompare(b.provider);
234
+ const providerCmp = a.provider.localeCompare(b.provider);
235
+ if (providerCmp !== 0) return providerCmp;
236
+ return a.id.localeCompare(b.id);
141
237
  });
142
238
 
143
239
  this.allModels = models;
@@ -145,12 +241,81 @@ export class ModelSelectorComponent extends Container {
145
241
  this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, models.length - 1));
146
242
  }
147
243
 
244
+ private buildProviderTabs(): void {
245
+ // Extract unique providers from models
246
+ const providerSet = new Set<string>();
247
+ for (const item of this.allModels) {
248
+ providerSet.add(item.provider.toUpperCase());
249
+ }
250
+ // Sort providers alphabetically
251
+ const sortedProviders = Array.from(providerSet).sort();
252
+ this.providers = [ALL_TAB, ...sortedProviders];
253
+ }
254
+
255
+ private updateTabBar(): void {
256
+ this.headerContainer.clear();
257
+
258
+ // Build tab bar line
259
+ const parts: string[] = [];
260
+ parts.push(theme.fg("muted", "Provider:"));
261
+ parts.push(" ");
262
+
263
+ for (let i = 0; i < this.providers.length; i++) {
264
+ const provider = this.providers[i]!;
265
+ const isActive = i === this.activeTabIndex;
266
+
267
+ if (isActive) {
268
+ parts.push(theme.fg("accent", `[ ${provider} ]`));
269
+ } else {
270
+ parts.push(theme.fg("muted", ` ${provider} `));
271
+ }
272
+
273
+ if (i < this.providers.length - 1) {
274
+ parts.push(" ");
275
+ }
276
+ }
277
+
278
+ parts.push(" ");
279
+ parts.push(theme.fg("dim", "(←/→ or Tab to switch)"));
280
+
281
+ this.headerContainer.addChild(new Text(parts.join(""), 0, 0));
282
+ }
283
+
284
+ private getActiveProvider(): string {
285
+ return this.providers[this.activeTabIndex] ?? ALL_TAB;
286
+ }
287
+
148
288
  private filterModels(query: string): void {
149
- this.filteredModels = fuzzyFilter(this.allModels, query, ({ id, provider }) => `${id} ${provider}`);
289
+ const activeProvider = this.getActiveProvider();
290
+
291
+ // Start with all models or filter by provider
292
+ let baseModels = this.allModels;
293
+ if (activeProvider !== ALL_TAB) {
294
+ baseModels = this.allModels.filter((m) => m.provider.toUpperCase() === activeProvider);
295
+ }
296
+
297
+ // Apply fuzzy filter if query is present
298
+ if (query.trim()) {
299
+ // If user is searching, auto-switch to ALL tab to show global results
300
+ if (activeProvider !== ALL_TAB) {
301
+ this.activeTabIndex = 0;
302
+ this.updateTabBar();
303
+ baseModels = this.allModels;
304
+ }
305
+ this.filteredModels = fuzzyFilter(baseModels, query, ({ id, provider }) => `${id} ${provider}`);
306
+ } else {
307
+ this.filteredModels = baseModels;
308
+ }
309
+
150
310
  this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
151
311
  this.updateList();
152
312
  }
153
313
 
314
+ private applyTabFilter(): void {
315
+ const query = this.searchInput.getValue();
316
+ this.filterModels(query);
317
+ }
318
+
154
319
  private updateList(): void {
155
320
  this.listContainer.clear();
156
321
 
@@ -161,26 +326,44 @@ export class ModelSelectorComponent extends Container {
161
326
  );
162
327
  const endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length);
163
328
 
329
+ const activeProvider = this.getActiveProvider();
330
+ const showProvider = activeProvider === ALL_TAB;
331
+
164
332
  // Show visible slice of filtered models
165
333
  for (let i = startIndex; i < endIndex; i++) {
166
334
  const item = this.filteredModels[i];
167
335
  if (!item) continue;
168
336
 
169
337
  const isSelected = i === this.selectedIndex;
170
- const isCurrent = modelsAreEqual(this.currentModel, item.model);
338
+ const isDefault = modelsAreEqual(this.defaultModel, item.model);
339
+ const isSmol = modelsAreEqual(this.smolModel, item.model);
340
+ const isSlow = modelsAreEqual(this.slowModel, item.model);
341
+
342
+ // Build role badges (right-aligned style)
343
+ const badges: string[] = [];
344
+ if (isDefault) badges.push(theme.fg("success", "[ DEFAULT ]"));
345
+ if (isSmol) badges.push(theme.fg("warning", "[ SMOL ]"));
346
+ if (isSlow) badges.push(theme.fg("accent", "[ SLOW ]"));
347
+ const badgeText = badges.length > 0 ? ` ${badges.join(" ")}` : "";
171
348
 
172
349
  let line = "";
173
350
  if (isSelected) {
174
351
  const prefix = theme.fg("accent", "→ ");
175
- const modelText = `${item.id}`;
176
- const providerBadge = theme.fg("muted", `[${item.provider}]`);
177
- const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
178
- line = `${prefix + theme.fg("accent", modelText)} ${providerBadge}${checkmark}`;
352
+ const modelText = item.id;
353
+ if (showProvider) {
354
+ const providerBadge = theme.fg("muted", `[${item.provider}]`);
355
+ line = `${prefix}${theme.fg("accent", modelText)} ${providerBadge}${badgeText}`;
356
+ } else {
357
+ line = `${prefix}${theme.fg("accent", modelText)}${badgeText}`;
358
+ }
179
359
  } else {
180
- const modelText = ` ${item.id}`;
181
- const providerBadge = theme.fg("muted", `[${item.provider}]`);
182
- const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
183
- line = `${modelText} ${providerBadge}${checkmark}`;
360
+ const prefix = " ";
361
+ if (showProvider) {
362
+ const providerBadge = theme.fg("muted", `[${item.provider}]`);
363
+ line = `${prefix}${item.id} ${providerBadge}${badgeText}`;
364
+ } else {
365
+ line = `${prefix}${item.id}${badgeText}`;
366
+ }
184
367
  }
185
368
 
186
369
  this.listContainer.addChild(new Text(line, 0, 0));
@@ -194,7 +377,6 @@ export class ModelSelectorComponent extends Container {
194
377
 
195
378
  // Show error message or "no results" if empty
196
379
  if (this.errorMessage) {
197
- // Show error in red
198
380
  const errorLines = this.errorMessage.split("\n");
199
381
  for (const line of errorLines) {
200
382
  this.listContainer.addChild(new Text(theme.fg("error", line), 0, 0));
@@ -204,41 +386,159 @@ export class ModelSelectorComponent extends Container {
204
386
  }
205
387
  }
206
388
 
389
+ private openMenu(): void {
390
+ if (this.filteredModels.length === 0) return;
391
+
392
+ this.isMenuOpen = true;
393
+ this.menuSelectedIndex = 0;
394
+ this.updateMenu();
395
+ }
396
+
397
+ private closeMenu(): void {
398
+ this.isMenuOpen = false;
399
+ this.menuContainer.clear();
400
+ }
401
+
402
+ private updateMenu(): void {
403
+ this.menuContainer.clear();
404
+
405
+ const selectedModel = this.filteredModels[this.selectedIndex];
406
+ if (!selectedModel) return;
407
+
408
+ // Menu header
409
+ this.menuContainer.addChild(new Spacer(1));
410
+ this.menuContainer.addChild(new Text(theme.fg("border", "─".repeat(40)), 0, 0));
411
+ this.menuContainer.addChild(new Text(theme.fg("text", ` Action for: ${theme.bold(selectedModel.id)}`), 0, 0));
412
+ this.menuContainer.addChild(new Spacer(1));
413
+
414
+ // Menu options
415
+ for (let i = 0; i < MENU_ACTIONS.length; i++) {
416
+ const action = MENU_ACTIONS[i]!;
417
+ const isSelected = i === this.menuSelectedIndex;
418
+
419
+ let line: string;
420
+ if (isSelected) {
421
+ line = theme.fg("accent", ` → ${action.label}`);
422
+ } else {
423
+ line = theme.fg("muted", ` ${action.label}`);
424
+ }
425
+ this.menuContainer.addChild(new Text(line, 0, 0));
426
+ }
427
+
428
+ this.menuContainer.addChild(new Spacer(1));
429
+ this.menuContainer.addChild(new Text(theme.fg("dim", " Enter: confirm Esc: cancel"), 0, 0));
430
+ this.menuContainer.addChild(new Text(theme.fg("border", "─".repeat(40)), 0, 0));
431
+ }
432
+
207
433
  handleInput(keyData: string): void {
208
- // Up arrow - wrap to bottom when at top
434
+ if (this.isMenuOpen) {
435
+ this.handleMenuInput(keyData);
436
+ return;
437
+ }
438
+
439
+ // Tab bar navigation: Left/Right arrows or Tab/Shift+Tab
440
+ if (isArrowLeft(keyData) || isShiftTab(keyData)) {
441
+ this.activeTabIndex = (this.activeTabIndex - 1 + this.providers.length) % this.providers.length;
442
+ this.updateTabBar();
443
+ this.selectedIndex = 0;
444
+ this.applyTabFilter();
445
+ return;
446
+ }
447
+
448
+ if (isArrowRight(keyData) || isTab(keyData)) {
449
+ this.activeTabIndex = (this.activeTabIndex + 1) % this.providers.length;
450
+ this.updateTabBar();
451
+ this.selectedIndex = 0;
452
+ this.applyTabFilter();
453
+ return;
454
+ }
455
+
456
+ // Up arrow - navigate list (wrap to bottom when at top)
209
457
  if (isArrowUp(keyData)) {
210
458
  if (this.filteredModels.length === 0) return;
211
459
  this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;
212
460
  this.updateList();
461
+ return;
213
462
  }
214
- // Down arrow - wrap to top when at bottom
215
- else if (isArrowDown(keyData)) {
463
+
464
+ // Down arrow - navigate list (wrap to top when at bottom)
465
+ if (isArrowDown(keyData)) {
216
466
  if (this.filteredModels.length === 0) return;
217
467
  this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;
218
468
  this.updateList();
469
+ return;
219
470
  }
220
- // Enter
221
- else if (isEnter(keyData)) {
222
- const selectedModel = this.filteredModels[this.selectedIndex];
223
- if (selectedModel) {
224
- this.handleSelect(selectedModel.model);
471
+
472
+ // Enter - open context menu
473
+ if (isEnter(keyData)) {
474
+ if (this.filteredModels[this.selectedIndex]) {
475
+ this.openMenu();
225
476
  }
477
+ return;
226
478
  }
227
- // Escape
228
- else if (isEscape(keyData)) {
479
+
480
+ // Escape - close selector
481
+ if (isEscape(keyData)) {
229
482
  this.onCancelCallback();
483
+ return;
230
484
  }
485
+
231
486
  // Pass everything else to search input
232
- else {
233
- this.searchInput.handleInput(keyData);
234
- this.filterModels(this.searchInput.getValue());
487
+ this.searchInput.handleInput(keyData);
488
+ this.filterModels(this.searchInput.getValue());
489
+ }
490
+
491
+ private handleMenuInput(keyData: string): void {
492
+ // Up arrow - navigate menu
493
+ if (isArrowUp(keyData)) {
494
+ this.menuSelectedIndex = (this.menuSelectedIndex - 1 + MENU_ACTIONS.length) % MENU_ACTIONS.length;
495
+ this.updateMenu();
496
+ return;
497
+ }
498
+
499
+ // Down arrow - navigate menu
500
+ if (isArrowDown(keyData)) {
501
+ this.menuSelectedIndex = (this.menuSelectedIndex + 1) % MENU_ACTIONS.length;
502
+ this.updateMenu();
503
+ return;
504
+ }
505
+
506
+ // Enter - confirm selection
507
+ if (isEnter(keyData)) {
508
+ const selectedModel = this.filteredModels[this.selectedIndex];
509
+ const action = MENU_ACTIONS[this.menuSelectedIndex];
510
+ if (selectedModel && action) {
511
+ this.handleSelect(selectedModel.model, action.role);
512
+ this.closeMenu();
513
+ }
514
+ return;
515
+ }
516
+
517
+ // Escape - close menu only
518
+ if (isEscape(keyData)) {
519
+ this.closeMenu();
520
+ return;
235
521
  }
236
522
  }
237
523
 
238
- private handleSelect(model: Model<any>): void {
239
- // Save as new default
240
- this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
241
- this.onSelectCallback(model);
524
+ private handleSelect(model: Model<any>, role: ModelRole): void {
525
+ // Save to settings
526
+ this.settingsManager.setModelRole(role, `${model.provider}/${model.id}`);
527
+
528
+ // Update local state for UI
529
+ if (role === "default") {
530
+ this.defaultModel = model;
531
+ } else if (role === "smol") {
532
+ this.smolModel = model;
533
+ } else if (role === "slow") {
534
+ this.slowModel = model;
535
+ }
536
+
537
+ // Notify caller (for updating agent state if needed)
538
+ this.onSelectCallback(model, role);
539
+
540
+ // Update list to show new badges
541
+ this.updateList();
242
542
  }
243
543
 
244
544
  getSearchInput(): Input {
@@ -1,8 +1,8 @@
1
1
  import { getOAuthProviders, type OAuthProviderInfo } from "@oh-my-pi/pi-ai";
2
2
  import { Container, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
3
- import type { AuthStorage } from "../../../core/auth-storage.js";
4
- import { theme } from "../theme/theme.js";
5
- import { DynamicBorder } from "./dynamic-border.js";
3
+ import type { AuthStorage } from "../../../core/auth-storage";
4
+ import { theme } from "../theme/theme";
5
+ import { DynamicBorder } from "./dynamic-border";
6
6
 
7
7
  /**
8
8
  * Component that renders an OAuth provider selector
@@ -18,10 +18,10 @@ import {
18
18
  Spacer,
19
19
  Text,
20
20
  } from "@oh-my-pi/pi-tui";
21
- import { PluginManager } from "../../../core/plugins/manager.js";
22
- import type { InstalledPlugin, PluginSettingSchema } from "../../../core/plugins/types.js";
23
- import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.js";
24
- import { DynamicBorder } from "./dynamic-border.js";
21
+ import { PluginManager } from "../../../core/plugins/manager";
22
+ import type { InstalledPlugin, PluginSettingSchema } from "../../../core/plugins/types";
23
+ import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme";
24
+ import { DynamicBorder } from "./dynamic-border";
25
25
 
26
26
  // =============================================================================
27
27
  // Plugin List Component
@@ -1,6 +1,6 @@
1
1
  import { Container, type SelectItem, SelectList } from "@oh-my-pi/pi-tui";
2
- import { getSelectListTheme } from "../theme/theme.js";
3
- import { DynamicBorder } from "./dynamic-border.js";
2
+ import { getSelectListTheme } from "../theme/theme";
3
+ import { DynamicBorder } from "./dynamic-border";
4
4
 
5
5
  /**
6
6
  * Component that renders a queue mode selector with borders