@oh-my-pi/pi-coding-agent 1.338.0 → 1.341.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 (42) hide show
  1. package/CHANGELOG.md +60 -1
  2. package/package.json +3 -3
  3. package/src/cli/args.ts +8 -0
  4. package/src/core/agent-session.ts +32 -14
  5. package/src/core/export-html/index.ts +48 -15
  6. package/src/core/export-html/template.html +3 -11
  7. package/src/core/mcp/client.ts +43 -16
  8. package/src/core/mcp/config.ts +152 -6
  9. package/src/core/mcp/index.ts +6 -2
  10. package/src/core/mcp/loader.ts +30 -3
  11. package/src/core/mcp/manager.ts +69 -10
  12. package/src/core/mcp/types.ts +9 -3
  13. package/src/core/model-resolver.ts +101 -0
  14. package/src/core/sdk.ts +65 -18
  15. package/src/core/session-manager.ts +117 -14
  16. package/src/core/settings-manager.ts +107 -19
  17. package/src/core/title-generator.ts +94 -0
  18. package/src/core/tools/bash.ts +1 -2
  19. package/src/core/tools/edit-diff.ts +2 -2
  20. package/src/core/tools/edit.ts +43 -5
  21. package/src/core/tools/grep.ts +3 -2
  22. package/src/core/tools/index.ts +73 -13
  23. package/src/core/tools/lsp/client.ts +45 -20
  24. package/src/core/tools/lsp/config.ts +708 -34
  25. package/src/core/tools/lsp/index.ts +423 -23
  26. package/src/core/tools/lsp/types.ts +5 -0
  27. package/src/core/tools/task/bundled-agents/explore.md +1 -1
  28. package/src/core/tools/task/bundled-agents/reviewer.md +1 -1
  29. package/src/core/tools/task/model-resolver.ts +52 -3
  30. package/src/core/tools/write.ts +67 -4
  31. package/src/index.ts +5 -0
  32. package/src/main.ts +23 -2
  33. package/src/modes/interactive/components/model-selector.ts +96 -18
  34. package/src/modes/interactive/components/session-selector.ts +20 -7
  35. package/src/modes/interactive/components/settings-defs.ts +59 -2
  36. package/src/modes/interactive/components/settings-selector.ts +8 -11
  37. package/src/modes/interactive/components/tool-execution.ts +18 -0
  38. package/src/modes/interactive/components/tree-selector.ts +2 -2
  39. package/src/modes/interactive/components/welcome.ts +40 -3
  40. package/src/modes/interactive/interactive-mode.ts +87 -10
  41. package/src/core/export-html/vendor/highlight.min.js +0 -1213
  42. package/src/core/export-html/vendor/marked.min.js +0 -6
@@ -2,6 +2,7 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
2
  import { Type } from "@sinclair/typebox";
3
3
  import { mkdir, writeFile } from "fs/promises";
4
4
  import { dirname } from "path";
5
+ import type { FileDiagnosticsResult, FileFormatResult } from "./lsp/index.js";
5
6
  import { resolveToCwd } from "./path-utils.js";
6
7
 
7
8
  const writeSchema = Type.Object({
@@ -9,7 +10,30 @@ const writeSchema = Type.Object({
9
10
  content: Type.String({ description: "Content to write to the file" }),
10
11
  });
11
12
 
12
- export function createWriteTool(cwd: string): AgentTool<typeof writeSchema> {
13
+ /** Options for creating the write tool */
14
+ export interface WriteToolOptions {
15
+ /** Callback to format file using LSP after writing */
16
+ formatOnWrite?: (absolutePath: string) => Promise<FileFormatResult>;
17
+ /** Callback to get LSP diagnostics after writing a file */
18
+ getDiagnostics?: (absolutePath: string) => Promise<FileDiagnosticsResult>;
19
+ }
20
+
21
+ /** Details returned by the write tool for TUI rendering */
22
+ export interface WriteToolDetails {
23
+ /** Whether the file was formatted */
24
+ wasFormatted: boolean;
25
+ /** Format result (if available) */
26
+ formatResult?: FileFormatResult;
27
+ /** Whether LSP diagnostics were retrieved */
28
+ hasDiagnostics: boolean;
29
+ /** Diagnostic result (if available) */
30
+ diagnostics?: FileDiagnosticsResult;
31
+ }
32
+
33
+ export function createWriteTool(
34
+ cwd: string,
35
+ options: WriteToolOptions = {},
36
+ ): AgentTool<typeof writeSchema, WriteToolDetails> {
13
37
  return {
14
38
  name: "write",
15
39
  label: "Write",
@@ -30,7 +54,7 @@ Usage:
30
54
  const absolutePath = resolveToCwd(path, cwd);
31
55
  const dir = dirname(absolutePath);
32
56
 
33
- return new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>(
57
+ return new Promise<{ content: Array<{ type: "text"; text: string }>; details: WriteToolDetails }>(
34
58
  (resolve, reject) => {
35
59
  // Check if already aborted
36
60
  if (signal?.aborted) {
@@ -74,9 +98,48 @@ Usage:
74
98
  signal.removeEventListener("abort", onAbort);
75
99
  }
76
100
 
101
+ // Format file if callback provided (before diagnostics)
102
+ let formatResult: FileFormatResult | undefined;
103
+ if (options.formatOnWrite) {
104
+ try {
105
+ formatResult = await options.formatOnWrite(absolutePath);
106
+ } catch {
107
+ // Ignore formatting errors - don't fail the write
108
+ }
109
+ }
110
+
111
+ // Get LSP diagnostics if callback provided (after formatting)
112
+ let diagnosticsResult: FileDiagnosticsResult | undefined;
113
+ if (options.getDiagnostics) {
114
+ try {
115
+ diagnosticsResult = await options.getDiagnostics(absolutePath);
116
+ } catch {
117
+ // Ignore diagnostics errors - don't fail the write
118
+ }
119
+ }
120
+
121
+ // Build result text
122
+ let resultText = `Successfully wrote ${content.length} bytes to ${path}`;
123
+
124
+ // Note if file was formatted
125
+ if (formatResult?.formatted) {
126
+ resultText += ` (formatted by ${formatResult.serverName})`;
127
+ }
128
+
129
+ // Append diagnostics if available and there are issues
130
+ if (diagnosticsResult?.available && diagnosticsResult.diagnostics.length > 0) {
131
+ resultText += `\n\nLSP Diagnostics (${diagnosticsResult.summary}):\n`;
132
+ resultText += diagnosticsResult.diagnostics.map((d) => ` ${d}`).join("\n");
133
+ }
134
+
77
135
  resolve({
78
- content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
79
- details: undefined,
136
+ content: [{ type: "text", text: resultText }],
137
+ details: {
138
+ wasFormatted: formatResult?.formatted ?? false,
139
+ formatResult,
140
+ hasDiagnostics: diagnosticsResult?.available ?? false,
141
+ diagnostics: diagnosticsResult,
142
+ },
80
143
  });
81
144
  } catch (error: any) {
82
145
  // Clean up abort handler
package/src/index.ts CHANGED
@@ -123,6 +123,7 @@ export {
123
123
  } from "./core/session-manager.js";
124
124
  export {
125
125
  type CompactionSettings,
126
+ type LspSettings,
126
127
  type RetrySettings,
127
128
  type Settings,
128
129
  SettingsManager,
@@ -143,6 +144,7 @@ export {
143
144
  export {
144
145
  type BashToolDetails,
145
146
  bashTool,
147
+ type CodingToolsOptions,
146
148
  codingTools,
147
149
  editTool,
148
150
  type FindToolDetails,
@@ -154,8 +156,11 @@ export {
154
156
  type ReadToolDetails,
155
157
  readTool,
156
158
  type TruncationResult,
159
+ type WriteToolDetails,
160
+ type WriteToolOptions,
157
161
  writeTool,
158
162
  } from "./core/tools/index.js";
163
+ export type { FileDiagnosticsResult } from "./core/tools/lsp/index.js";
159
164
  // Main entry point
160
165
  export { main } from "./main.js";
161
166
  // UI components for hooks and custom tools
package/src/main.ts CHANGED
@@ -62,11 +62,20 @@ async function runInteractiveMode(
62
62
  initialMessages: string[],
63
63
  customTools: LoadedCustomTool[],
64
64
  setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void,
65
+ lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }> | undefined,
65
66
  initialMessage?: string,
66
67
  initialImages?: ImageContent[],
67
68
  fdPath: string | undefined = undefined,
68
69
  ): Promise<void> {
69
- const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath);
70
+ const mode = new InteractiveMode(
71
+ session,
72
+ version,
73
+ changelogMarkdown,
74
+ customTools,
75
+ setToolUIContext,
76
+ lspServers,
77
+ fdPath,
78
+ );
70
79
 
71
80
  await mode.init();
72
81
 
@@ -347,6 +356,17 @@ export async function main(args: string[]) {
347
356
 
348
357
  const settingsManager = SettingsManager.create(cwd);
349
358
  time("SettingsManager.create");
359
+
360
+ // Apply model role overrides from CLI args or env vars (ephemeral, not persisted)
361
+ const smolModel = parsed.smol ?? process.env.PI_SMOL_MODEL;
362
+ const slowModel = parsed.slow ?? process.env.PI_SLOW_MODEL;
363
+ if (smolModel || slowModel) {
364
+ const roleOverrides: Record<string, string> = {};
365
+ if (smolModel) roleOverrides.smol = smolModel;
366
+ if (slowModel) roleOverrides.slow = slowModel;
367
+ settingsManager.applyOverrides({ modelRoles: roleOverrides });
368
+ }
369
+
350
370
  initTheme(settingsManager.getTheme(), isInteractive);
351
371
  time("initTheme");
352
372
 
@@ -393,7 +413,7 @@ export async function main(args: string[]) {
393
413
  }
394
414
 
395
415
  time("buildSessionOptions");
396
- const { session, customToolsResult, modelFallbackMessage } = await createAgentSession(sessionOptions);
416
+ const { session, customToolsResult, modelFallbackMessage, lspServers } = await createAgentSession(sessionOptions);
397
417
  time("createAgentSession");
398
418
 
399
419
  if (!isInteractive && !session.model) {
@@ -449,6 +469,7 @@ export async function main(args: string[]) {
449
469
  parsed.messages,
450
470
  customToolsResult.tools,
451
471
  customToolsResult.setUIContext,
472
+ lspServers,
452
473
  initialMessage,
453
474
  initialImages,
454
475
  fdPath,
@@ -1,6 +1,7 @@
1
1
  import { type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
2
2
  import { Container, Input, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
3
3
  import type { ModelRegistry } from "../../../core/model-registry.js";
4
+ import { parseModelString } from "../../../core/model-resolver.js";
4
5
  import type { SettingsManager } from "../../../core/settings-manager.js";
5
6
  import { fuzzyFilter } from "../../../utils/fuzzy.js";
6
7
  import { theme } from "../theme/theme.js";
@@ -18,7 +19,11 @@ interface ScopedModelItem {
18
19
  }
19
20
 
20
21
  /**
21
- * Component that renders a model selector with search
22
+ * Component that renders a model selector with search.
23
+ * - Enter: Set selected model as default
24
+ * - S: Set selected model as smol
25
+ * - L: Set selected model as slow
26
+ * - Escape: Close selector
22
27
  */
23
28
  export class ModelSelectorComponent extends Container {
24
29
  private searchInput: Input;
@@ -27,9 +32,12 @@ export class ModelSelectorComponent extends Container {
27
32
  private filteredModels: ModelItem[] = [];
28
33
  private selectedIndex: number = 0;
29
34
  private currentModel?: Model<any>;
35
+ private defaultModel?: Model<any>;
36
+ private smolModel?: Model<any>;
37
+ private slowModel?: Model<any>;
30
38
  private settingsManager: SettingsManager;
31
39
  private modelRegistry: ModelRegistry;
32
- private onSelectCallback: (model: Model<any>) => void;
40
+ private onSelectCallback: (model: Model<any>, role: string) => void;
33
41
  private onCancelCallback: () => void;
34
42
  private errorMessage?: string;
35
43
  private tui: TUI;
@@ -41,7 +49,7 @@ export class ModelSelectorComponent extends Container {
41
49
  settingsManager: SettingsManager,
42
50
  modelRegistry: ModelRegistry,
43
51
  scopedModels: ReadonlyArray<ScopedModelItem>,
44
- onSelect: (model: Model<any>) => void,
52
+ onSelect: (model: Model<any>, role: string) => void,
45
53
  onCancel: () => void,
46
54
  ) {
47
55
  super();
@@ -54,24 +62,28 @@ export class ModelSelectorComponent extends Container {
54
62
  this.onSelectCallback = onSelect;
55
63
  this.onCancelCallback = onCancel;
56
64
 
65
+ // Load current role assignments from settings
66
+ this._loadRoleModels();
67
+
57
68
  // Add top border
58
69
  this.addChild(new DynamicBorder());
59
70
  this.addChild(new Spacer(1));
60
71
 
61
- // Add hint about model filtering
72
+ // Add hint about model filtering and key bindings
62
73
  const hintText =
63
74
  scopedModels.length > 0
64
75
  ? "Showing models from --models scope"
65
76
  : "Only showing models with configured API keys (see README for details)";
66
77
  this.addChild(new Text(theme.fg("warning", hintText), 0, 0));
78
+ this.addChild(new Text(theme.fg("muted", "Enter: default S: smol L: slow Esc: close"), 0, 0));
67
79
  this.addChild(new Spacer(1));
68
80
 
69
81
  // Create search input
70
82
  this.searchInput = new Input();
71
83
  this.searchInput.onSubmit = () => {
72
- // Enter on search input selects the first filtered item
84
+ // Enter on search input sets as default
73
85
  if (this.filteredModels[this.selectedIndex]) {
74
- this.handleSelect(this.filteredModels[this.selectedIndex].model);
86
+ this.handleSelect(this.filteredModels[this.selectedIndex].model, "default");
75
87
  }
76
88
  };
77
89
  this.addChild(this.searchInput);
@@ -95,6 +107,38 @@ export class ModelSelectorComponent extends Container {
95
107
  });
96
108
  }
97
109
 
110
+ private _loadRoleModels(): void {
111
+ const roles = this.settingsManager.getModelRoles();
112
+ const allModels = this.modelRegistry.getAll();
113
+
114
+ // Load default model
115
+ const defaultStr = roles.default;
116
+ if (defaultStr) {
117
+ const parsed = parseModelString(defaultStr);
118
+ if (parsed) {
119
+ this.defaultModel = allModels.find((m) => m.provider === parsed.provider && m.id === parsed.id);
120
+ }
121
+ }
122
+
123
+ // Load smol model
124
+ const smolStr = roles.smol;
125
+ if (smolStr) {
126
+ const parsed = parseModelString(smolStr);
127
+ if (parsed) {
128
+ this.smolModel = allModels.find((m) => m.provider === parsed.provider && m.id === parsed.id);
129
+ }
130
+ }
131
+
132
+ // Load slow model
133
+ const slowStr = roles.slow;
134
+ if (slowStr) {
135
+ const parsed = parseModelString(slowStr);
136
+ if (parsed) {
137
+ this.slowModel = allModels.find((m) => m.provider === parsed.provider && m.id === parsed.id);
138
+ }
139
+ }
140
+ }
141
+
98
142
  private async loadModels(): Promise<void> {
99
143
  let models: ModelItem[];
100
144
 
@@ -167,20 +211,26 @@ export class ModelSelectorComponent extends Container {
167
211
  if (!item) continue;
168
212
 
169
213
  const isSelected = i === this.selectedIndex;
170
- const isCurrent = modelsAreEqual(this.currentModel, item.model);
214
+ const isDefault = modelsAreEqual(this.defaultModel, item.model);
215
+ const isSmol = modelsAreEqual(this.smolModel, item.model);
216
+ const isSlow = modelsAreEqual(this.slowModel, item.model);
217
+
218
+ // Build role markers: ✓ for default, ⚡ for smol, 🧠 for slow
219
+ let markers = "";
220
+ if (isDefault) markers += theme.fg("success", " ✓");
221
+ if (isSmol) markers += theme.fg("warning", " ⚡");
222
+ if (isSlow) markers += theme.fg("accent", " 🧠");
171
223
 
172
224
  let line = "";
173
225
  if (isSelected) {
174
226
  const prefix = theme.fg("accent", "→ ");
175
227
  const modelText = `${item.id}`;
176
228
  const providerBadge = theme.fg("muted", `[${item.provider}]`);
177
- const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
178
- line = `${prefix + theme.fg("accent", modelText)} ${providerBadge}${checkmark}`;
229
+ line = `${prefix + theme.fg("accent", modelText)} ${providerBadge}${markers}`;
179
230
  } else {
180
231
  const modelText = ` ${item.id}`;
181
232
  const providerBadge = theme.fg("muted", `[${item.provider}]`);
182
- const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
183
- line = `${modelText} ${providerBadge}${checkmark}`;
233
+ line = `${modelText} ${providerBadge}${markers}`;
184
234
  }
185
235
 
186
236
  this.listContainer.addChild(new Text(line, 0, 0));
@@ -217,14 +267,28 @@ export class ModelSelectorComponent extends Container {
217
267
  this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;
218
268
  this.updateList();
219
269
  }
220
- // Enter
270
+ // Enter - set as default model (don't close)
221
271
  else if (isEnter(keyData)) {
222
272
  const selectedModel = this.filteredModels[this.selectedIndex];
223
273
  if (selectedModel) {
224
- this.handleSelect(selectedModel.model);
274
+ this.handleSelect(selectedModel.model, "default");
225
275
  }
226
276
  }
227
- // Escape
277
+ // S key - set as smol model (don't close)
278
+ else if (keyData === "s" || keyData === "S") {
279
+ const selectedModel = this.filteredModels[this.selectedIndex];
280
+ if (selectedModel) {
281
+ this.handleSelect(selectedModel.model, "smol");
282
+ }
283
+ }
284
+ // L key - set as slow model (don't close)
285
+ else if (keyData === "l" || keyData === "L") {
286
+ const selectedModel = this.filteredModels[this.selectedIndex];
287
+ if (selectedModel) {
288
+ this.handleSelect(selectedModel.model, "slow");
289
+ }
290
+ }
291
+ // Escape - close
228
292
  else if (isEscape(keyData)) {
229
293
  this.onCancelCallback();
230
294
  }
@@ -235,10 +299,24 @@ export class ModelSelectorComponent extends Container {
235
299
  }
236
300
  }
237
301
 
238
- private handleSelect(model: Model<any>): void {
239
- // Save as new default
240
- this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
241
- this.onSelectCallback(model);
302
+ private handleSelect(model: Model<any>, role: string): void {
303
+ // Save to settings
304
+ this.settingsManager.setModelRole(role, `${model.provider}/${model.id}`);
305
+
306
+ // Update local state for UI
307
+ if (role === "default") {
308
+ this.defaultModel = model;
309
+ } else if (role === "smol") {
310
+ this.smolModel = model;
311
+ } else if (role === "slow") {
312
+ this.slowModel = model;
313
+ }
314
+
315
+ // Notify caller (for updating agent state if needed)
316
+ this.onSelectCallback(model, role);
317
+
318
+ // Update list to show new markers
319
+ this.updateList();
242
320
  }
243
321
 
244
322
  getSearchInput(): Input {
@@ -90,7 +90,7 @@ class SessionList implements Component {
90
90
  );
91
91
  const endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length);
92
92
 
93
- // Render visible sessions (2 lines per session + blank line)
93
+ // Render visible sessions (2-3 lines per session + blank line)
94
94
  for (let i = startIndex; i < endIndex; i++) {
95
95
  const session = this.filteredSessions[i];
96
96
  const isSelected = i === this.selectedIndex;
@@ -98,19 +98,32 @@ class SessionList implements Component {
98
98
  // Normalize first message to single line
99
99
  const normalizedMessage = session.firstMessage.replace(/\n/g, " ").trim();
100
100
 
101
- // First line: cursor + message (truncate to visible width)
101
+ // First line: cursor + title (or first message if no title)
102
102
  const cursor = isSelected ? theme.fg("accent", "› ") : " ";
103
- const maxMsgWidth = width - 2; // Account for cursor (2 visible chars)
104
- const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth, "...");
105
- const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
103
+ const maxWidth = width - 2; // Account for cursor (2 visible chars)
104
+
105
+ if (session.title) {
106
+ // Has title: show title on first line, dimmed first message on second line
107
+ const truncatedTitle = truncateToWidth(session.title, maxWidth, "...");
108
+ const titleLine = cursor + (isSelected ? theme.bold(truncatedTitle) : truncatedTitle);
109
+ lines.push(titleLine);
110
+
111
+ // Second line: dimmed first message preview
112
+ const truncatedPreview = truncateToWidth(normalizedMessage, maxWidth, "...");
113
+ lines.push(` ${theme.fg("dim", truncatedPreview)}`);
114
+ } else {
115
+ // No title: show first message as main line
116
+ const truncatedMsg = truncateToWidth(normalizedMessage, maxWidth, "...");
117
+ const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
118
+ lines.push(messageLine);
119
+ }
106
120
 
107
- // Second line: metadata (dimmed) - also truncate for safety
121
+ // Metadata line: date + message count
108
122
  const modified = formatDate(session.modified);
109
123
  const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`;
110
124
  const metadata = ` ${modified} · ${msgCount}`;
111
125
  const metadataLine = theme.fg("dim", truncateToWidth(metadata, width, ""));
112
126
 
113
- lines.push(messageLine);
114
127
  lines.push(metadataLine);
115
128
  lines.push(""); // Blank line between sessions
116
129
  }
@@ -20,7 +20,7 @@ interface BaseSettingDef {
20
20
  id: string;
21
21
  label: string;
22
22
  description: string;
23
- tab: "config" | "exa";
23
+ tab: string;
24
24
  }
25
25
 
26
26
  // Boolean toggle setting
@@ -99,6 +99,16 @@ export const SETTINGS_DEFS: SettingDef[] = [
99
99
  get: (sm) => sm.getQueueMode(),
100
100
  set: (sm, v) => sm.setQueueMode(v as "all" | "one-at-a-time"), // Also handled in session
101
101
  },
102
+ {
103
+ id: "interruptMode",
104
+ tab: "config",
105
+ type: "enum",
106
+ label: "Interrupt mode",
107
+ description: "When to process queued messages: immediately (interrupt tools) or wait for turn to complete",
108
+ values: ["immediate", "wait"],
109
+ get: (sm) => sm.getInterruptMode(),
110
+ set: (sm, v) => sm.setInterruptMode(v as "immediate" | "wait"), // Also handled in session
111
+ },
102
112
  {
103
113
  id: "hideThinking",
104
114
  tab: "config",
@@ -126,6 +136,24 @@ export const SETTINGS_DEFS: SettingDef[] = [
126
136
  get: (sm) => sm.getBashInterceptorEnabled(),
127
137
  set: (sm, v) => sm.setBashInterceptorEnabled(v),
128
138
  },
139
+ {
140
+ id: "mcpProjectConfig",
141
+ tab: "config",
142
+ type: "boolean",
143
+ label: "MCP project config",
144
+ description: "Load .mcp.json/mcp.json from project root",
145
+ get: (sm) => sm.getMCPProjectConfigEnabled(),
146
+ set: (sm, v) => sm.setMCPProjectConfigEnabled(v),
147
+ },
148
+ {
149
+ id: "editFuzzyMatch",
150
+ tab: "config",
151
+ type: "boolean",
152
+ label: "Edit fuzzy match",
153
+ description: "Accept high-confidence fuzzy matches for whitespace/indentation differences",
154
+ get: (sm) => sm.getEditFuzzyMatch(),
155
+ set: (sm, v) => sm.setEditFuzzyMatch(v),
156
+ },
129
157
  {
130
158
  id: "thinkingLevel",
131
159
  tab: "config",
@@ -152,6 +180,35 @@ export const SETTINGS_DEFS: SettingDef[] = [
152
180
  getOptions: () => [], // Filled dynamically from context
153
181
  },
154
182
 
183
+ // LSP tab
184
+ {
185
+ id: "lspFormatOnWrite",
186
+ tab: "lsp",
187
+ type: "boolean",
188
+ label: "Format on write",
189
+ description: "Automatically format code files using LSP after writing",
190
+ get: (sm) => sm.getLspFormatOnWrite(),
191
+ set: (sm, v) => sm.setLspFormatOnWrite(v),
192
+ },
193
+ {
194
+ id: "lspDiagnosticsOnWrite",
195
+ tab: "lsp",
196
+ type: "boolean",
197
+ label: "Diagnostics on write",
198
+ description: "Return LSP diagnostics (errors/warnings) after writing code files",
199
+ get: (sm) => sm.getLspDiagnosticsOnWrite(),
200
+ set: (sm, v) => sm.setLspDiagnosticsOnWrite(v),
201
+ },
202
+ {
203
+ id: "lspDiagnosticsOnEdit",
204
+ tab: "lsp",
205
+ type: "boolean",
206
+ label: "Diagnostics on edit",
207
+ description: "Return LSP diagnostics (errors/warnings) after editing code files",
208
+ get: (sm) => sm.getLspDiagnosticsOnEdit(),
209
+ set: (sm, v) => sm.setLspDiagnosticsOnEdit(v),
210
+ },
211
+
155
212
  // Exa tab
156
213
  {
157
214
  id: "exaEnabled",
@@ -210,7 +267,7 @@ export const SETTINGS_DEFS: SettingDef[] = [
210
267
  ];
211
268
 
212
269
  /** Get settings for a specific tab */
213
- export function getSettingsForTab(tab: "config" | "exa"): SettingDef[] {
270
+ export function getSettingsForTab(tab: string): SettingDef[] {
214
271
  return SETTINGS_DEFS.filter((def) => def.tab === tab);
215
272
  }
216
273
 
@@ -93,10 +93,11 @@ class SelectSubmenu extends Container {
93
93
  }
94
94
  }
95
95
 
96
- type TabId = "config" | "exa" | "plugins";
96
+ type TabId = string;
97
97
 
98
98
  const SETTINGS_TABS: Tab[] = [
99
99
  { id: "config", label: "Config" },
100
+ { id: "lsp", label: "LSP" },
100
101
  { id: "exa", label: "Exa" },
101
102
  { id: "plugins", label: "Plugins" },
102
103
  ];
@@ -189,14 +190,10 @@ export class SettingsSelectorComponent extends Container {
189
190
  const bottomBorder = this.children[this.children.length - 1];
190
191
  this.removeChild(bottomBorder);
191
192
 
192
- switch (tabId) {
193
- case "config":
194
- case "exa":
195
- this.showSettingsTab(tabId);
196
- break;
197
- case "plugins":
198
- this.showPluginsTab();
199
- break;
193
+ if (tabId === "plugins") {
194
+ this.showPluginsTab();
195
+ } else {
196
+ this.showSettingsTab(tabId);
200
197
  }
201
198
 
202
199
  // Re-add bottom border
@@ -301,9 +298,9 @@ export class SettingsSelectorComponent extends Container {
301
298
  }
302
299
 
303
300
  /**
304
- * Show a settings tab (config or exa) using definitions.
301
+ * Show a settings tab using definitions.
305
302
  */
306
- private showSettingsTab(tabId: "config" | "exa"): void {
303
+ private showSettingsTab(tabId: string): void {
307
304
  const defs = getSettingsForTab(tabId);
308
305
  const items: SettingItem[] = [];
309
306
 
@@ -501,6 +501,24 @@ export class ToolExecutionComponent extends Container {
501
501
  text += theme.fg("toolOutput", `\n... (${remaining} more lines, ${totalLines} total)`);
502
502
  }
503
503
  }
504
+
505
+ // Show LSP diagnostics if available
506
+ if (this.result?.details?.diagnostics?.available) {
507
+ const diag = this.result.details.diagnostics;
508
+ if (diag.diagnostics.length > 0) {
509
+ const icon = diag.hasErrors ? theme.fg("error", "●") : theme.fg("warning", "●");
510
+ text += `\n\n${icon} ${theme.fg("toolTitle", "LSP Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
511
+ const maxDiags = this.expanded ? diag.diagnostics.length : 5;
512
+ const displayDiags = diag.diagnostics.slice(0, maxDiags);
513
+ for (const d of displayDiags) {
514
+ const color = d.includes("[error]") ? "error" : d.includes("[warning]") ? "warning" : "dim";
515
+ text += `\n ${theme.fg(color, d)}`;
516
+ }
517
+ if (diag.diagnostics.length > maxDiags) {
518
+ text += theme.fg("dim", `\n ... (${diag.diagnostics.length - maxDiags} more)`);
519
+ }
520
+ }
521
+ }
504
522
  } else if (this.toolName === "edit") {
505
523
  const rawPath = this.args?.file_path || this.args?.path || "";
506
524
  const path = shortenPath(rawPath);
@@ -356,7 +356,7 @@ class TreeList implements Component {
356
356
  parts.push("branch summary", entry.summary);
357
357
  break;
358
358
  case "model_change":
359
- parts.push("model", entry.modelId);
359
+ parts.push("model", entry.model);
360
360
  break;
361
361
  case "thinking_level_change":
362
362
  parts.push("thinking", entry.thinkingLevel);
@@ -558,7 +558,7 @@ class TreeList implements Component {
558
558
  result = theme.fg("warning", `[branch summary]: `) + normalize(entry.summary);
559
559
  break;
560
560
  case "model_change":
561
- result = theme.fg("dim", `[model: ${entry.modelId}]`);
561
+ result = theme.fg("dim", `[model: ${entry.model}]`);
562
562
  break;
563
563
  case "thinking_level_change":
564
564
  result = theme.fg("dim", `[thinking: ${entry.thinkingLevel}]`);