@oh-my-pi/pi-coding-agent 11.0.3 → 11.2.0

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 (143) hide show
  1. package/CHANGELOG.md +199 -49
  2. package/README.md +1 -1
  3. package/docs/config-usage.md +3 -4
  4. package/docs/sdk.md +6 -5
  5. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  6. package/examples/sdk/README.md +1 -1
  7. package/package.json +19 -11
  8. package/src/cli/args.ts +11 -94
  9. package/src/cli/config-cli.ts +1 -1
  10. package/src/cli/file-processor.ts +3 -3
  11. package/src/cli/oclif-help.ts +26 -0
  12. package/src/cli/web-search-cli.ts +148 -0
  13. package/src/cli.ts +8 -2
  14. package/src/commands/commit.ts +36 -0
  15. package/src/commands/config.ts +51 -0
  16. package/src/commands/grep.ts +41 -0
  17. package/src/commands/index/index.ts +136 -0
  18. package/src/commands/jupyter.ts +32 -0
  19. package/src/commands/plugin.ts +70 -0
  20. package/src/commands/setup.ts +39 -0
  21. package/src/commands/shell.ts +29 -0
  22. package/src/commands/stats.ts +29 -0
  23. package/src/commands/update.ts +21 -0
  24. package/src/commands/web-search.ts +50 -0
  25. package/src/commit/agentic/index.ts +3 -2
  26. package/src/commit/agentic/tools/analyze-file.ts +1 -3
  27. package/src/commit/git/errors.ts +4 -6
  28. package/src/commit/pipeline.ts +3 -2
  29. package/src/config/keybindings.ts +1 -3
  30. package/src/config/model-registry.ts +89 -162
  31. package/src/config/settings-schema.ts +10 -0
  32. package/src/config.ts +202 -132
  33. package/src/exa/mcp-client.ts +8 -41
  34. package/src/export/html/index.ts +1 -1
  35. package/src/extensibility/extensions/loader.ts +7 -10
  36. package/src/extensibility/extensions/runner.ts +5 -15
  37. package/src/extensibility/extensions/types.ts +1 -1
  38. package/src/extensibility/hooks/runner.ts +6 -9
  39. package/src/index.ts +0 -1
  40. package/src/ipy/kernel.ts +10 -22
  41. package/src/lsp/clients/biome-client.ts +4 -7
  42. package/src/lsp/clients/lsp-linter-client.ts +4 -6
  43. package/src/lsp/index.ts +5 -4
  44. package/src/lsp/utils.ts +18 -0
  45. package/src/main.ts +86 -181
  46. package/src/mcp/json-rpc.ts +2 -2
  47. package/src/mcp/transports/http.ts +12 -49
  48. package/src/modes/components/armin.ts +1 -3
  49. package/src/modes/components/assistant-message.ts +4 -4
  50. package/src/modes/components/bash-execution.ts +5 -3
  51. package/src/modes/components/branch-summary-message.ts +1 -3
  52. package/src/modes/components/compaction-summary-message.ts +1 -3
  53. package/src/modes/components/custom-message.ts +4 -5
  54. package/src/modes/components/extensions/extension-dashboard.ts +10 -16
  55. package/src/modes/components/extensions/extension-list.ts +5 -5
  56. package/src/modes/components/footer.ts +1 -4
  57. package/src/modes/components/hook-editor.ts +7 -32
  58. package/src/modes/components/hook-message.ts +4 -5
  59. package/src/modes/components/model-selector.ts +2 -2
  60. package/src/modes/components/plugin-settings.ts +16 -20
  61. package/src/modes/components/python-execution.ts +5 -5
  62. package/src/modes/components/session-selector.ts +6 -7
  63. package/src/modes/components/settings-defs.ts +49 -40
  64. package/src/modes/components/settings-selector.ts +8 -17
  65. package/src/modes/components/skill-message.ts +1 -3
  66. package/src/modes/components/status-line-segment-editor.ts +1 -3
  67. package/src/modes/components/status-line.ts +1 -3
  68. package/src/modes/components/todo-reminder.ts +5 -7
  69. package/src/modes/components/tree-selector.ts +10 -12
  70. package/src/modes/components/ttsr-notification.ts +1 -3
  71. package/src/modes/components/user-message-selector.ts +2 -4
  72. package/src/modes/components/welcome.ts +6 -18
  73. package/src/modes/controllers/event-controller.ts +1 -0
  74. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  75. package/src/modes/controllers/input-controller.ts +7 -34
  76. package/src/modes/controllers/selector-controller.ts +3 -3
  77. package/src/modes/interactive-mode.ts +27 -1
  78. package/src/modes/rpc/rpc-client.ts +2 -5
  79. package/src/modes/rpc/rpc-mode.ts +2 -2
  80. package/src/modes/theme/theme.ts +2 -6
  81. package/src/modes/types.ts +1 -0
  82. package/src/modes/utils/ui-helpers.ts +6 -1
  83. package/src/patch/index.ts +1 -4
  84. package/src/prompts/agents/explore.md +1 -0
  85. package/src/prompts/agents/frontmatter.md +2 -1
  86. package/src/prompts/agents/init.md +1 -0
  87. package/src/prompts/agents/plan.md +1 -0
  88. package/src/prompts/agents/reviewer.md +1 -0
  89. package/src/prompts/system/subagent-submit-reminder.md +2 -0
  90. package/src/prompts/system/subagent-system-prompt.md +2 -0
  91. package/src/prompts/system/subagent-user-prompt.md +8 -0
  92. package/src/prompts/system/system-prompt.md +5 -3
  93. package/src/prompts/system/web-search.md +6 -4
  94. package/src/prompts/tools/task.md +216 -163
  95. package/src/sdk.ts +11 -110
  96. package/src/session/agent-session.ts +117 -83
  97. package/src/session/auth-storage.ts +10 -51
  98. package/src/session/messages.ts +17 -3
  99. package/src/session/session-manager.ts +30 -30
  100. package/src/session/streaming-output.ts +1 -1
  101. package/src/ssh/ssh-executor.ts +6 -3
  102. package/src/task/agents.ts +2 -0
  103. package/src/task/discovery.ts +1 -1
  104. package/src/task/executor.ts +5 -10
  105. package/src/task/index.ts +43 -23
  106. package/src/task/render.ts +67 -64
  107. package/src/task/template.ts +17 -34
  108. package/src/task/types.ts +49 -22
  109. package/src/tools/ask.ts +1 -3
  110. package/src/tools/bash.ts +1 -4
  111. package/src/tools/browser.ts +5 -7
  112. package/src/tools/exit-plan-mode.ts +1 -4
  113. package/src/tools/fetch.ts +1 -3
  114. package/src/tools/find.ts +4 -3
  115. package/src/tools/gemini-image.ts +24 -55
  116. package/src/tools/grep.ts +4 -4
  117. package/src/tools/index.ts +12 -14
  118. package/src/tools/notebook.ts +1 -5
  119. package/src/tools/python.ts +4 -3
  120. package/src/tools/read.ts +2 -4
  121. package/src/tools/render-utils.ts +23 -0
  122. package/src/tools/ssh.ts +8 -12
  123. package/src/tools/todo-write.ts +1 -4
  124. package/src/tools/tool-errors.ts +1 -4
  125. package/src/tools/write.ts +1 -3
  126. package/src/utils/external-editor.ts +59 -0
  127. package/src/utils/file-mentions.ts +39 -1
  128. package/src/utils/image-convert.ts +1 -1
  129. package/src/utils/image-resize.ts +4 -4
  130. package/src/web/search/auth.ts +3 -33
  131. package/src/web/search/index.ts +73 -139
  132. package/src/web/search/provider.ts +58 -0
  133. package/src/web/search/providers/anthropic.ts +53 -14
  134. package/src/web/search/providers/base.ts +22 -0
  135. package/src/web/search/providers/codex.ts +38 -16
  136. package/src/web/search/providers/exa.ts +30 -6
  137. package/src/web/search/providers/gemini.ts +56 -20
  138. package/src/web/search/providers/jina.ts +28 -5
  139. package/src/web/search/providers/perplexity.ts +103 -36
  140. package/src/web/search/render.ts +84 -74
  141. package/src/web/search/types.ts +285 -59
  142. package/src/migrations.ts +0 -175
  143. package/src/session/storage-migration.ts +0 -173
@@ -8,11 +8,9 @@ import type { CompactionSummaryMessage } from "../../session/messages";
8
8
  */
9
9
  export class CompactionSummaryMessageComponent extends Box {
10
10
  private expanded = false;
11
- private message: CompactionSummaryMessage;
12
11
 
13
- constructor(message: CompactionSummaryMessage) {
12
+ constructor(private readonly message: CompactionSummaryMessage) {
14
13
  super(1, 1, t => theme.bg("customMessageBg", t));
15
- this.message = message;
16
14
  this.updateDisplay();
17
15
  }
18
16
 
@@ -10,16 +10,15 @@ import type { CustomMessage } from "../../session/messages";
10
10
  * Uses distinct styling to differentiate from user messages.
11
11
  */
12
12
  export class CustomMessageComponent extends Container {
13
- private message: CustomMessage<unknown>;
14
- private customRenderer?: MessageRenderer;
15
13
  private box: Box;
16
14
  private customComponent?: Component;
17
15
  private _expanded = false;
18
16
 
19
- constructor(message: CustomMessage<unknown>, customRenderer?: MessageRenderer) {
17
+ constructor(
18
+ private readonly message: CustomMessage<unknown>,
19
+ private readonly customRenderer?: MessageRenderer,
20
+ ) {
20
21
  super();
21
- this.message = message;
22
- this.customRenderer = customRenderer;
23
22
 
24
23
  this.addChild(new Spacer(1));
25
24
 
@@ -33,17 +33,15 @@ export class ExtensionDashboard extends Container {
33
33
  private state!: DashboardState;
34
34
  private mainList!: ExtensionList;
35
35
  private inspector!: InspectorPanel;
36
- private settingsInstance: Settings | null;
37
- private cwd: string;
38
- private terminalHeight: number;
39
36
 
40
37
  public onClose?: () => void;
41
38
 
42
- private constructor(cwd: string, settingsInstance: Settings | null, terminalHeight: number) {
39
+ private constructor(
40
+ private readonly cwd: string,
41
+ private readonly settingsInstance: Settings | null,
42
+ private readonly terminalHeight: number,
43
+ ) {
43
44
  super();
44
- this.cwd = cwd;
45
- this.settingsInstance = settingsInstance;
46
- this.terminalHeight = terminalHeight;
47
45
  }
48
46
 
49
47
  static async create(
@@ -292,15 +290,11 @@ export class ExtensionDashboard extends Container {
292
290
  * Two-column body component for side-by-side rendering.
293
291
  */
294
292
  class TwoColumnBody implements Component {
295
- private leftPane: ExtensionList;
296
- private rightPane: InspectorPanel;
297
- private maxHeight: number;
298
-
299
- constructor(left: ExtensionList, right: InspectorPanel, maxHeight: number) {
300
- this.leftPane = left;
301
- this.rightPane = right;
302
- this.maxHeight = maxHeight;
303
- }
293
+ constructor(
294
+ private readonly leftPane: ExtensionList,
295
+ private readonly rightPane: InspectorPanel,
296
+ private readonly maxHeight: number,
297
+ ) {}
304
298
 
305
299
  render(width: number): string[] {
306
300
  const leftWidth = Math.floor(width * 0.5);
@@ -31,19 +31,19 @@ type ListItem =
31
31
  | { type: "extension"; item: Extension };
32
32
 
33
33
  export class ExtensionList implements Component {
34
- private extensions: Extension[] = [];
35
34
  private listItems: ListItem[] = [];
36
35
  private selectedIndex = 0;
37
36
  private scrollOffset = 0;
38
37
  private searchQuery = "";
39
38
  private focused = false;
40
- private callbacks: ExtensionListCallbacks;
41
39
  private masterSwitchProvider: string | null = null;
42
40
  private maxVisible: number;
43
41
 
44
- constructor(extensions: Extension[], callbacks: ExtensionListCallbacks = {}, maxVisible?: number) {
45
- this.extensions = extensions;
46
- this.callbacks = callbacks;
42
+ constructor(
43
+ private extensions: Extension[],
44
+ private readonly callbacks: ExtensionListCallbacks = {},
45
+ maxVisible?: number,
46
+ ) {
47
47
  this.masterSwitchProvider = callbacks.masterSwitchProvider ?? null;
48
48
  this.maxVisible = maxVisible ?? DEFAULT_MAX_VISIBLE;
49
49
  this.rebuildList();
@@ -42,16 +42,13 @@ async function findGitHeadPath(): Promise<{ path: string; content: string } | nu
42
42
  * Footer component that shows pwd, token stats, and context usage
43
43
  */
44
44
  export class FooterComponent implements Component {
45
- private session: AgentSession;
46
45
  private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
47
46
  private gitWatcher: fs.FSWatcher | null = null;
48
47
  private onBranchChange: (() => void) | null = null;
49
48
  private autoCompactEnabled: boolean = true;
50
49
  private extensionStatuses: Map<string, string> = new Map();
51
50
 
52
- constructor(session: AgentSession) {
53
- this.session = session;
54
- }
51
+ constructor(private readonly session: AgentSession) {}
55
52
 
56
53
  setAutoCompactEnabled(enabled: boolean): void {
57
54
  this.autoCompactEnabled = enabled;
@@ -2,12 +2,9 @@
2
2
  * Multi-line editor component for hooks.
3
3
  * Supports Ctrl+G for external editor.
4
4
  */
5
- import * as fs from "node:fs/promises";
6
- import * as os from "node:os";
7
- import * as path from "node:path";
8
5
  import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
9
- import { $env, Snowflake } from "@oh-my-pi/pi-utils";
10
6
  import { getEditorTheme, theme } from "../../modes/theme/theme";
7
+ import { getEditorCommand, openInEditor } from "../../utils/external-editor";
11
8
  import { DynamicBorder } from "./dynamic-border";
12
9
 
13
10
  export class HookEditorComponent extends Container {
@@ -47,10 +44,7 @@ export class HookEditorComponent extends Container {
47
44
  this.addChild(new Spacer(1));
48
45
 
49
46
  // Add hint
50
- const hasExternalEditor = !!($env.VISUAL || $env.EDITOR);
51
- const hint = hasExternalEditor
52
- ? "ctrl+enter submit esc cancel ctrl+g external editor"
53
- : "ctrl+enter submit esc cancel";
47
+ const hint = "ctrl+enter submit esc cancel ctrl+g external editor";
54
48
  this.addChild(new Text(theme.fg("dim", hint), 1, 0));
55
49
 
56
50
  this.addChild(new Spacer(1));
@@ -83,36 +77,17 @@ export class HookEditorComponent extends Container {
83
77
  }
84
78
 
85
79
  private async openExternalEditor(): Promise<void> {
86
- const editorCmd = $env.VISUAL || $env.EDITOR;
87
- if (!editorCmd) {
88
- return;
89
- }
80
+ const editorCmd = getEditorCommand();
81
+ if (!editorCmd) return;
90
82
 
91
83
  const currentText = this.editor.getText();
92
- const tmpFile = path.join(os.tmpdir(), `omp-hook-editor-${Snowflake.next()}.md`);
93
-
94
84
  try {
95
- await Bun.write(tmpFile, currentText);
96
85
  this.tui.stop();
97
-
98
- const [editor, ...editorArgs] = editorCmd.split(" ");
99
- const child = Bun.spawn([editor, ...editorArgs, tmpFile], {
100
- stdin: "inherit",
101
- stdout: "inherit",
102
- stderr: "inherit",
103
- });
104
- const exitCode = await child.exited;
105
-
106
- if (exitCode === 0) {
107
- const newContent = (await Bun.file(tmpFile).text()).replace(/\n$/, "");
108
- this.editor.setText(newContent);
86
+ const result = await openInEditor(editorCmd, currentText);
87
+ if (result !== null) {
88
+ this.editor.setText(result);
109
89
  }
110
90
  } finally {
111
- try {
112
- await fs.rm(tmpFile, { force: true });
113
- } catch {
114
- // Ignore cleanup errors
115
- }
116
91
  this.tui.start();
117
92
  this.tui.requestRender(true);
118
93
  }
@@ -10,16 +10,15 @@ import type { HookMessage } from "../../session/messages";
10
10
  * Uses distinct styling to differentiate from user messages.
11
11
  */
12
12
  export class HookMessageComponent extends Container {
13
- private message: HookMessage<unknown>;
14
- private customRenderer?: HookMessageRenderer;
15
13
  private box: Box;
16
14
  private customComponent?: Component;
17
15
  private _expanded = false;
18
16
 
19
- constructor(message: HookMessage<unknown>, customRenderer?: HookMessageRenderer) {
17
+ constructor(
18
+ private readonly message: HookMessage<unknown>,
19
+ private readonly customRenderer?: HookMessageRenderer,
20
+ ) {
20
21
  super();
21
- this.message = message;
22
- this.customRenderer = customRenderer;
23
22
 
24
23
  this.addChild(new Spacer(1));
25
24
 
@@ -74,7 +74,7 @@ export class ModelSelectorComponent extends Container {
74
74
  private modelRegistry: ModelRegistry;
75
75
  private onSelectCallback: (model: Model, role: ModelRole | null) => void;
76
76
  private onCancelCallback: () => void;
77
- private errorMessage?: string;
77
+ private errorMessage?: unknown;
78
78
  private tui: TUI;
79
79
  private scopedModels: ReadonlyArray<ScopedModelItem>;
80
80
  private temporaryOnly: boolean;
@@ -392,7 +392,7 @@ export class ModelSelectorComponent extends Container {
392
392
 
393
393
  // Show error message or "no results" if empty
394
394
  if (this.errorMessage) {
395
- const errorLines = this.errorMessage.split("\n");
395
+ const errorLines = String(this.errorMessage).split("\n");
396
396
  for (const line of errorLines) {
397
397
  this.listContainer.addChild(new Text(theme.fg("error", line), 0, 0));
398
398
  }
@@ -36,12 +36,13 @@ export interface PluginListCallbacks {
36
36
  * Selecting a plugin opens its detail view.
37
37
  */
38
38
  export class PluginListComponent extends Container {
39
- private selectList: SelectList;
40
- private plugins: InstalledPlugin[];
39
+ private readonly selectList: SelectList;
41
40
 
42
- constructor(plugins: InstalledPlugin[], callbacks: PluginListCallbacks) {
41
+ constructor(
42
+ private readonly plugins: InstalledPlugin[],
43
+ callbacks: PluginListCallbacks,
44
+ ) {
43
45
  super();
44
- this.plugins = plugins;
45
46
 
46
47
  // Title
47
48
  this.addChild(new DynamicBorder());
@@ -121,15 +122,13 @@ export interface PluginDetailCallbacks {
121
122
  */
122
123
  export class PluginDetailComponent extends Container {
123
124
  private settingsList!: SettingsList;
124
- private plugin: InstalledPlugin;
125
- private manager: PluginManager;
126
- private callbacks: PluginDetailCallbacks;
127
125
 
128
- constructor(plugin: InstalledPlugin, manager: PluginManager, callbacks: PluginDetailCallbacks) {
126
+ constructor(
127
+ private plugin: InstalledPlugin,
128
+ private readonly manager: PluginManager,
129
+ private readonly callbacks: PluginDetailCallbacks,
130
+ ) {
129
131
  super();
130
- this.plugin = plugin;
131
- this.manager = manager;
132
- this.callbacks = callbacks;
133
132
 
134
133
  void this.rebuild();
135
134
  }
@@ -335,19 +334,15 @@ class ConfigEnumSubmenu extends Container {
335
334
  */
336
335
  class ConfigInputSubmenu extends Container {
337
336
  private input: Input;
338
- private onSubmit: (value: string) => void;
339
- private onCancel: () => void;
340
337
 
341
338
  constructor(
342
339
  key: string,
343
340
  schema: PluginSettingSchema,
344
341
  currentValue: string,
345
- onSubmit: (value: string) => void,
346
- onCancel: () => void,
342
+ private readonly onSubmit: (value: string) => void,
343
+ private readonly onCancel: () => void,
347
344
  ) {
348
345
  super();
349
- this.onSubmit = onSubmit;
350
- this.onCancel = onCancel;
351
346
 
352
347
  this.addChild(new Text(theme.bold(theme.fg("accent", key)), 0, 0));
353
348
  if (schema.description) {
@@ -416,17 +411,18 @@ interface InputHandler {
416
411
  */
417
412
  export class PluginSettingsComponent extends Container {
418
413
  private manager: PluginManager;
419
- private callbacks: PluginSettingsCallbacks;
420
414
  private viewComponent: (Container & InputHandler) | null = null;
421
415
  // biome-ignore lint/correctness/noUnusedPrivateClassMembers: state tracking for view management
422
416
  private currentView: "list" | "detail" = "list";
423
417
  // biome-ignore lint/correctness/noUnusedPrivateClassMembers: state tracking for view management
424
418
  private currentPlugin: InstalledPlugin | null = null;
425
419
 
426
- constructor(cwd: string, callbacks: PluginSettingsCallbacks) {
420
+ constructor(
421
+ cwd: string,
422
+ private readonly callbacks: PluginSettingsCallbacks,
423
+ ) {
427
424
  super();
428
425
  this.manager = new PluginManager(cwd);
429
- this.callbacks = callbacks;
430
426
  this.showPluginList();
431
427
  }
432
428
 
@@ -12,7 +12,6 @@ import { truncateToVisualLines } from "./visual-truncate";
12
12
  const PREVIEW_LINES = 20;
13
13
 
14
14
  export class PythonExecutionComponent extends Container {
15
- private code: string;
16
15
  private outputLines: string[] = [];
17
16
  private status: "running" | "complete" | "cancelled" | "error" = "running";
18
17
  private exitCode: number | undefined = undefined;
@@ -20,7 +19,6 @@ export class PythonExecutionComponent extends Container {
20
19
  private truncation?: TruncationMeta;
21
20
  private expanded = false;
22
21
  private contentContainer: Container;
23
- private excludeFromContext: boolean;
24
22
 
25
23
  private formatHeader(colorKey: "dim" | "pythonMode"): Text {
26
24
  const prompt = theme.fg(colorKey, theme.bold(">>>"));
@@ -32,10 +30,12 @@ export class PythonExecutionComponent extends Container {
32
30
  return new Text(headerLines.join("\n"), 1, 0);
33
31
  }
34
32
 
35
- constructor(code: string, ui: TUI, excludeFromContext = false) {
33
+ constructor(
34
+ private readonly code: string,
35
+ ui: TUI,
36
+ private readonly excludeFromContext = false,
37
+ ) {
36
38
  super();
37
- this.code = code;
38
- this.excludeFromContext = excludeFromContext;
39
39
 
40
40
  const colorKey = this.excludeFromContext ? "dim" : "pythonMode";
41
41
  const borderColor = (str: string) => theme.fg(colorKey, str);
@@ -18,20 +18,19 @@ import { DynamicBorder } from "./dynamic-border";
18
18
  * Custom session list component with multi-line items and search
19
19
  */
20
20
  class SessionList implements Component {
21
- private allSessions: SessionInfo[] = [];
22
21
  private filteredSessions: SessionInfo[] = [];
23
22
  private selectedIndex: number = 0;
24
- private searchInput: Input;
25
- private showCwd = false;
23
+ private readonly searchInput: Input;
26
24
  public onSelect?: (sessionPath: string) => void;
27
25
  public onCancel?: () => void;
28
26
  public onExit: () => void = () => {};
29
27
  private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank)
30
28
 
31
- constructor(sessions: SessionInfo[], showCwd = false) {
32
- this.allSessions = sessions;
33
- this.filteredSessions = sessions;
34
- this.showCwd = showCwd;
29
+ constructor(
30
+ private readonly allSessions: SessionInfo[],
31
+ private readonly showCwd = false,
32
+ ) {
33
+ this.filteredSessions = allSessions;
35
34
  this.searchInput = new Input();
36
35
 
37
36
  // Handle Enter in search input - select current item
@@ -43,7 +43,7 @@ export interface EnumSettingDef extends BaseSettingDef {
43
43
 
44
44
  export interface SubmenuSettingDef extends BaseSettingDef {
45
45
  type: "submenu";
46
- getOptions: () => Array<{ value: string; label: string; description?: string }>;
46
+ get options(): OptionList;
47
47
  onPreview?: (value: string) => void;
48
48
  onPreviewCancel?: (originalValue: string) => void;
49
49
  }
@@ -62,11 +62,12 @@ const CONDITIONS: Record<string, () => boolean> = {
62
62
  // Submenu Option Providers
63
63
  // ═══════════════════════════════════════════════════════════════════════════
64
64
 
65
- type OptionProvider = () => Array<{ value: string; label: string; description?: string }>;
65
+ type OptionList = ReadonlyArray<{ value: string; label: string; description?: string }>;
66
+ type OptionProvider = (() => OptionList) | OptionList;
66
67
 
67
68
  const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
68
69
  // Retry max retries
69
- "retry.maxRetries": () => [
70
+ "retry.maxRetries": [
70
71
  { value: "1", label: "1 retry" },
71
72
  { value: "2", label: "2 retries" },
72
73
  { value: "3", label: "3 retries" },
@@ -74,7 +75,7 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
74
75
  { value: "10", label: "10 retries" },
75
76
  ],
76
77
  // Task max concurrency
77
- "task.maxConcurrency": () => [
78
+ "task.maxConcurrency": [
78
79
  { value: "0", label: "Unlimited" },
79
80
  { value: "1", label: "1 task" },
80
81
  { value: "2", label: "2 tasks" },
@@ -85,7 +86,7 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
85
86
  { value: "64", label: "64 tasks" },
86
87
  ],
87
88
  // Task max recursion depth
88
- "task.maxRecursionDepth": () => [
89
+ "task.maxRecursionDepth": [
89
90
  { value: "-1", label: "Unlimited" },
90
91
  { value: "0", label: "None" },
91
92
  { value: "1", label: "Single" },
@@ -93,21 +94,21 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
93
94
  { value: "3", label: "Triple" },
94
95
  ],
95
96
  // Todo max reminders
96
- "todo.reminders.max": () => [
97
+ "todo.reminders.max": [
97
98
  { value: "1", label: "1 reminder" },
98
99
  { value: "2", label: "2 reminders" },
99
100
  { value: "3", label: "3 reminders" },
100
101
  { value: "5", label: "5 reminders" },
101
102
  ],
102
103
  // Grep context
103
- "grep.contextBefore": () => [
104
+ "grep.contextBefore": [
104
105
  { value: "0", label: "0 lines" },
105
106
  { value: "1", label: "1 line" },
106
107
  { value: "2", label: "2 lines" },
107
108
  { value: "3", label: "3 lines" },
108
109
  { value: "5", label: "5 lines" },
109
110
  ],
110
- "grep.contextAfter": () => [
111
+ "grep.contextAfter": [
111
112
  { value: "0", label: "0 lines" },
112
113
  { value: "1", label: "1 line" },
113
114
  { value: "2", label: "2 lines" },
@@ -116,7 +117,7 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
116
117
  { value: "10", label: "10 lines" },
117
118
  ],
118
119
  // Ask timeout
119
- "ask.timeout": () => [
120
+ "ask.timeout": [
120
121
  { value: "0", label: "Disabled" },
121
122
  { value: "15", label: "15 seconds" },
122
123
  { value: "30", label: "30 seconds" },
@@ -124,14 +125,14 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
124
125
  { value: "120", label: "120 seconds" },
125
126
  ],
126
127
  // Edit fuzzy threshold
127
- "edit.fuzzyThreshold": () => [
128
+ "edit.fuzzyThreshold": [
128
129
  { value: "0.85", label: "0.85", description: "Lenient" },
129
130
  { value: "0.90", label: "0.90", description: "Moderate" },
130
131
  { value: "0.95", label: "0.95", description: "Default" },
131
132
  { value: "0.98", label: "0.98", description: "Strict" },
132
133
  ],
133
134
  // TTSR repeat gap
134
- "ttsr.repeatGap": () => [
135
+ "ttsr.repeatGap": [
135
136
  { value: "5", label: "5 messages" },
136
137
  { value: "10", label: "10 messages" },
137
138
  { value: "15", label: "15 messages" },
@@ -139,29 +140,38 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
139
140
  { value: "30", label: "30 messages" },
140
141
  ],
141
142
  // Provider options
142
- "providers.webSearch": () => [
143
+ "providers.webSearch": [
143
144
  { value: "auto", label: "Auto", description: "Priority: Exa > Perplexity > Anthropic" },
144
145
  { value: "exa", label: "Exa", description: "Requires EXA_API_KEY" },
145
146
  { value: "perplexity", label: "Perplexity", description: "Requires PERPLEXITY_API_KEY" },
146
147
  { value: "anthropic", label: "Anthropic", description: "Uses Anthropic web search" },
147
148
  ],
148
- "providers.image": () => [
149
+ "providers.image": [
149
150
  { value: "auto", label: "Auto", description: "Priority: OpenRouter > Gemini" },
150
151
  { value: "gemini", label: "Gemini", description: "Requires GEMINI_API_KEY" },
151
152
  { value: "openrouter", label: "OpenRouter", description: "Requires OPENROUTER_API_KEY" },
152
153
  ],
153
- "providers.kimiApiFormat": () => [
154
+ "providers.kimiApiFormat": [
154
155
  { value: "openai", label: "OpenAI", description: "api.kimi.com" },
155
156
  { value: "anthropic", label: "Anthropic", description: "api.moonshot.ai" },
156
157
  ],
158
+ // Default thinking level
159
+ defaultThinkingLevel: [
160
+ { value: "off", label: "off", description: "No reasoning" },
161
+ { value: "minimal", label: "minimal", description: "Very brief (~1k tokens)" },
162
+ { value: "low", label: "low", description: "Light (~2k tokens)" },
163
+ { value: "medium", label: "medium", description: "Moderate (~8k tokens)" },
164
+ { value: "high", label: "high", description: "Deep (~16k tokens)" },
165
+ { value: "xhigh", label: "xhigh", description: "Maximum (~32k tokens)" },
166
+ ],
157
167
  // Symbol preset
158
- symbolPreset: () => [
168
+ symbolPreset: [
159
169
  { value: "unicode", label: "Unicode", description: "Standard symbols (default)" },
160
170
  { value: "nerd", label: "Nerd Font", description: "Requires Nerd Font" },
161
171
  { value: "ascii", label: "ASCII", description: "Maximum compatibility" },
162
172
  ],
163
173
  // Status line preset
164
- "statusLine.preset": () => [
174
+ "statusLine.preset": [
165
175
  { value: "default", label: "Default", description: "Model, path, git, context, tokens, cost" },
166
176
  { value: "minimal", label: "Minimal", description: "Path and git only" },
167
177
  { value: "compact", label: "Compact", description: "Model, git, cost, context" },
@@ -171,7 +181,7 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
171
181
  { value: "custom", label: "Custom", description: "User-defined segments" },
172
182
  ],
173
183
  // Status line separator
174
- "statusLine.separator": () => [
184
+ "statusLine.separator": [
175
185
  { value: "powerline", label: "Powerline", description: "Solid arrows (Nerd Font)" },
176
186
  { value: "powerline-thin", label: "Thin chevron", description: "Thin arrows (Nerd Font)" },
177
187
  { value: "slash", label: "Slash", description: "Forward slashes" },
@@ -182,14 +192,23 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
182
192
  ],
183
193
  };
184
194
 
185
- const THINKING_DESCRIPTIONS: Record<string, string> = {
186
- off: "No reasoning",
187
- minimal: "Very brief (~1k tokens)",
188
- low: "Light (~2k tokens)",
189
- medium: "Moderate (~8k tokens)",
190
- high: "Deep (~16k tokens)",
191
- xhigh: "Maximum (~32k tokens)",
192
- };
195
+ function createSubmenuSettingDef(base: Omit<SettingDef, "type" | "options">, provider: OptionProvider): SettingDef {
196
+ if (typeof provider === "function") {
197
+ return {
198
+ ...base,
199
+ type: "submenu",
200
+ get options() {
201
+ return provider();
202
+ },
203
+ };
204
+ } else {
205
+ return {
206
+ ...base,
207
+ type: "submenu",
208
+ options: provider,
209
+ };
210
+ }
211
+ }
193
212
 
194
213
  // ═══════════════════════════════════════════════════════════════════════════
195
214
  // Schema to UI Conversion
@@ -215,18 +234,8 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
215
234
  // If marked as submenu, use submenu type
216
235
  if (ui.submenu) {
217
236
  const provider = OPTION_PROVIDERS[path];
218
- return {
219
- ...base,
220
- type: "submenu",
221
- getOptions:
222
- provider ??
223
- (() =>
224
- values.map(v => ({
225
- value: v,
226
- label: v,
227
- description: path === "defaultThinkingLevel" ? THINKING_DESCRIPTIONS[v] : undefined,
228
- }))),
229
- };
237
+ if (!provider) return null;
238
+ return createSubmenuSettingDef(base, provider);
230
239
  }
231
240
 
232
241
  return { ...base, type: "enum", values };
@@ -235,17 +244,17 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
235
244
  if (schemaType === "number" && ui.submenu) {
236
245
  const provider = OPTION_PROVIDERS[path];
237
246
  if (provider) {
238
- return { ...base, type: "submenu", getOptions: provider };
247
+ return createSubmenuSettingDef(base, provider);
239
248
  }
240
249
  }
241
250
 
242
251
  if (schemaType === "string" && ui.submenu) {
243
252
  const provider = OPTION_PROVIDERS[path];
244
253
  if (provider) {
245
- return { ...base, type: "submenu", getOptions: provider };
254
+ return createSubmenuSettingDef(base, provider);
246
255
  }
247
256
  // For theme etc, options will be injected at runtime
248
- return { ...base, type: "submenu", getOptions: () => [] };
257
+ return createSubmenuSettingDef(base, []);
249
258
  }
250
259
 
251
260
  return null;
@@ -41,20 +41,18 @@ function getTabBarTheme(): TabBarTheme {
41
41
  class SelectSubmenu extends Container {
42
42
  private selectList: SelectList;
43
43
  private previewText: Text | null = null;
44
- private getPreview: (() => string) | undefined;
45
44
 
46
45
  constructor(
47
46
  title: string,
48
47
  description: string,
49
- options: SelectItem[],
48
+ options: ReadonlyArray<SelectItem>,
50
49
  currentValue: string,
51
50
  onSelect: (value: string) => void,
52
51
  onCancel: () => void,
53
52
  onSelectionChange?: (value: string) => void,
54
- getPreview?: () => string,
53
+ private readonly getPreview?: () => string,
55
54
  ) {
56
55
  super();
57
- this.getPreview = getPreview;
58
56
 
59
57
  // Title
60
58
  this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0));
@@ -179,15 +177,12 @@ export class SettingsSelectorComponent extends Container {
179
177
  private statusPreviewText: Text | null = null;
180
178
  private currentTabId: SettingTab | "plugins" = "display";
181
179
 
182
- private context: SettingsRuntimeContext;
183
- private callbacks: SettingsCallbacks;
184
-
185
- constructor(context: SettingsRuntimeContext, callbacks: SettingsCallbacks) {
180
+ constructor(
181
+ private readonly context: SettingsRuntimeContext,
182
+ private readonly callbacks: SettingsCallbacks,
183
+ ) {
186
184
  super();
187
185
 
188
- this.context = context;
189
- this.callbacks = callbacks;
190
-
191
186
  // Add top border
192
187
  this.addChild(new DynamicBorder());
193
188
 
@@ -285,10 +280,6 @@ export class SettingsSelectorComponent extends Container {
285
280
  * Get the current value for a setting.
286
281
  */
287
282
  private getCurrentValue(def: SettingDef): unknown {
288
- // Special case: thinking level comes from runtime context
289
- if (def.path === "defaultThinkingLevel") {
290
- return this.context.thinkingLevel;
291
- }
292
283
  return settings.get(def.path);
293
284
  }
294
285
 
@@ -300,12 +291,12 @@ export class SettingsSelectorComponent extends Container {
300
291
  currentValue: string,
301
292
  done: (value?: string) => void,
302
293
  ): Container {
303
- let options = def.getOptions();
294
+ let options = def.options;
304
295
 
305
296
  // Special case: inject runtime options for thinking level
306
297
  if (def.path === "defaultThinkingLevel") {
307
298
  options = this.context.availableThinkingLevels.map(level => {
308
- const baseOpt = def.getOptions().find(o => o.value === level);
299
+ const baseOpt = options.find(o => o.value === level);
309
300
  return baseOpt || { value: level, label: level };
310
301
  });
311
302
  } else if (def.path === "theme") {