@mariozechner/pi-coding-agent 0.42.5 → 0.44.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 (121) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +16 -8
  3. package/dist/cli/list-models.d.ts.map +1 -1
  4. package/dist/cli/list-models.js +1 -1
  5. package/dist/cli/list-models.js.map +1 -1
  6. package/dist/cli/session-picker.d.ts +4 -2
  7. package/dist/cli/session-picker.d.ts.map +1 -1
  8. package/dist/cli/session-picker.js +3 -3
  9. package/dist/cli/session-picker.js.map +1 -1
  10. package/dist/core/agent-session.d.ts +19 -10
  11. package/dist/core/agent-session.d.ts.map +1 -1
  12. package/dist/core/agent-session.js +43 -18
  13. package/dist/core/agent-session.js.map +1 -1
  14. package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
  15. package/dist/core/compaction/branch-summarization.js +3 -1
  16. package/dist/core/compaction/branch-summarization.js.map +1 -1
  17. package/dist/core/extensions/index.d.ts +2 -2
  18. package/dist/core/extensions/index.d.ts.map +1 -1
  19. package/dist/core/extensions/index.js.map +1 -1
  20. package/dist/core/extensions/loader.d.ts.map +1 -1
  21. package/dist/core/extensions/loader.js +8 -0
  22. package/dist/core/extensions/loader.js.map +1 -1
  23. package/dist/core/extensions/runner.d.ts +2 -2
  24. package/dist/core/extensions/runner.d.ts.map +1 -1
  25. package/dist/core/extensions/runner.js +11 -5
  26. package/dist/core/extensions/runner.js.map +1 -1
  27. package/dist/core/extensions/types.d.ts +38 -17
  28. package/dist/core/extensions/types.d.ts.map +1 -1
  29. package/dist/core/extensions/types.js.map +1 -1
  30. package/dist/core/footer-data-provider.d.ts.map +1 -1
  31. package/dist/core/footer-data-provider.js +10 -4
  32. package/dist/core/footer-data-provider.js.map +1 -1
  33. package/dist/core/index.d.ts +1 -1
  34. package/dist/core/index.d.ts.map +1 -1
  35. package/dist/core/index.js.map +1 -1
  36. package/dist/core/session-manager.d.ts +24 -4
  37. package/dist/core/session-manager.d.ts.map +1 -1
  38. package/dist/core/session-manager.js +179 -66
  39. package/dist/core/session-manager.js.map +1 -1
  40. package/dist/core/settings-manager.d.ts +7 -3
  41. package/dist/core/settings-manager.d.ts.map +1 -1
  42. package/dist/core/settings-manager.js +15 -0
  43. package/dist/core/settings-manager.js.map +1 -1
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/main.d.ts.map +1 -1
  48. package/dist/main.js +13 -12
  49. package/dist/main.js.map +1 -1
  50. package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -1
  51. package/dist/modes/interactive/components/extension-editor.js +8 -8
  52. package/dist/modes/interactive/components/extension-editor.js.map +1 -1
  53. package/dist/modes/interactive/components/index.d.ts +1 -0
  54. package/dist/modes/interactive/components/index.d.ts.map +1 -1
  55. package/dist/modes/interactive/components/index.js +1 -0
  56. package/dist/modes/interactive/components/index.js.map +1 -1
  57. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  58. package/dist/modes/interactive/components/model-selector.js +2 -3
  59. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  60. package/dist/modes/interactive/components/scoped-models-selector.d.ts +47 -0
  61. package/dist/modes/interactive/components/scoped-models-selector.d.ts.map +1 -0
  62. package/dist/modes/interactive/components/scoped-models-selector.js +241 -0
  63. package/dist/modes/interactive/components/scoped-models-selector.js.map +1 -0
  64. package/dist/modes/interactive/components/session-selector.d.ts +17 -3
  65. package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
  66. package/dist/modes/interactive/components/session-selector.js +192 -39
  67. package/dist/modes/interactive/components/session-selector.js.map +1 -1
  68. package/dist/modes/interactive/components/settings-selector.d.ts +4 -2
  69. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  70. package/dist/modes/interactive/components/settings-selector.js +14 -2
  71. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  72. package/dist/modes/interactive/components/tree-selector.d.ts +2 -2
  73. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
  74. package/dist/modes/interactive/components/tree-selector.js +8 -7
  75. package/dist/modes/interactive/components/tree-selector.js.map +1 -1
  76. package/dist/modes/interactive/interactive-mode.d.ts +7 -0
  77. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  78. package/dist/modes/interactive/interactive-mode.js +263 -30
  79. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  80. package/dist/modes/interactive/theme/theme.d.ts +1 -1
  81. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  82. package/dist/modes/interactive/theme/theme.js +22 -8
  83. package/dist/modes/interactive/theme/theme.js.map +1 -1
  84. package/dist/modes/print-mode.d.ts.map +1 -1
  85. package/dist/modes/print-mode.js +9 -3
  86. package/dist/modes/print-mode.js.map +1 -1
  87. package/dist/modes/rpc/rpc-client.d.ts +4 -4
  88. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  89. package/dist/modes/rpc/rpc-client.js +6 -6
  90. package/dist/modes/rpc/rpc-client.js.map +1 -1
  91. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  92. package/dist/modes/rpc/rpc-mode.js +18 -9
  93. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  94. package/dist/modes/rpc/rpc-types.d.ts +4 -4
  95. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  96. package/dist/modes/rpc/rpc-types.js.map +1 -1
  97. package/docs/extensions.md +64 -10
  98. package/docs/rpc.md +10 -10
  99. package/docs/sdk.md +10 -5
  100. package/docs/session.md +13 -1
  101. package/docs/skills.md +27 -0
  102. package/docs/tree.md +9 -5
  103. package/docs/tui.md +3 -0
  104. package/examples/extensions/README.md +4 -3
  105. package/examples/extensions/confirm-destructive.ts +5 -5
  106. package/examples/extensions/dirty-repo-guard.ts +2 -2
  107. package/examples/extensions/git-checkpoint.ts +3 -3
  108. package/examples/extensions/handoff.ts +1 -1
  109. package/examples/extensions/model-status.ts +31 -0
  110. package/examples/extensions/notify.ts +25 -0
  111. package/examples/extensions/preset.ts +3 -3
  112. package/examples/extensions/todo.ts +1 -1
  113. package/examples/extensions/tools.ts +9 -8
  114. package/examples/extensions/with-deps/package-lock.json +2 -2
  115. package/examples/extensions/with-deps/package.json +1 -1
  116. package/examples/sdk/11-sessions.ts +1 -1
  117. package/package.json +4 -4
  118. package/dist/utils/fuzzy.d.ts +0 -7
  119. package/dist/utils/fuzzy.d.ts.map +0 -1
  120. package/dist/utils/fuzzy.js +0 -86
  121. package/dist/utils/fuzzy.js.map +0 -1
@@ -7,18 +7,18 @@ import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
9
  import { getOAuthProviders, } from "@mariozechner/pi-ai";
10
- import { CombinedAutocompleteProvider, Container, getEditorKeybindings, Loader, Markdown, matchesKey, ProcessTerminal, Spacer, Text, TruncatedText, TUI, visibleWidth, } from "@mariozechner/pi-tui";
10
+ import { CombinedAutocompleteProvider, Container, fuzzyFilter, getEditorKeybindings, Loader, Markdown, matchesKey, ProcessTerminal, Spacer, Text, TruncatedText, TUI, visibleWidth, } from "@mariozechner/pi-tui";
11
11
  import { spawn, spawnSync } from "child_process";
12
12
  import { APP_NAME, getAuthPath, getDebugLogPath, isBunBinary, VERSION } from "../../config.js";
13
13
  import { FooterDataProvider } from "../../core/footer-data-provider.js";
14
14
  import { KeybindingsManager } from "../../core/keybindings.js";
15
15
  import { createCompactionSummaryMessage } from "../../core/messages.js";
16
+ import { resolveModelScope } from "../../core/model-resolver.js";
16
17
  import { SessionManager } from "../../core/session-manager.js";
17
18
  import { loadProjectContextFiles } from "../../core/system-prompt.js";
18
19
  import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
19
20
  import { copyToClipboard } from "../../utils/clipboard.js";
20
21
  import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
21
- import { fuzzyFilter } from "../../utils/fuzzy.js";
22
22
  import { ensureTool } from "../../utils/tools-manager.js";
23
23
  import { ArminComponent } from "./components/armin.js";
24
24
  import { AssistantMessageComponent } from "./components/assistant-message.js";
@@ -36,6 +36,7 @@ import { FooterComponent } from "./components/footer.js";
36
36
  import { LoginDialogComponent } from "./components/login-dialog.js";
37
37
  import { ModelSelectorComponent } from "./components/model-selector.js";
38
38
  import { OAuthSelectorComponent } from "./components/oauth-selector.js";
39
+ import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js";
39
40
  import { SessionSelectorComponent } from "./components/session-selector.js";
40
41
  import { SettingsSelectorComponent } from "./components/settings-selector.js";
41
42
  import { ToolExecutionComponent } from "./components/tool-execution.js";
@@ -56,6 +57,7 @@ export class InteractiveMode {
56
57
  defaultEditor;
57
58
  editor;
58
59
  autocompleteProvider;
60
+ fdPath;
59
61
  editorContainer;
60
62
  footer;
61
63
  footerDataProvider;
@@ -64,6 +66,7 @@ export class InteractiveMode {
64
66
  isInitialized = false;
65
67
  onInputCallback;
66
68
  loadingAnimation = undefined;
69
+ defaultWorkingMessage = "Working... (esc to interrupt)";
67
70
  lastSigintTime = 0;
68
71
  lastEscapeTime = 0;
69
72
  changelogMarkdown = undefined;
@@ -79,6 +82,8 @@ export class InteractiveMode {
79
82
  toolOutputExpanded = false;
80
83
  // Thinking block visibility state
81
84
  hideThinkingBlock = false;
85
+ // Skill commands: command name -> skill file path
86
+ skillCommands = new Map();
82
87
  // Agent subscription unsubscribe function
83
88
  unsubscribe;
84
89
  // Track if editor is in bash mode (text starts with !)
@@ -162,8 +167,8 @@ export class InteractiveMode {
162
167
  provider: m.provider,
163
168
  label: `${m.provider}/${m.id}`,
164
169
  }));
165
- // Fuzzy filter by model ID (not provider/id to avoid matching provider name)
166
- const filtered = fuzzyFilter(items, prefix, (item) => item.id);
170
+ // Fuzzy filter by model ID + provider (allows "opus anthropic" to match)
171
+ const filtered = fuzzyFilter(items, prefix, (item) => `${item.id} ${item.provider}`);
167
172
  if (filtered.length === 0)
168
173
  return null;
169
174
  return filtered.map((item) => ({
@@ -173,13 +178,15 @@ export class InteractiveMode {
173
178
  }));
174
179
  },
175
180
  },
181
+ { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
176
182
  { name: "export", description: "Export session to HTML file" },
177
183
  { name: "share", description: "Share session as a secret GitHub gist" },
178
184
  { name: "copy", description: "Copy last agent message to clipboard" },
185
+ { name: "name", description: "Set session display name" },
179
186
  { name: "session", description: "Show session info and stats" },
180
187
  { name: "changelog", description: "Show changelog entries" },
181
188
  { name: "hotkeys", description: "Show all keyboard shortcuts" },
182
- { name: "branch", description: "Create a new branch from a previous message" },
189
+ { name: "fork", description: "Create a new fork from a previous message" },
183
190
  { name: "tree", description: "Navigate session tree (switch branches)" },
184
191
  { name: "login", description: "Login with OAuth provider" },
185
192
  { name: "logout", description: "Logout from OAuth provider" },
@@ -197,18 +204,31 @@ export class InteractiveMode {
197
204
  name: cmd.name,
198
205
  description: cmd.description ?? "(extension command)",
199
206
  }));
207
+ // Build skill commands from session.skills (if enabled)
208
+ this.skillCommands.clear();
209
+ const skillCommandList = [];
210
+ if (this.settingsManager.getEnableSkillCommands()) {
211
+ for (const skill of this.session.skills) {
212
+ const commandName = `skill:${skill.name}`;
213
+ this.skillCommands.set(commandName, skill.filePath);
214
+ skillCommandList.push({ name: commandName, description: skill.description });
215
+ }
216
+ }
200
217
  // Setup autocomplete
201
- this.autocompleteProvider = new CombinedAutocompleteProvider([...slashCommands, ...templateCommands, ...extensionCommands], process.cwd(), fdPath);
218
+ this.autocompleteProvider = new CombinedAutocompleteProvider([...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList], process.cwd(), fdPath);
202
219
  this.defaultEditor.setAutocompleteProvider(this.autocompleteProvider);
203
220
  }
221
+ rebuildAutocomplete() {
222
+ this.setupAutocomplete(this.fdPath);
223
+ }
204
224
  async init() {
205
225
  if (this.isInitialized)
206
226
  return;
207
227
  // Load changelog (only show new entries, skip for resumed sessions)
208
228
  this.changelogMarkdown = this.getChangelogForDisplay();
209
229
  // Setup autocomplete with fd tool for file path completion
210
- const fdPath = await ensureTool("fd");
211
- this.setupAutocomplete(fdPath);
230
+ this.fdPath = await ensureTool("fd");
231
+ this.setupAutocomplete(this.fdPath);
212
232
  // Add header with keybindings from config
213
233
  const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
214
234
  // Format keybinding for startup display (lowercase, compact)
@@ -280,7 +300,7 @@ export class InteractiveMode {
280
300
  theme.fg("muted", " to queue follow-up") +
281
301
  "\n" +
282
302
  theme.fg("dim", dequeue) +
283
- theme.fg("muted", " to restore queued messages") +
303
+ theme.fg("muted", " to edit all queued messages") +
284
304
  "\n" +
285
305
  theme.fg("dim", "ctrl+v") +
286
306
  theme.fg("muted", " to paste image") +
@@ -313,6 +333,7 @@ export class InteractiveMode {
313
333
  this.ui.addChild(this.pendingMessagesContainer);
314
334
  this.ui.addChild(this.statusContainer);
315
335
  this.ui.addChild(this.widgetContainer);
336
+ this.renderWidgets(); // Initialize with default spacer
316
337
  this.ui.addChild(this.editorContainer);
317
338
  this.ui.addChild(this.footer);
318
339
  this.ui.setFocus(this.editor);
@@ -503,8 +524,14 @@ export class InteractiveMode {
503
524
  appendEntry: (customType, data) => {
504
525
  this.sessionManager.appendCustomEntry(customType, data);
505
526
  },
527
+ setSessionName: (name) => {
528
+ this.sessionManager.appendSessionInfo(name);
529
+ },
530
+ getSessionName: () => {
531
+ return this.sessionManager.getSessionName();
532
+ },
506
533
  getActiveTools: () => this.session.getActiveToolNames(),
507
- getAllTools: () => this.session.getAllToolNames(),
534
+ getAllTools: () => this.session.getAllTools(),
508
535
  setActiveTools: (toolNames) => this.session.setActiveToolsByName(toolNames),
509
536
  setModel: async (model) => {
510
537
  const key = await this.session.modelRegistry.getApiKey(model);
@@ -553,15 +580,15 @@ export class InteractiveMode {
553
580
  this.ui.requestRender();
554
581
  return { cancelled: false };
555
582
  },
556
- branch: async (entryId) => {
557
- const result = await this.session.branch(entryId);
583
+ fork: async (entryId) => {
584
+ const result = await this.session.fork(entryId);
558
585
  if (result.cancelled) {
559
586
  return { cancelled: true };
560
587
  }
561
588
  this.chatContainer.clear();
562
589
  this.renderInitialMessages();
563
590
  this.editor.setText(result.selectedText);
564
- this.showStatus("Branched to new session");
591
+ this.showStatus("Forked to new session");
565
592
  return { cancelled: false };
566
593
  },
567
594
  navigateTree: async (targetId, options) => {
@@ -765,6 +792,11 @@ export class InteractiveMode {
765
792
  input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts),
766
793
  notify: (message, type) => this.showExtensionNotify(message, type),
767
794
  setStatus: (key, text) => this.setExtensionStatus(key, text),
795
+ setWorkingMessage: (message) => {
796
+ if (this.loadingAnimation) {
797
+ this.loadingAnimation.setMessage(message ?? this.defaultWorkingMessage);
798
+ }
799
+ },
768
800
  setWidget: (key, content) => this.setExtensionWidget(key, content),
769
801
  setFooter: (factory) => this.setExtensionFooter(factory),
770
802
  setHeader: (factory) => this.setExtensionHeader(factory),
@@ -1065,7 +1097,7 @@ export class InteractiveMode {
1065
1097
  this.updateEditorBorderColor();
1066
1098
  }
1067
1099
  else if (!this.editor.getText().trim()) {
1068
- // Double-escape with empty editor triggers /tree or /branch based on setting
1100
+ // Double-escape with empty editor triggers /tree or /fork based on setting
1069
1101
  const now = Date.now();
1070
1102
  if (now - this.lastEscapeTime < 500) {
1071
1103
  if (this.settingsManager.getDoubleEscapeAction() === "tree") {
@@ -1139,6 +1171,11 @@ export class InteractiveMode {
1139
1171
  this.editor.setText("");
1140
1172
  return;
1141
1173
  }
1174
+ if (text === "/scoped-models") {
1175
+ this.editor.setText("");
1176
+ await this.showModelsSelector();
1177
+ return;
1178
+ }
1142
1179
  if (text === "/model" || text.startsWith("/model ")) {
1143
1180
  const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
1144
1181
  this.editor.setText("");
@@ -1160,6 +1197,11 @@ export class InteractiveMode {
1160
1197
  this.editor.setText("");
1161
1198
  return;
1162
1199
  }
1200
+ if (text === "/name" || text.startsWith("/name ")) {
1201
+ this.handleNameCommand(text);
1202
+ this.editor.setText("");
1203
+ return;
1204
+ }
1163
1205
  if (text === "/session") {
1164
1206
  this.handleSessionCommand();
1165
1207
  this.editor.setText("");
@@ -1175,7 +1217,7 @@ export class InteractiveMode {
1175
1217
  this.editor.setText("");
1176
1218
  return;
1177
1219
  }
1178
- if (text === "/branch") {
1220
+ if (text === "/fork") {
1179
1221
  this.showUserMessageSelector();
1180
1222
  this.editor.setText("");
1181
1223
  return;
@@ -1226,6 +1268,19 @@ export class InteractiveMode {
1226
1268
  await this.shutdown();
1227
1269
  return;
1228
1270
  }
1271
+ // Handle skill commands (/skill:name [args])
1272
+ if (text.startsWith("/skill:")) {
1273
+ const spaceIndex = text.indexOf(" ");
1274
+ const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
1275
+ const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
1276
+ const skillPath = this.skillCommands.get(commandName);
1277
+ if (skillPath) {
1278
+ this.editor.addToHistory?.(text);
1279
+ this.editor.setText("");
1280
+ await this.handleSkillCommand(skillPath, args);
1281
+ return;
1282
+ }
1283
+ }
1229
1284
  // Handle bash command (! for normal, !! for excluded from context)
1230
1285
  if (text.startsWith("!")) {
1231
1286
  const isExcluded = text.startsWith("!!");
@@ -1300,7 +1355,7 @@ export class InteractiveMode {
1300
1355
  this.loadingAnimation.stop();
1301
1356
  }
1302
1357
  this.statusContainer.clear();
1303
- this.loadingAnimation = new Loader(this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), "Working... (esc to interrupt)");
1358
+ this.loadingAnimation = new Loader(this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), this.defaultWorkingMessage);
1304
1359
  this.statusContainer.addChild(this.loadingAnimation);
1305
1360
  this.ui.requestRender();
1306
1361
  break;
@@ -1882,7 +1937,8 @@ export class InteractiveMode {
1882
1937
  }
1883
1938
  // Restart TUI
1884
1939
  this.ui.start();
1885
- this.ui.requestRender();
1940
+ // Force full re-render since external editor uses alternate screen
1941
+ this.ui.requestRender(true);
1886
1942
  }
1887
1943
  }
1888
1944
  // =========================================================================
@@ -1934,6 +1990,9 @@ export class InteractiveMode {
1934
1990
  const text = theme.fg("dim", `Follow-up: ${message}`);
1935
1991
  this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0));
1936
1992
  }
1993
+ const dequeueHint = this.getAppKeyDisplay("dequeue");
1994
+ const hintText = theme.fg("dim", `↳ ${dequeueHint} to edit all queued messages`);
1995
+ this.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
1937
1996
  }
1938
1997
  }
1939
1998
  restoreQueuedMessagesToEditor(options) {
@@ -2076,6 +2135,7 @@ export class InteractiveMode {
2076
2135
  showImages: this.settingsManager.getShowImages(),
2077
2136
  autoResizeImages: this.settingsManager.getImageAutoResize(),
2078
2137
  blockImages: this.settingsManager.getBlockImages(),
2138
+ enableSkillCommands: this.settingsManager.getEnableSkillCommands(),
2079
2139
  steeringMode: this.session.steeringMode,
2080
2140
  followUpMode: this.session.followUpMode,
2081
2141
  thinkingLevel: this.session.thinkingLevel,
@@ -2104,6 +2164,10 @@ export class InteractiveMode {
2104
2164
  onBlockImagesChange: (blocked) => {
2105
2165
  this.settingsManager.setBlockImages(blocked);
2106
2166
  },
2167
+ onEnableSkillCommandsChange: (enabled) => {
2168
+ this.settingsManager.setEnableSkillCommands(enabled);
2169
+ this.rebuildAutocomplete();
2170
+ },
2107
2171
  onSteeringModeChange: (mode) => {
2108
2172
  this.session.setSteeringMode(mode);
2109
2173
  },
@@ -2232,17 +2296,125 @@ export class InteractiveMode {
2232
2296
  return { component: selector, focus: selector };
2233
2297
  });
2234
2298
  }
2299
+ async showModelsSelector() {
2300
+ // Get all available models
2301
+ this.session.modelRegistry.refresh();
2302
+ const allModels = this.session.modelRegistry.getAvailable();
2303
+ if (allModels.length === 0) {
2304
+ this.showStatus("No models available");
2305
+ return;
2306
+ }
2307
+ // Check if session has scoped models (from previous session-only changes or CLI --models)
2308
+ const sessionScopedModels = this.session.scopedModels;
2309
+ const hasSessionScope = sessionScopedModels.length > 0;
2310
+ // Build enabled model IDs from session state or settings
2311
+ const enabledModelIds = new Set();
2312
+ let hasFilter = false;
2313
+ if (hasSessionScope) {
2314
+ // Use current session's scoped models
2315
+ for (const sm of sessionScopedModels) {
2316
+ enabledModelIds.add(`${sm.model.provider}/${sm.model.id}`);
2317
+ }
2318
+ hasFilter = true;
2319
+ }
2320
+ else {
2321
+ // Fall back to settings
2322
+ const patterns = this.settingsManager.getEnabledModels();
2323
+ if (patterns !== undefined && patterns.length > 0) {
2324
+ hasFilter = true;
2325
+ const scopedModels = await resolveModelScope(patterns, this.session.modelRegistry);
2326
+ for (const sm of scopedModels) {
2327
+ enabledModelIds.add(`${sm.model.provider}/${sm.model.id}`);
2328
+ }
2329
+ }
2330
+ }
2331
+ // Track current enabled state (session-only until persisted)
2332
+ const currentEnabledIds = new Set(enabledModelIds);
2333
+ let currentHasFilter = hasFilter;
2334
+ // Helper to update session's scoped models (session-only, no persist)
2335
+ const updateSessionModels = async (enabledIds) => {
2336
+ if (enabledIds.size > 0 && enabledIds.size < allModels.length) {
2337
+ // Use current session thinking level, not settings default
2338
+ const currentThinkingLevel = this.session.thinkingLevel;
2339
+ const newScopedModels = await resolveModelScope(Array.from(enabledIds), this.session.modelRegistry);
2340
+ this.session.setScopedModels(newScopedModels.map((sm) => ({
2341
+ model: sm.model,
2342
+ thinkingLevel: sm.thinkingLevel ?? currentThinkingLevel,
2343
+ })));
2344
+ }
2345
+ else {
2346
+ // All enabled or none enabled = no filter
2347
+ this.session.setScopedModels([]);
2348
+ }
2349
+ };
2350
+ this.showSelector((done) => {
2351
+ const selector = new ScopedModelsSelectorComponent({
2352
+ allModels,
2353
+ enabledModelIds: currentEnabledIds,
2354
+ hasEnabledModelsFilter: currentHasFilter,
2355
+ }, {
2356
+ onModelToggle: async (modelId, enabled) => {
2357
+ if (enabled) {
2358
+ currentEnabledIds.add(modelId);
2359
+ }
2360
+ else {
2361
+ currentEnabledIds.delete(modelId);
2362
+ }
2363
+ currentHasFilter = true;
2364
+ await updateSessionModels(currentEnabledIds);
2365
+ },
2366
+ onEnableAll: async (allModelIds) => {
2367
+ currentEnabledIds.clear();
2368
+ for (const id of allModelIds) {
2369
+ currentEnabledIds.add(id);
2370
+ }
2371
+ currentHasFilter = false;
2372
+ await updateSessionModels(currentEnabledIds);
2373
+ },
2374
+ onClearAll: async () => {
2375
+ currentEnabledIds.clear();
2376
+ currentHasFilter = true;
2377
+ await updateSessionModels(currentEnabledIds);
2378
+ },
2379
+ onToggleProvider: async (_provider, modelIds, enabled) => {
2380
+ for (const id of modelIds) {
2381
+ if (enabled) {
2382
+ currentEnabledIds.add(id);
2383
+ }
2384
+ else {
2385
+ currentEnabledIds.delete(id);
2386
+ }
2387
+ }
2388
+ currentHasFilter = true;
2389
+ await updateSessionModels(currentEnabledIds);
2390
+ },
2391
+ onPersist: (enabledIds) => {
2392
+ // Persist to settings
2393
+ const newPatterns = enabledIds.length === allModels.length
2394
+ ? undefined // All enabled = clear filter
2395
+ : enabledIds;
2396
+ this.settingsManager.setEnabledModels(newPatterns);
2397
+ this.showStatus("Model selection saved to settings");
2398
+ },
2399
+ onCancel: () => {
2400
+ done();
2401
+ this.ui.requestRender();
2402
+ },
2403
+ });
2404
+ return { component: selector, focus: selector };
2405
+ });
2406
+ }
2235
2407
  showUserMessageSelector() {
2236
- const userMessages = this.session.getUserMessagesForBranching();
2408
+ const userMessages = this.session.getUserMessagesForForking();
2237
2409
  if (userMessages.length === 0) {
2238
- this.showStatus("No messages to branch from");
2410
+ this.showStatus("No messages to fork from");
2239
2411
  return;
2240
2412
  }
2241
2413
  this.showSelector((done) => {
2242
2414
  const selector = new UserMessageSelectorComponent(userMessages.map((m) => ({ id: m.entryId, text: m.text })), async (entryId) => {
2243
- const result = await this.session.branch(entryId);
2415
+ const result = await this.session.fork(entryId);
2244
2416
  if (result.cancelled) {
2245
- // Extension cancelled the branch
2417
+ // Extension cancelled the fork
2246
2418
  done();
2247
2419
  this.ui.requestRender();
2248
2420
  return;
@@ -2259,7 +2431,7 @@ export class InteractiveMode {
2259
2431
  return { component: selector, focus: selector.getMessageList() };
2260
2432
  });
2261
2433
  }
2262
- showTreeSelector() {
2434
+ showTreeSelector(initialSelectedId) {
2263
2435
  const tree = this.sessionManager.getTree();
2264
2436
  const realLeafId = this.sessionManager.getLeafId();
2265
2437
  // Find the visible leaf for display (skip metadata entries like labels)
@@ -2286,7 +2458,31 @@ export class InteractiveMode {
2286
2458
  }
2287
2459
  // Ask about summarization
2288
2460
  done(); // Close selector first
2289
- const wantsSummary = await this.showExtensionConfirm("Summarize branch?", "Create a summary of the branch you're leaving?");
2461
+ // Loop until user makes a complete choice or cancels to tree
2462
+ let wantsSummary = false;
2463
+ let customInstructions;
2464
+ while (true) {
2465
+ const summaryChoice = await this.showExtensionSelector("Summarize branch?", [
2466
+ "No summary",
2467
+ "Summarize",
2468
+ "Summarize with custom prompt",
2469
+ ]);
2470
+ if (summaryChoice === undefined) {
2471
+ // User pressed escape - re-show tree selector with same selection
2472
+ this.showTreeSelector(entryId);
2473
+ return;
2474
+ }
2475
+ wantsSummary = summaryChoice !== "No summary";
2476
+ if (summaryChoice === "Summarize with custom prompt") {
2477
+ customInstructions = await this.showExtensionEditor("Custom summarization instructions");
2478
+ if (customInstructions === undefined) {
2479
+ // User cancelled - loop back to summary selector
2480
+ continue;
2481
+ }
2482
+ }
2483
+ // User made a complete choice
2484
+ break;
2485
+ }
2290
2486
  // Set up escape handler and loader if summarizing
2291
2487
  let summaryLoader;
2292
2488
  const originalOnEscape = this.defaultEditor.onEscape;
@@ -2300,11 +2496,14 @@ export class InteractiveMode {
2300
2496
  this.ui.requestRender();
2301
2497
  }
2302
2498
  try {
2303
- const result = await this.session.navigateTree(entryId, { summarize: wantsSummary });
2499
+ const result = await this.session.navigateTree(entryId, {
2500
+ summarize: wantsSummary,
2501
+ customInstructions,
2502
+ });
2304
2503
  if (result.aborted) {
2305
- // Summarization aborted - re-show tree selector
2504
+ // Summarization aborted - re-show tree selector with same selection
2306
2505
  this.showStatus("Branch summarization cancelled");
2307
- this.showTreeSelector();
2506
+ this.showTreeSelector(entryId);
2308
2507
  return;
2309
2508
  }
2310
2509
  if (result.cancelled) {
@@ -2335,14 +2534,13 @@ export class InteractiveMode {
2335
2534
  }, (entryId, label) => {
2336
2535
  this.sessionManager.appendLabelChange(entryId, label);
2337
2536
  this.ui.requestRender();
2338
- });
2537
+ }, initialSelectedId);
2339
2538
  return { component: selector, focus: selector };
2340
2539
  });
2341
2540
  }
2342
2541
  showSessionSelector() {
2343
2542
  this.showSelector((done) => {
2344
- const sessions = SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir());
2345
- const selector = new SessionSelectorComponent(sessions, async (sessionPath) => {
2543
+ const selector = new SessionSelectorComponent((onProgress) => SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir(), onProgress), SessionManager.listAll, async (sessionPath) => {
2346
2544
  done();
2347
2545
  await this.handleResumeSession(sessionPath);
2348
2546
  }, () => {
@@ -2350,7 +2548,7 @@ export class InteractiveMode {
2350
2548
  this.ui.requestRender();
2351
2549
  }, () => {
2352
2550
  void this.shutdown();
2353
- });
2551
+ }, () => this.ui.requestRender());
2354
2552
  return { component: selector, focus: selector.getSessionList() };
2355
2553
  });
2356
2554
  }
@@ -2601,9 +2799,32 @@ export class InteractiveMode {
2601
2799
  this.showError(error instanceof Error ? error.message : String(error));
2602
2800
  }
2603
2801
  }
2802
+ handleNameCommand(text) {
2803
+ const name = text.replace(/^\/name\s*/, "").trim();
2804
+ if (!name) {
2805
+ const currentName = this.sessionManager.getSessionName();
2806
+ if (currentName) {
2807
+ this.chatContainer.addChild(new Spacer(1));
2808
+ this.chatContainer.addChild(new Text(theme.fg("dim", `Session name: ${currentName}`), 1, 0));
2809
+ }
2810
+ else {
2811
+ this.showWarning("Usage: /name <name>");
2812
+ }
2813
+ this.ui.requestRender();
2814
+ return;
2815
+ }
2816
+ this.sessionManager.appendSessionInfo(name);
2817
+ this.chatContainer.addChild(new Spacer(1));
2818
+ this.chatContainer.addChild(new Text(theme.fg("dim", `Session name set: ${name}`), 1, 0));
2819
+ this.ui.requestRender();
2820
+ }
2604
2821
  handleSessionCommand() {
2605
2822
  const stats = this.session.getSessionStats();
2823
+ const sessionName = this.sessionManager.getSessionName();
2606
2824
  let info = `${theme.bold("Session Info")}\n\n`;
2825
+ if (sessionName) {
2826
+ info += `${theme.fg("dim", "Name:")} ${sessionName}\n`;
2827
+ }
2607
2828
  info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`;
2608
2829
  info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`;
2609
2830
  info += `${theme.bold("Messages")}\n`;
@@ -2630,6 +2851,18 @@ export class InteractiveMode {
2630
2851
  this.chatContainer.addChild(new Text(info, 1, 0));
2631
2852
  this.ui.requestRender();
2632
2853
  }
2854
+ async handleSkillCommand(skillPath, args) {
2855
+ try {
2856
+ const content = fs.readFileSync(skillPath, "utf-8");
2857
+ // Strip YAML frontmatter if present
2858
+ const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
2859
+ const message = args ? `${body}\n\n---\n\nUser: ${args}` : body;
2860
+ await this.session.prompt(message);
2861
+ }
2862
+ catch (err) {
2863
+ this.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
2864
+ }
2865
+ }
2633
2866
  handleChangelogCommand() {
2634
2867
  const changelogPath = getChangelogPath();
2635
2868
  const allEntries = parseChangelog(changelogPath);