@oh-my-pi/pi-coding-agent 1.340.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 (32) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/package.json +1 -1
  3. package/src/cli/args.ts +8 -0
  4. package/src/core/agent-session.ts +32 -14
  5. package/src/core/model-resolver.ts +101 -0
  6. package/src/core/sdk.ts +50 -17
  7. package/src/core/session-manager.ts +117 -14
  8. package/src/core/settings-manager.ts +90 -19
  9. package/src/core/title-generator.ts +94 -0
  10. package/src/core/tools/bash.ts +1 -2
  11. package/src/core/tools/edit-diff.ts +2 -2
  12. package/src/core/tools/edit.ts +43 -5
  13. package/src/core/tools/grep.ts +3 -2
  14. package/src/core/tools/index.ts +73 -13
  15. package/src/core/tools/lsp/client.ts +45 -20
  16. package/src/core/tools/lsp/config.ts +708 -34
  17. package/src/core/tools/lsp/index.ts +423 -23
  18. package/src/core/tools/lsp/types.ts +5 -0
  19. package/src/core/tools/task/bundled-agents/explore.md +1 -1
  20. package/src/core/tools/task/bundled-agents/reviewer.md +1 -1
  21. package/src/core/tools/task/model-resolver.ts +52 -3
  22. package/src/core/tools/write.ts +67 -4
  23. package/src/index.ts +5 -0
  24. package/src/main.ts +23 -2
  25. package/src/modes/interactive/components/model-selector.ts +96 -18
  26. package/src/modes/interactive/components/session-selector.ts +20 -7
  27. package/src/modes/interactive/components/settings-defs.ts +50 -2
  28. package/src/modes/interactive/components/settings-selector.ts +8 -11
  29. package/src/modes/interactive/components/tool-execution.ts +18 -0
  30. package/src/modes/interactive/components/tree-selector.ts +2 -2
  31. package/src/modes/interactive/components/welcome.ts +40 -3
  32. package/src/modes/interactive/interactive-mode.ts +86 -9
package/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.341.0] - 2026-01-03
6
+ ### Added
7
+
8
+ - Added interruptMode setting to control when queued messages are processed during tool execution.
9
+ - Implemented getter and setter methods in SettingsManager for interrupt mode persistence.
10
+ - Exposed interruptMode configuration in interactive settings UI with immediate/wait options.
11
+ - Wired interrupt mode through AgentSession and SDK to enable runtime configuration.
12
+ - Model roles: Configure different models for different purposes (default, smol, slow) via `/model` selector
13
+ - Model selector key bindings: Enter sets default, S sets smol, L sets slow, Escape closes
14
+ - Model selector shows role markers: ✓ for default, ⚡ for smol, 🧠 for slow
15
+ - `pi/<role>` model aliases in Task tool agent definitions (e.g., `model: pi/smol, haiku, flash, mini`)
16
+ - Smol model auto-discovery using priority chain: haiku > flash > mini
17
+ - Slow model auto-discovery using priority chain: gpt-5.2-codex > codex > gpt > opus > pro
18
+ - CLI args for model roles: `--smol <model>` and `--slow <model>` (ephemeral, not persisted)
19
+ - Env var overrides: `PI_SMOL_MODEL` and `PI_SLOW_MODEL`
20
+ - Title generation now uses configured smol model from settings
21
+ - LSP diagnostics on edit: Edit tool can now return LSP diagnostics after editing code files. Disabled by default to avoid noise during multi-edit sequences. Enable via `lsp.diagnosticsOnEdit` setting.
22
+ - LSP workspace diagnostics: New `lsp action=workspace_diagnostics` command checks the entire project for errors. Auto-detects project type and uses appropriate checker (rust-analyzer/cargo for Rust, tsc for TypeScript, go build for Go, pyright for Python).
23
+ - LSP local binary resolution: LSP servers installed in project-local directories are now discovered automatically. Checks `node_modules/.bin/` for Node.js projects, `.venv/bin/`/`venv/bin/` for Python projects, and `vendor/bundle/bin/` for Ruby projects before falling back to `$PATH`.
24
+ - LSP format on write: Write tool now automatically formats code files using LSP after writing. Uses the language server's built-in formatter (e.g., rustfmt for Rust, gofmt for Go). Controlled via `lsp.formatOnWrite` setting (enabled by default).
25
+ - LSP diagnostics on write: Write tool now returns LSP diagnostics (errors/warnings) after writing code files. This gives immediate feedback on syntax errors and type issues. Controlled via `lsp.diagnosticsOnWrite` setting (enabled by default).
26
+ - LSP server warmup at startup: LSP servers are now started at launch to avoid cold-start delays when first writing files.
27
+ - LSP server status in welcome banner: Shows which language servers are active and ready.
28
+ - Edit fuzzy match setting: Added `edit.fuzzyMatch` setting (enabled by default) to control whether the edit tool accepts high-confidence fuzzy matches for whitespace/indentation differences. Toggle via `/settings`.
29
+ - Multi-server LSP diagnostics: Diagnostics now query all applicable language servers for a file type. For TypeScript/JavaScript projects with Biome, this means both type errors (from tsserver) and lint errors (from Biome) are reported together.
30
+ - Comprehensive LSP server configurations for 40+ languages including Rust, Go, Python, Java, Kotlin, Scala, Haskell, OCaml, Elixir, Ruby, PHP, C#, Lua, Nix, and many more. Each server includes sensible defaults for args, settings, and init options.
31
+ - Extended LSP config file search paths: Now searches for `lsp.json`, `.lsp.json` in project root and `.pi/` subdirectory, plus user-level configs in `~/.pi/` and home directory.
32
+
33
+ ### Changed
34
+
35
+ - LSP settings moved to dedicated "LSP" tab in `/settings` for better organization
36
+ - Improved grep tool description to document pagination options (`headLimit`, `offset`) and clarify recursive search behavior
37
+ - LSP idle timeout now disabled by default. Configure via `idleTimeoutMs` in lsp.json to auto-shutdown inactive servers.
38
+ - Model settings now use role-based storage (`modelRoles` map) instead of single `defaultProvider`/`defaultModel` fields. Supports multiple model roles (default, small, etc.)
39
+ - Session model persistence now uses `"provider/modelId"` string format with optional role field
40
+
41
+ ### Fixed
42
+
43
+ - Recent sessions now show in welcome banner (was never wired up).
44
+ - Auto-generated session titles: Sessions are now automatically titled based on the first message using a small model (Haiku/GPT-4o-mini/Flash). Titles are shown in the terminal window title, recent sessions list, and --resume picker. The resume picker shows title with dimmed first message preview below.
45
+
5
46
  ## [1.340.0] - 2026-01-03
6
47
 
7
48
  ### Changed
@@ -1286,4 +1327,4 @@ Initial public release.
1286
1327
  - Git branch display in footer
1287
1328
  - Message queueing during streaming responses
1288
1329
  - OAuth integration for Gmail and Google Calendar access
1289
- - HTML export with syntax highlighting and collapsible sections
1330
+ - HTML export with syntax highlighting and collapsible sections
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "1.340.0",
3
+ "version": "1.341.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
package/src/cli/args.ts CHANGED
@@ -12,6 +12,8 @@ export type Mode = "text" | "json" | "rpc";
12
12
  export interface Args {
13
13
  provider?: string;
14
14
  model?: string;
15
+ smol?: string;
16
+ slow?: string;
15
17
  apiKey?: string;
16
18
  systemPrompt?: string;
17
19
  appendSystemPrompt?: string;
@@ -69,6 +71,10 @@ export function parseArgs(args: string[]): Args {
69
71
  result.provider = args[++i];
70
72
  } else if (arg === "--model" && i + 1 < args.length) {
71
73
  result.model = args[++i];
74
+ } else if (arg === "--smol" && i + 1 < args.length) {
75
+ result.smol = args[++i];
76
+ } else if (arg === "--slow" && i + 1 < args.length) {
77
+ result.slow = args[++i];
72
78
  } else if (arg === "--api-key" && i + 1 < args.length) {
73
79
  result.apiKey = args[++i];
74
80
  } else if (arg === "--system-prompt" && i + 1 < args.length) {
@@ -148,6 +154,8 @@ ${chalk.bold("Usage:")}
148
154
  ${chalk.bold("Options:")}
149
155
  --provider <name> Provider name (default: google)
150
156
  --model <id> Model ID (default: gemini-2.5-flash)
157
+ --smol <id> Smol/fast model for lightweight tasks (or PI_SMOL_MODEL env)
158
+ --slow <id> Slow/reasoning model for thorough analysis (or PI_SLOW_MODEL env)
151
159
  --api-key <key> API key (defaults to env vars)
152
160
  --system-prompt <text> System prompt (default: coding assistant prompt)
153
161
  --append-system-prompt <text> Append text or file contents to the system prompt
@@ -424,6 +424,11 @@ export class AgentSession {
424
424
  return this.agent.getQueueMode();
425
425
  }
426
426
 
427
+ /** Current interrupt mode */
428
+ get interruptMode(): "immediate" | "wait" {
429
+ return this.agent.getInterruptMode();
430
+ }
431
+
427
432
  /** Current session file path, or undefined if sessions are disabled */
428
433
  get sessionFile(): string | undefined {
429
434
  return this.sessionManager.getSessionFile();
@@ -701,15 +706,15 @@ export class AgentSession {
701
706
  * Validates API key, saves to session and settings.
702
707
  * @throws Error if no API key available for the model
703
708
  */
704
- async setModel(model: Model<any>): Promise<void> {
709
+ async setModel(model: Model<any>, role: string = "default"): Promise<void> {
705
710
  const apiKey = await this._modelRegistry.getApiKey(model);
706
711
  if (!apiKey) {
707
712
  throw new Error(`No API key for ${model.provider}/${model.id}`);
708
713
  }
709
714
 
710
715
  this.agent.setModel(model);
711
- this.sessionManager.appendModelChange(model.provider, model.id);
712
- this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
716
+ this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, role);
717
+ this.settingsManager.setModelRole(role, `${model.provider}/${model.id}`);
713
718
 
714
719
  // Re-clamp thinking level for new model's capabilities
715
720
  this.setThinkingLevel(this.thinkingLevel);
@@ -747,8 +752,8 @@ export class AgentSession {
747
752
 
748
753
  // Apply model
749
754
  this.agent.setModel(next.model);
750
- this.sessionManager.appendModelChange(next.model.provider, next.model.id);
751
- this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
755
+ this.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
756
+ this.settingsManager.setModelRole("default", `${next.model.provider}/${next.model.id}`);
752
757
 
753
758
  // Apply thinking level (setThinkingLevel clamps to model capabilities)
754
759
  this.setThinkingLevel(next.thinkingLevel);
@@ -774,8 +779,8 @@ export class AgentSession {
774
779
  }
775
780
 
776
781
  this.agent.setModel(nextModel);
777
- this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
778
- this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
782
+ this.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
783
+ this.settingsManager.setModelRole("default", `${nextModel.provider}/${nextModel.id}`);
779
784
 
780
785
  // Re-clamp thinking level for new model's capabilities
781
786
  this.setThinkingLevel(this.thinkingLevel);
@@ -861,6 +866,15 @@ export class AgentSession {
861
866
  this.settingsManager.setQueueMode(mode);
862
867
  }
863
868
 
869
+ /**
870
+ * Set interrupt mode.
871
+ * Saves to settings.
872
+ */
873
+ setInterruptMode(mode: "immediate" | "wait"): void {
874
+ this.agent.setInterruptMode(mode);
875
+ this.settingsManager.setInterruptMode(mode);
876
+ }
877
+
864
878
  // =========================================================================
865
879
  // Compaction
866
880
  // =========================================================================
@@ -1472,13 +1486,17 @@ export class AgentSession {
1472
1486
  this.agent.replaceMessages(sessionContext.messages);
1473
1487
 
1474
1488
  // Restore model if saved
1475
- if (sessionContext.model) {
1476
- const availableModels = await this._modelRegistry.getAvailable();
1477
- const match = availableModels.find(
1478
- (m) => m.provider === sessionContext.model!.provider && m.id === sessionContext.model!.modelId,
1479
- );
1480
- if (match) {
1481
- this.agent.setModel(match);
1489
+ const defaultModelStr = sessionContext.models.default;
1490
+ if (defaultModelStr) {
1491
+ const slashIdx = defaultModelStr.indexOf("/");
1492
+ if (slashIdx > 0) {
1493
+ const provider = defaultModelStr.slice(0, slashIdx);
1494
+ const modelId = defaultModelStr.slice(slashIdx + 1);
1495
+ const availableModels = await this._modelRegistry.getAvailable();
1496
+ const match = availableModels.find((m) => m.provider === provider && m.id === modelId);
1497
+ if (match) {
1498
+ this.agent.setModel(match);
1499
+ }
1482
1500
  }
1483
1501
  }
1484
1502
 
@@ -30,6 +30,29 @@ export interface ScopedModel {
30
30
  thinkingLevel: ThinkingLevel;
31
31
  }
32
32
 
33
+ /** Priority chain for auto-discovering smol/fast models */
34
+ export const SMOL_MODEL_PRIORITY = ["claude-haiku-4-5", "haiku", "flash", "mini"];
35
+
36
+ /** Priority chain for auto-discovering slow/comprehensive models (reasoning, codex) */
37
+ export const SLOW_MODEL_PRIORITY = ["gpt-5.2-codex", "gpt-5.2", "codex", "gpt", "opus", "pro"];
38
+
39
+ /**
40
+ * Parse a model string in "provider/modelId" format.
41
+ * Returns undefined if the format is invalid.
42
+ */
43
+ export function parseModelString(modelStr: string): { provider: string; id: string } | undefined {
44
+ const slashIdx = modelStr.indexOf("/");
45
+ if (slashIdx <= 0) return undefined;
46
+ return { provider: modelStr.slice(0, slashIdx), id: modelStr.slice(slashIdx + 1) };
47
+ }
48
+
49
+ /**
50
+ * Format a model as "provider/modelId" string.
51
+ */
52
+ export function formatModelString(model: Model<Api>): string {
53
+ return `${model.provider}/${model.id}`;
54
+ }
55
+
33
56
  /**
34
57
  * Helper to check if a model ID looks like an alias (no date suffix)
35
58
  * Dates are typically in format: -20241022 or -20250929
@@ -391,3 +414,81 @@ export async function restoreModelFromSession(
391
414
  // No models available
392
415
  return { model: undefined, fallbackMessage: undefined };
393
416
  }
417
+
418
+ /**
419
+ * Find a smol/fast model using the priority chain.
420
+ * Tries exact matches first, then fuzzy matches.
421
+ *
422
+ * @param modelRegistry The model registry to search
423
+ * @param savedModel Optional saved model string from settings (provider/modelId)
424
+ * @returns The best available smol model, or undefined if none found
425
+ */
426
+ export async function findSmolModel(
427
+ modelRegistry: ModelRegistry,
428
+ savedModel?: string,
429
+ ): Promise<Model<Api> | undefined> {
430
+ const availableModels = await modelRegistry.getAvailable();
431
+ if (availableModels.length === 0) return undefined;
432
+
433
+ // 1. Try saved model from settings
434
+ if (savedModel) {
435
+ const parsed = parseModelString(savedModel);
436
+ if (parsed) {
437
+ const match = availableModels.find((m) => m.provider === parsed.provider && m.id === parsed.id);
438
+ if (match) return match;
439
+ }
440
+ }
441
+
442
+ // 2. Try priority chain
443
+ for (const pattern of SMOL_MODEL_PRIORITY) {
444
+ // Try exact match first
445
+ const exactMatch = availableModels.find((m) => m.id.toLowerCase() === pattern.toLowerCase());
446
+ if (exactMatch) return exactMatch;
447
+
448
+ // Try fuzzy match (substring)
449
+ const fuzzyMatch = availableModels.find((m) => m.id.toLowerCase().includes(pattern.toLowerCase()));
450
+ if (fuzzyMatch) return fuzzyMatch;
451
+ }
452
+
453
+ // 3. Fallback to first available (same as default)
454
+ return availableModels[0];
455
+ }
456
+
457
+ /**
458
+ * Find a slow/comprehensive model using the priority chain.
459
+ * Prioritizes reasoning and codex models for thorough analysis.
460
+ *
461
+ * @param modelRegistry The model registry to search
462
+ * @param savedModel Optional saved model string from settings (provider/modelId)
463
+ * @returns The best available slow model, or undefined if none found
464
+ */
465
+ export async function findSlowModel(
466
+ modelRegistry: ModelRegistry,
467
+ savedModel?: string,
468
+ ): Promise<Model<Api> | undefined> {
469
+ const availableModels = await modelRegistry.getAvailable();
470
+ if (availableModels.length === 0) return undefined;
471
+
472
+ // 1. Try saved model from settings
473
+ if (savedModel) {
474
+ const parsed = parseModelString(savedModel);
475
+ if (parsed) {
476
+ const match = availableModels.find((m) => m.provider === parsed.provider && m.id === parsed.id);
477
+ if (match) return match;
478
+ }
479
+ }
480
+
481
+ // 2. Try priority chain
482
+ for (const pattern of SLOW_MODEL_PRIORITY) {
483
+ // Try exact match first
484
+ const exactMatch = availableModels.find((m) => m.id.toLowerCase() === pattern.toLowerCase());
485
+ if (exactMatch) return exactMatch;
486
+
487
+ // Try fuzzy match (substring)
488
+ const fuzzyMatch = availableModels.find((m) => m.id.toLowerCase().includes(pattern.toLowerCase()));
489
+ if (fuzzyMatch) return fuzzyMatch;
490
+ }
491
+
492
+ // 3. Fallback to first available (same as default)
493
+ return availableModels[0];
494
+ }
package/src/core/sdk.ts CHANGED
@@ -78,6 +78,7 @@ import {
78
78
  readOnlyTools,
79
79
  readTool,
80
80
  type Tool,
81
+ warmupLspServers,
81
82
  writeTool,
82
83
  } from "./tools/index.js";
83
84
 
@@ -146,6 +147,8 @@ export interface CreateAgentSessionResult {
146
147
  mcpManager?: MCPManager;
147
148
  /** Warning if session was restored with a different model than saved */
148
149
  modelFallbackMessage?: string;
150
+ /** LSP servers that were warmed up at startup */
151
+ lspServers?: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }>;
149
152
  }
150
153
 
151
154
  // Re-exports
@@ -323,8 +326,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
323
326
  export function loadSettings(cwd?: string, agentDir?: string): Settings {
324
327
  const manager = SettingsManager.create(cwd ?? process.cwd(), agentDir ?? getDefaultAgentDir());
325
328
  return {
326
- defaultProvider: manager.getDefaultProvider(),
327
- defaultModel: manager.getDefaultModel(),
329
+ modelRoles: manager.getModelRoles(),
328
330
  defaultThinkingLevel: manager.getDefaultThinkingLevel(),
329
331
  queueMode: manager.getQueueMode(),
330
332
  theme: manager.getTheme(),
@@ -479,24 +481,34 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
479
481
  let modelFallbackMessage: string | undefined;
480
482
 
481
483
  // If session has data, try to restore model from it
482
- if (!model && hasExistingSession && existingSession.model) {
483
- const restoredModel = modelRegistry.find(existingSession.model.provider, existingSession.model.modelId);
484
- if (restoredModel && (await modelRegistry.getApiKey(restoredModel))) {
485
- model = restoredModel;
486
- }
487
- if (!model) {
488
- modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`;
484
+ const defaultModelStr = existingSession.models.default;
485
+ if (!model && hasExistingSession && defaultModelStr) {
486
+ const slashIdx = defaultModelStr.indexOf("/");
487
+ if (slashIdx > 0) {
488
+ const provider = defaultModelStr.slice(0, slashIdx);
489
+ const modelId = defaultModelStr.slice(slashIdx + 1);
490
+ const restoredModel = modelRegistry.find(provider, modelId);
491
+ if (restoredModel && (await modelRegistry.getApiKey(restoredModel))) {
492
+ model = restoredModel;
493
+ }
494
+ if (!model) {
495
+ modelFallbackMessage = `Could not restore model ${defaultModelStr}`;
496
+ }
489
497
  }
490
498
  }
491
499
 
492
500
  // If still no model, try settings default
493
501
  if (!model) {
494
- const defaultProvider = settingsManager.getDefaultProvider();
495
- const defaultModelId = settingsManager.getDefaultModel();
496
- if (defaultProvider && defaultModelId) {
497
- const settingsModel = modelRegistry.find(defaultProvider, defaultModelId);
498
- if (settingsModel && (await modelRegistry.getApiKey(settingsModel))) {
499
- model = settingsModel;
502
+ const settingsDefaultModel = settingsManager.getModelRole("default");
503
+ if (settingsDefaultModel) {
504
+ const slashIdx = settingsDefaultModel.indexOf("/");
505
+ if (slashIdx > 0) {
506
+ const provider = settingsDefaultModel.slice(0, slashIdx);
507
+ const modelId = settingsDefaultModel.slice(slashIdx + 1);
508
+ const settingsModel = modelRegistry.find(provider, modelId);
509
+ if (settingsModel && (await modelRegistry.getApiKey(settingsModel))) {
510
+ model = settingsModel;
511
+ }
500
512
  }
501
513
  }
502
514
  }
@@ -566,7 +578,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
566
578
  const sessionContext = {
567
579
  getSessionFile: () => sessionManager.getSessionFile() ?? null,
568
580
  };
569
- const builtInTools = options.tools ?? createCodingTools(cwd, options.hasUI ?? false, sessionContext);
581
+ const builtInTools =
582
+ options.tools ??
583
+ createCodingTools(cwd, options.hasUI ?? false, sessionContext, {
584
+ lspFormatOnWrite: settingsManager.getLspFormatOnWrite(),
585
+ lspDiagnosticsOnWrite: settingsManager.getLspDiagnosticsOnWrite(),
586
+ lspDiagnosticsOnEdit: settingsManager.getLspDiagnosticsOnEdit(),
587
+ editFuzzyMatch: settingsManager.getEditFuzzyMatch(),
588
+ });
570
589
  time("createCodingTools");
571
590
 
572
591
  let customToolsResult: CustomToolsLoadResult;
@@ -728,6 +747,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
728
747
  }
729
748
  : undefined,
730
749
  queueMode: settingsManager.getQueueMode(),
750
+ interruptMode: settingsManager.getInterruptMode(),
731
751
  getToolContext: toolContextStore.getContext,
732
752
  getApiKey: async () => {
733
753
  const currentModel = agent.state.model;
@@ -749,7 +769,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
749
769
  } else {
750
770
  // Save initial model and thinking level for new sessions so they can be restored on resume
751
771
  if (model) {
752
- sessionManager.appendModelChange(model.provider, model.id);
772
+ sessionManager.appendModelChange(`${model.provider}/${model.id}`);
753
773
  }
754
774
  sessionManager.appendThinkingLevelChange(thinkingLevel);
755
775
  }
@@ -767,10 +787,23 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
767
787
  });
768
788
  time("createAgentSession");
769
789
 
790
+ // Warm up LSP servers (connects to detected servers)
791
+ let lspServers: CreateAgentSessionResult["lspServers"];
792
+ if (settingsManager.getLspDiagnosticsOnWrite()) {
793
+ try {
794
+ const result = await warmupLspServers(cwd);
795
+ lspServers = result.servers;
796
+ time("warmupLspServers");
797
+ } catch {
798
+ // Ignore warmup errors
799
+ }
800
+ }
801
+
770
802
  return {
771
803
  session,
772
804
  customToolsResult,
773
805
  mcpManager,
774
806
  modelFallbackMessage,
807
+ lspServers,
775
808
  };
776
809
  }
@@ -28,6 +28,7 @@ export interface SessionHeader {
28
28
  type: "session";
29
29
  version?: number; // v1 sessions don't have this
30
30
  id: string;
31
+ title?: string; // Auto-generated title from first message
31
32
  timestamp: string;
32
33
  cwd: string;
33
34
  parentSession?: string;
@@ -56,8 +57,10 @@ export interface ThinkingLevelChangeEntry extends SessionEntryBase {
56
57
 
57
58
  export interface ModelChangeEntry extends SessionEntryBase {
58
59
  type: "model_change";
59
- provider: string;
60
- modelId: string;
60
+ /** Model in "provider/modelId" format */
61
+ model: string;
62
+ /** Role: "default", "smol", "slow", etc. Undefined treated as "default" */
63
+ role?: string;
61
64
  }
62
65
 
63
66
  export interface CompactionEntry<T = unknown> extends SessionEntryBase {
@@ -149,12 +152,14 @@ export interface SessionTreeNode {
149
152
  export interface SessionContext {
150
153
  messages: AgentMessage[];
151
154
  thinkingLevel: string;
152
- model: { provider: string; modelId: string } | null;
155
+ /** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
156
+ models: Record<string, string>;
153
157
  }
154
158
 
155
159
  export interface SessionInfo {
156
160
  path: string;
157
161
  id: string;
162
+ title?: string;
158
163
  created: Date;
159
164
  modified: Date;
160
165
  messageCount: number;
@@ -290,7 +295,7 @@ export function buildSessionContext(
290
295
  let leaf: SessionEntry | undefined;
291
296
  if (leafId === null) {
292
297
  // Explicitly null - return no messages (navigated to before first entry)
293
- return { messages: [], thinkingLevel: "off", model: null };
298
+ return { messages: [], thinkingLevel: "off", models: {} };
294
299
  }
295
300
  if (leafId) {
296
301
  leaf = byId.get(leafId);
@@ -301,7 +306,7 @@ export function buildSessionContext(
301
306
  }
302
307
 
303
308
  if (!leaf) {
304
- return { messages: [], thinkingLevel: "off", model: null };
309
+ return { messages: [], thinkingLevel: "off", models: {} };
305
310
  }
306
311
 
307
312
  // Walk from leaf to root, collecting path
@@ -314,16 +319,21 @@ export function buildSessionContext(
314
319
 
315
320
  // Extract settings and find compaction
316
321
  let thinkingLevel = "off";
317
- let model: { provider: string; modelId: string } | null = null;
322
+ const models: Record<string, string> = {};
318
323
  let compaction: CompactionEntry | null = null;
319
324
 
320
325
  for (const entry of path) {
321
326
  if (entry.type === "thinking_level_change") {
322
327
  thinkingLevel = entry.thinkingLevel;
323
328
  } else if (entry.type === "model_change") {
324
- model = { provider: entry.provider, modelId: entry.modelId };
329
+ // New format: { model: "provider/id", role?: string }
330
+ if (entry.model) {
331
+ const role = entry.role ?? "default";
332
+ models[role] = entry.model;
333
+ }
325
334
  } else if (entry.type === "message" && entry.message.role === "assistant") {
326
- model = { provider: entry.message.provider, modelId: entry.message.model };
335
+ // Infer default model from assistant messages
336
+ models.default = `${entry.message.provider}/${entry.message.model}`;
327
337
  } else if (entry.type === "compaction") {
328
338
  compaction = entry;
329
339
  }
@@ -379,7 +389,7 @@ export function buildSessionContext(
379
389
  }
380
390
  }
381
391
 
382
- return { messages, thinkingLevel, model };
392
+ return { messages, thinkingLevel, models };
383
393
  }
384
394
 
385
395
  /**
@@ -454,6 +464,67 @@ export function findMostRecentSession(sessionDir: string): string | null {
454
464
  }
455
465
  }
456
466
 
467
+ /** Recent session info for display */
468
+ export interface RecentSessionInfo {
469
+ name: string;
470
+ path: string;
471
+ timeAgo: string;
472
+ }
473
+
474
+ /** Format a time difference as a human-readable string */
475
+ function formatTimeAgo(date: Date): string {
476
+ const now = Date.now();
477
+ const diffMs = now - date.getTime();
478
+ const diffMins = Math.floor(diffMs / 60000);
479
+ const diffHours = Math.floor(diffMs / 3600000);
480
+ const diffDays = Math.floor(diffMs / 86400000);
481
+
482
+ if (diffMins < 1) return "just now";
483
+ if (diffMins < 60) return `${diffMins}m ago`;
484
+ if (diffHours < 24) return `${diffHours}h ago`;
485
+ if (diffDays < 7) return `${diffDays}d ago`;
486
+ return date.toLocaleDateString();
487
+ }
488
+
489
+ /** Get recent sessions for display in welcome screen */
490
+ export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionInfo[] {
491
+ try {
492
+ const files = readdirSync(sessionDir)
493
+ .filter((f) => f.endsWith(".jsonl"))
494
+ .map((f) => join(sessionDir, f))
495
+ .filter(isValidSessionFile)
496
+ .map((path) => {
497
+ const stat = statSync(path);
498
+ // Try to get session title or id from first line
499
+ let name = path.split("/").pop()?.replace(".jsonl", "") ?? "Unknown";
500
+ try {
501
+ const content = readFileSync(path, "utf-8");
502
+ const firstLine = content.split("\n")[0];
503
+ if (firstLine) {
504
+ const header = JSON.parse(firstLine) as SessionHeader;
505
+ if (header.type === "session") {
506
+ // Prefer title over id
507
+ name = header.title ?? header.id ?? name;
508
+ }
509
+ }
510
+ } catch {
511
+ // Use filename as fallback
512
+ }
513
+ return { path, name, mtime: stat.mtime };
514
+ })
515
+ .sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
516
+ .slice(0, limit);
517
+
518
+ return files.map((f) => ({
519
+ name: f.name.length > 40 ? `${f.name.slice(0, 37)}...` : f.name,
520
+ path: f.path,
521
+ timeAgo: formatTimeAgo(f.mtime),
522
+ }));
523
+ } catch {
524
+ return [];
525
+ }
526
+ }
527
+
457
528
  /**
458
529
  * Manages conversation sessions as append-only trees stored in JSONL files.
459
530
  *
@@ -467,6 +538,7 @@ export function findMostRecentSession(sessionDir: string): string | null {
467
538
  */
468
539
  export class SessionManager {
469
540
  private sessionId: string = "";
541
+ private sessionTitle: string | undefined;
470
542
  private sessionFile: string | undefined;
471
543
  private sessionDir: string;
472
544
  private cwd: string;
@@ -499,6 +571,7 @@ export class SessionManager {
499
571
  this.fileEntries = loadEntriesFromFile(this.sessionFile);
500
572
  const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
501
573
  this.sessionId = header?.id ?? crypto.randomUUID();
574
+ this.sessionTitle = header?.title;
502
575
 
503
576
  if (migrateToCurrentVersion(this.fileEntries)) {
504
577
  this._rewriteFile();
@@ -579,6 +652,31 @@ export class SessionManager {
579
652
  return this.sessionFile;
580
653
  }
581
654
 
655
+ getSessionTitle(): string | undefined {
656
+ return this.sessionTitle;
657
+ }
658
+
659
+ setSessionTitle(title: string): void {
660
+ this.sessionTitle = title;
661
+ // Update the session file header with the title
662
+ if (this.persist && this.sessionFile && existsSync(this.sessionFile)) {
663
+ try {
664
+ const content = readFileSync(this.sessionFile, "utf-8");
665
+ const lines = content.split("\n");
666
+ if (lines.length > 0) {
667
+ const header = JSON.parse(lines[0]) as SessionHeader;
668
+ if (header.type === "session") {
669
+ header.title = title;
670
+ lines[0] = JSON.stringify(header);
671
+ writeFileSync(this.sessionFile, lines.join("\n"));
672
+ }
673
+ }
674
+ } catch {
675
+ // Ignore errors updating title
676
+ }
677
+ }
678
+ }
679
+
582
680
  _persist(entry: SessionEntry): void {
583
681
  if (!this.persist || !this.sessionFile) return;
584
682
 
@@ -633,15 +731,19 @@ export class SessionManager {
633
731
  return entry.id;
634
732
  }
635
733
 
636
- /** Append a model change as child of current leaf, then advance leaf. Returns entry id. */
637
- appendModelChange(provider: string, modelId: string): string {
734
+ /**
735
+ * Append a model change as child of current leaf, then advance leaf. Returns entry id.
736
+ * @param model Model in "provider/modelId" format
737
+ * @param role Optional role (default: "default")
738
+ */
739
+ appendModelChange(model: string, role?: string): string {
638
740
  const entry: ModelChangeEntry = {
639
741
  type: "model_change",
640
742
  id: generateId(this.byId),
641
743
  parentId: this.leafId,
642
744
  timestamp: new Date().toISOString(),
643
- provider,
644
- modelId,
745
+ model,
746
+ role,
645
747
  };
646
748
  this._appendEntry(entry);
647
749
  return entry.id;
@@ -1061,7 +1163,7 @@ export class SessionManager {
1061
1163
  if (lines.length === 0) continue;
1062
1164
 
1063
1165
  // Check first line for valid session header
1064
- let header: { type: string; id: string; timestamp: string } | null = null;
1166
+ let header: { type: string; id: string; title?: string; timestamp: string } | null = null;
1065
1167
  try {
1066
1168
  const first = JSON.parse(lines[0]);
1067
1169
  if (first.type === "session" && first.id) {
@@ -1107,6 +1209,7 @@ export class SessionManager {
1107
1209
  sessions.push({
1108
1210
  path: file,
1109
1211
  id: header.id,
1212
+ title: header.title,
1110
1213
  created: new Date(header.timestamp),
1111
1214
  modified: stats.mtime,
1112
1215
  messageCount,