@oh-my-pi/pi-coding-agent 4.5.0 → 4.7.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 (31) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +5 -5
  3. package/src/cli/config-cli.ts +344 -0
  4. package/src/core/agent-session.ts +112 -1
  5. package/src/core/extensions/types.ts +2 -0
  6. package/src/core/hooks/loader.ts +6 -3
  7. package/src/core/hooks/tool-wrapper.ts +1 -0
  8. package/src/core/session-manager.ts +4 -1
  9. package/src/core/settings-manager.ts +43 -0
  10. package/src/core/tools/ask.ts +272 -99
  11. package/src/core/tools/index.ts +1 -1
  12. package/src/core/tools/schema-validation.test.ts +30 -0
  13. package/src/core/tools/task/model-resolver.ts +28 -2
  14. package/src/main.ts +12 -0
  15. package/src/modes/interactive/components/hook-editor.ts +1 -1
  16. package/src/modes/interactive/components/hook-message.ts +5 -0
  17. package/src/modes/interactive/components/hook-selector.ts +3 -1
  18. package/src/modes/interactive/components/index.ts +1 -0
  19. package/src/modes/interactive/components/read-tool-group.ts +12 -4
  20. package/src/modes/interactive/components/settings-defs.ts +9 -0
  21. package/src/modes/interactive/components/todo-display.ts +1 -1
  22. package/src/modes/interactive/components/todo-reminder.ts +42 -0
  23. package/src/modes/interactive/controllers/command-controller.ts +12 -0
  24. package/src/modes/interactive/controllers/event-controller.ts +18 -8
  25. package/src/modes/interactive/controllers/extension-ui-controller.ts +9 -2
  26. package/src/modes/interactive/controllers/input-controller.ts +48 -16
  27. package/src/modes/interactive/controllers/selector-controller.ts +38 -8
  28. package/src/modes/interactive/interactive-mode.ts +30 -4
  29. package/src/modes/interactive/types.ts +5 -1
  30. package/src/modes/rpc/rpc-mode.ts +11 -3
  31. package/src/prompts/tools/ask.md +14 -0
@@ -21,11 +21,12 @@ import { DynamicBorder } from "./dynamic-border";
21
21
  export interface HookSelectorOptions {
22
22
  tui?: TUI;
23
23
  timeout?: number;
24
+ initialIndex?: number;
24
25
  }
25
26
 
26
27
  export class HookSelectorComponent extends Container {
27
28
  private options: string[];
28
- private selectedIndex = 0;
29
+ private selectedIndex: number;
29
30
  private listContainer: Container;
30
31
  private onSelectCallback: (option: string) => void;
31
32
  private onCancelCallback: () => void;
@@ -43,6 +44,7 @@ export class HookSelectorComponent extends Container {
43
44
  super();
44
45
 
45
46
  this.options = options;
47
+ this.selectedIndex = Math.min(opts?.initialIndex ?? 0, options.length - 1);
46
48
  this.onSelectCallback = onSelect;
47
49
  this.onCancelCallback = onCancel;
48
50
  this.baseTitle = title;
@@ -31,6 +31,7 @@ export { ShowImagesSelectorComponent } from "./show-images-selector";
31
31
  export { StatusLineComponent } from "./status-line";
32
32
  export { ThemeSelectorComponent } from "./theme-selector";
33
33
  export { ThinkingSelectorComponent } from "./thinking-selector";
34
+ export { TodoReminderComponent } from "./todo-reminder";
34
35
  export { ToolExecutionComponent, type ToolExecutionHandle, type ToolExecutionOptions } from "./tool-execution";
35
36
  export { TreeSelectorComponent } from "./tree-selector";
36
37
  export { TtsrNotificationComponent } from "./ttsr-notification";
@@ -74,15 +74,23 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
74
74
 
75
75
  private updateDisplay(): void {
76
76
  const entries = [...this.entries.values()];
77
- const header = `${theme.fg("toolTitle", theme.bold("Read"))}${
78
- entries.length > 1 ? theme.fg("dim", ` (${entries.length})`) : ""
79
- }`;
80
77
 
81
78
  if (entries.length === 0) {
82
- this.text.setText(` ${theme.format.bullet} ${header}`);
79
+ this.text.setText(` ${theme.format.bullet} ${theme.fg("toolTitle", theme.bold("Read"))}`);
83
80
  return;
84
81
  }
85
82
 
83
+ if (entries.length === 1) {
84
+ const entry = entries[0];
85
+ const statusSymbol = this.formatStatus(entry.status);
86
+ const pathDisplay = this.formatPath(entry);
87
+ this.text.setText(
88
+ ` ${theme.format.bullet} ${theme.fg("toolTitle", theme.bold("Read"))} ${pathDisplay} ${statusSymbol}`.trimEnd(),
89
+ );
90
+ return;
91
+ }
92
+
93
+ const header = `${theme.fg("toolTitle", theme.bold("Read"))}${theme.fg("dim", ` (${entries.length})`)}`;
86
94
  const lines = [` ${theme.format.bullet} ${header}`];
87
95
  const total = entries.length;
88
96
  for (const [index, entry] of entries.entries()) {
@@ -97,6 +97,15 @@ export const SETTINGS_DEFS: SettingDef[] = [
97
97
  get: (sm) => sm.getBranchSummaryEnabled(),
98
98
  set: (sm, v) => sm.setBranchSummaryEnabled(v),
99
99
  },
100
+ {
101
+ id: "todoCompletion",
102
+ tab: "config",
103
+ type: "boolean",
104
+ label: "Todo completion",
105
+ description: "Remind agent to complete todos before stopping (up to 3 reminders)",
106
+ get: (sm) => sm.getTodoCompletionEnabled(),
107
+ set: (sm, v) => sm.setTodoCompletionEnabled(v),
108
+ },
100
109
  {
101
110
  id: "showImages",
102
111
  tab: "config",
@@ -98,7 +98,7 @@ export class TodoDisplayComponent {
98
98
  }
99
99
 
100
100
  if (hasMore) {
101
- lines.push(theme.fg("dim", ` ${theme.tree.hook} +${this.todos.length - 5} more (Ctrl+T to expand)`));
101
+ lines.push(theme.fg("dim", ` ${theme.tree.hook} +${this.todos.length - 5} more (Ctrl+T to expand)`));
102
102
  }
103
103
 
104
104
  return lines;
@@ -0,0 +1,42 @@
1
+ import { Box, Container, Spacer, Text } from "@oh-my-pi/pi-tui";
2
+ import type { TodoItem } from "../../../core/tools/todo-write";
3
+ import { theme } from "../theme/theme";
4
+
5
+ /**
6
+ * Component that renders a todo completion reminder notification.
7
+ * Shows when the agent stops with incomplete todos.
8
+ */
9
+ export class TodoReminderComponent extends Container {
10
+ private todos: TodoItem[];
11
+ private attempt: number;
12
+ private maxAttempts: number;
13
+ private box: Box;
14
+
15
+ constructor(todos: TodoItem[], attempt: number, maxAttempts: number) {
16
+ super();
17
+ this.todos = todos;
18
+ this.attempt = attempt;
19
+ this.maxAttempts = maxAttempts;
20
+
21
+ this.addChild(new Spacer(1));
22
+
23
+ this.box = new Box(1, 1, (t) => theme.inverse(theme.fg("warning", t)));
24
+ this.addChild(this.box);
25
+
26
+ this.rebuild();
27
+ }
28
+
29
+ private rebuild(): void {
30
+ this.box.clear();
31
+
32
+ const count = this.todos.length;
33
+ const label = count === 1 ? "todo" : "todos";
34
+ const header = `${theme.icon.warning} ${count} incomplete ${label} - reminder ${this.attempt}/${this.maxAttempts}`;
35
+
36
+ this.box.addChild(new Text(header, 0, 0));
37
+ this.box.addChild(new Spacer(1));
38
+
39
+ const todoList = this.todos.map((t) => ` ${theme.checkbox.unchecked} ${t.content}`).join("\n");
40
+ this.box.addChild(new Text(theme.italic(todoList), 0, 0));
41
+ }
42
+ }
@@ -376,6 +376,7 @@ export class CommandController {
376
376
  this.ctx.chatContainer.addChild(
377
377
  new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
378
378
  );
379
+ await this.ctx.reloadTodos();
379
380
  this.ctx.ui.requestRender();
380
381
  }
381
382
 
@@ -476,6 +477,17 @@ export class CommandController {
476
477
  await this.executeCompaction(customInstructions, false);
477
478
  }
478
479
 
480
+ async handleSkillCommand(skillPath: string, args: string): Promise<void> {
481
+ try {
482
+ const content = fs.readFileSync(skillPath, "utf-8");
483
+ const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
484
+ const message = args ? `${body}\n\n---\n\nUser: ${args}` : body;
485
+ await this.ctx.session.prompt(message);
486
+ } catch (err) {
487
+ this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
488
+ }
489
+ }
490
+
479
491
  async executeCompaction(customInstructions?: string, isAuto = false): Promise<void> {
480
492
  if (this.ctx.loadingAnimation) {
481
493
  this.ctx.loadingAnimation.stop();
@@ -3,6 +3,7 @@ import type { AgentSessionEvent } from "../../../core/agent-session";
3
3
  import { detectNotificationProtocol, isNotificationSuppressed, sendNotification } from "../../../core/terminal-notify";
4
4
  import { AssistantMessageComponent } from "../components/assistant-message";
5
5
  import { ReadToolGroupComponent } from "../components/read-tool-group";
6
+ import { TodoReminderComponent } from "../components/todo-reminder";
6
7
  import { ToolExecutionComponent } from "../components/tool-execution";
7
8
  import { TtsrNotificationComponent } from "../components/ttsr-notification";
8
9
  import { getSymbolTheme, theme } from "../theme/theme";
@@ -150,6 +151,15 @@ export class EventController {
150
151
  if (event.message.role === "user") break;
151
152
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
152
153
  this.ctx.streamingMessage = event.message;
154
+ let errorMessage: string | undefined;
155
+ if (this.ctx.streamingMessage.stopReason === "aborted" && !this.ctx.session.isTtsrAbortPending) {
156
+ const retryAttempt = this.ctx.session.retryAttempt;
157
+ errorMessage =
158
+ retryAttempt > 0
159
+ ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
160
+ : "Operation aborted";
161
+ this.ctx.streamingMessage.errorMessage = errorMessage;
162
+ }
153
163
  if (this.ctx.session.isTtsrAbortPending && this.ctx.streamingMessage.stopReason === "aborted") {
154
164
  const msgWithoutAbort = { ...this.ctx.streamingMessage, stopReason: "stop" as const };
155
165
  this.ctx.streamingComponent.updateContent(msgWithoutAbort);
@@ -162,14 +172,7 @@ export class EventController {
162
172
  this.ctx.streamingMessage.stopReason === "error"
163
173
  ) {
164
174
  if (!this.ctx.session.isTtsrAbortPending) {
165
- let errorMessage: string;
166
- if (this.ctx.streamingMessage.stopReason === "aborted") {
167
- const retryAttempt = this.ctx.session.retryAttempt;
168
- errorMessage =
169
- retryAttempt > 0
170
- ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
171
- : "Operation aborted";
172
- } else {
175
+ if (!errorMessage) {
173
176
  errorMessage = this.ctx.streamingMessage.errorMessage || "Error";
174
177
  }
175
178
  for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
@@ -372,6 +375,13 @@ export class EventController {
372
375
  this.ctx.ui.requestRender();
373
376
  break;
374
377
  }
378
+
379
+ case "todo_reminder": {
380
+ const component = new TodoReminderComponent(event.todos, event.attempt, event.maxAttempts);
381
+ this.ctx.chatContainer.addChild(component);
382
+ this.ctx.ui.requestRender();
383
+ break;
384
+ }
375
385
  }
376
386
  }
377
387
 
@@ -25,7 +25,7 @@ export class ExtensionUiController {
25
25
  async initHooksAndCustomTools(): Promise<void> {
26
26
  // Create and set hook & tool UI context
27
27
  const uiContext: ExtensionUIContext = {
28
- select: (title, options, _dialogOptions) => this.showHookSelector(title, options),
28
+ select: (title, options, dialogOptions) => this.showHookSelector(title, options, dialogOptions?.initialIndex),
29
29
  confirm: (title, message, _dialogOptions) => this.showHookConfirm(title, message),
30
30
  input: (title, placeholder, _dialogOptions) => this.showHookInput(title, placeholder),
31
31
  notify: (message, type) => this.showHookNotify(message, type),
@@ -141,6 +141,7 @@ export class ExtensionUiController {
141
141
  this.ctx.chatContainer.addChild(
142
142
  new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
143
143
  );
144
+ await this.ctx.reloadTodos();
144
145
  this.ctx.ui.requestRender();
145
146
 
146
147
  return { cancelled: false };
@@ -154,6 +155,7 @@ export class ExtensionUiController {
154
155
  // Update UI
155
156
  this.ctx.chatContainer.clear();
156
157
  this.ctx.renderInitialMessages();
158
+ await this.ctx.reloadTodos();
157
159
  this.ctx.editor.setText(result.selectedText);
158
160
  this.ctx.showStatus("Branched to new session");
159
161
 
@@ -168,6 +170,7 @@ export class ExtensionUiController {
168
170
  // Update UI
169
171
  this.ctx.chatContainer.clear();
170
172
  this.ctx.renderInitialMessages();
173
+ await this.ctx.reloadTodos();
171
174
  if (result.editorText) {
172
175
  this.ctx.editor.setText(result.editorText);
173
176
  }
@@ -289,6 +292,7 @@ export class ExtensionUiController {
289
292
  this.ctx.chatContainer.addChild(
290
293
  new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
291
294
  );
295
+ await this.ctx.reloadTodos();
292
296
  this.ctx.ui.requestRender();
293
297
 
294
298
  return { cancelled: false };
@@ -305,6 +309,7 @@ export class ExtensionUiController {
305
309
  // Update UI
306
310
  this.ctx.chatContainer.clear();
307
311
  this.ctx.renderInitialMessages();
312
+ await this.ctx.reloadTodos();
308
313
  this.ctx.editor.setText(result.selectedText);
309
314
  this.ctx.showStatus("Branched to new session");
310
315
 
@@ -322,6 +327,7 @@ export class ExtensionUiController {
322
327
  // Update UI
323
328
  this.ctx.chatContainer.clear();
324
329
  this.ctx.renderInitialMessages();
330
+ await this.ctx.reloadTodos();
325
331
  if (result.editorText) {
326
332
  this.ctx.editor.setText(result.editorText);
327
333
  }
@@ -425,7 +431,7 @@ export class ExtensionUiController {
425
431
  /**
426
432
  * Show a selector for hooks.
427
433
  */
428
- showHookSelector(title: string, options: string[]): Promise<string | undefined> {
434
+ showHookSelector(title: string, options: string[], initialIndex?: number): Promise<string | undefined> {
429
435
  return new Promise((resolve) => {
430
436
  this.ctx.hookSelector = new HookSelectorComponent(
431
437
  title,
@@ -438,6 +444,7 @@ export class ExtensionUiController {
438
444
  this.hideHookSelector();
439
445
  resolve(undefined);
440
446
  },
447
+ { initialIndex },
441
448
  );
442
449
 
443
450
  this.ctx.editorContainer.clear();
@@ -24,14 +24,7 @@ export class InputController {
24
24
  setupKeyHandlers(): void {
25
25
  this.ctx.editor.onEscape = () => {
26
26
  if (this.ctx.loadingAnimation) {
27
- // Abort and restore queued messages to editor
28
- const queuedMessages = this.ctx.session.clearQueue();
29
- const queuedText = [...queuedMessages.steering, ...queuedMessages.followUp].join("\n\n");
30
- const currentText = this.ctx.editor.getText();
31
- const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n");
32
- this.ctx.editor.setText(combinedText);
33
- this.ctx.updatePendingMessagesDisplay();
34
- this.ctx.agent.abort();
27
+ this.restoreQueuedMessagesToEditor({ abort: true });
35
28
  } else if (this.ctx.session.isBashRunning) {
36
29
  this.ctx.session.abortBash();
37
30
  } else if (this.ctx.isBashMode) {
@@ -241,6 +234,27 @@ export class InputController {
241
234
  return;
242
235
  }
243
236
 
237
+ // Handle skill commands (/skill:name [args])
238
+ if (text.startsWith("/skill:")) {
239
+ const spaceIndex = text.indexOf(" ");
240
+ const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
241
+ const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
242
+ const skillPath = this.ctx.skillCommands?.get(commandName);
243
+ if (skillPath) {
244
+ this.ctx.editor.addToHistory(text);
245
+ this.ctx.editor.setText("");
246
+ try {
247
+ const content = fs.readFileSync(skillPath, "utf-8");
248
+ const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
249
+ const message = args ? `${body}\n\n---\n\nUser: ${args}` : body;
250
+ await this.ctx.session.prompt(message);
251
+ } catch (err) {
252
+ this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
253
+ }
254
+ return;
255
+ }
256
+ }
257
+
244
258
  // Handle bash command (! for normal, !! for excluded from context)
245
259
  if (text.startsWith("!")) {
246
260
  const isExcluded = text.startsWith("!!");
@@ -295,7 +309,7 @@ export class InputController {
295
309
  .then(async (title) => {
296
310
  if (title) {
297
311
  await this.ctx.sessionManager.setSessionTitle(title);
298
- setTerminalTitle(`omp: ${title}`);
312
+ setTerminalTitle(`π: ${title}`);
299
313
  }
300
314
  })
301
315
  .catch(() => {});
@@ -341,15 +355,33 @@ export class InputController {
341
355
  }
342
356
 
343
357
  handleDequeue(): void {
344
- const message = this.ctx.session.popLastQueuedMessage();
345
- if (!message) return;
358
+ const restored = this.restoreQueuedMessagesToEditor();
359
+ if (restored === 0) {
360
+ this.ctx.showStatus("No queued messages to restore");
361
+ } else {
362
+ this.ctx.showStatus(`Restored ${restored} queued message${restored > 1 ? "s" : ""} to editor`);
363
+ }
364
+ }
346
365
 
347
- // Prepend to existing editor text (if any)
348
- const currentText = this.ctx.editor.getText();
349
- const newText = currentText ? `${message}\n\n${currentText}` : message;
350
- this.ctx.editor.setText(newText);
366
+ restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
367
+ const { steering, followUp } = this.ctx.session.clearQueue();
368
+ const allQueued = [...steering, ...followUp];
369
+ if (allQueued.length === 0) {
370
+ this.ctx.updatePendingMessagesDisplay();
371
+ if (options?.abort) {
372
+ this.ctx.agent.abort();
373
+ }
374
+ return 0;
375
+ }
376
+ const queuedText = allQueued.join("\n\n");
377
+ const currentText = options?.currentText ?? this.ctx.editor.getText();
378
+ const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n");
379
+ this.ctx.editor.setText(combinedText);
351
380
  this.ctx.updatePendingMessagesDisplay();
352
- this.ctx.ui.requestRender();
381
+ if (options?.abort) {
382
+ this.ctx.agent.abort();
383
+ }
384
+ return allQueued.length;
353
385
  }
354
386
 
355
387
  handleBackgroundCommand(): void {
@@ -352,16 +352,41 @@ export class SelectorController {
352
352
  return;
353
353
  }
354
354
 
355
- // Ask about summarization (or skip if disabled in settings)
355
+ // Ask about summarization
356
356
  done(); // Close selector first
357
357
 
358
+ // Loop until user makes a complete choice or cancels to tree
359
+ let wantsSummary = false;
360
+ let customInstructions: string | undefined;
361
+
358
362
  const branchSummariesEnabled = this.ctx.settingsManager.getBranchSummaryEnabled();
359
- const wantsSummary = branchSummariesEnabled
360
- ? await this.ctx.showHookConfirm(
361
- "Summarize branch?",
362
- "Create a summary of the branch you're leaving?",
363
- )
364
- : false;
363
+
364
+ while (branchSummariesEnabled) {
365
+ const summaryChoice = await this.ctx.showHookSelector("Summarize branch?", [
366
+ "No summary",
367
+ "Summarize",
368
+ "Summarize with custom prompt",
369
+ ]);
370
+
371
+ if (summaryChoice === undefined) {
372
+ // User pressed escape - re-show tree selector
373
+ this.showTreeSelector();
374
+ return;
375
+ }
376
+
377
+ wantsSummary = summaryChoice !== "No summary";
378
+
379
+ if (summaryChoice === "Summarize with custom prompt") {
380
+ customInstructions = await this.ctx.showHookEditor("Custom summarization instructions");
381
+ if (customInstructions === undefined) {
382
+ // User cancelled - loop back to summary selector
383
+ continue;
384
+ }
385
+ }
386
+
387
+ // User made a complete choice
388
+ break;
389
+ }
365
390
 
366
391
  // Set up escape handler and loader if summarizing
367
392
  let summaryLoader: Loader | undefined;
@@ -384,7 +409,10 @@ export class SelectorController {
384
409
  }
385
410
 
386
411
  try {
387
- const result = await this.ctx.session.navigateTree(entryId, { summarize: wantsSummary });
412
+ const result = await this.ctx.session.navigateTree(entryId, {
413
+ summarize: wantsSummary,
414
+ customInstructions,
415
+ });
388
416
 
389
417
  if (result.aborted) {
390
418
  // Summarization aborted - re-show tree selector
@@ -400,6 +428,7 @@ export class SelectorController {
400
428
  // Update UI
401
429
  this.ctx.chatContainer.clear();
402
430
  this.ctx.renderInitialMessages();
431
+ await this.ctx.reloadTodos();
403
432
  if (result.editorText) {
404
433
  this.ctx.editor.setText(result.editorText);
405
434
  }
@@ -472,6 +501,7 @@ export class SelectorController {
472
501
  // Clear and re-render the chat
473
502
  this.ctx.chatContainer.clear();
474
503
  this.ctx.renderInitialMessages();
504
+ await this.ctx.reloadTodos();
475
505
  this.ctx.showStatus("Resumed session");
476
506
  }
477
507
 
@@ -109,6 +109,8 @@ export class InteractiveMode implements InteractiveModeContext {
109
109
  public lastEscapeTime = 0;
110
110
  public lastVoiceInterruptAt = 0;
111
111
  public voiceAutoModeEnabled = false;
112
+ public shutdownRequested = false;
113
+ private isShuttingDown = false;
112
114
  public voiceProgressTimer: ReturnType<typeof setTimeout> | undefined = undefined;
113
115
  public voiceProgressSpoken = false;
114
116
  public voiceProgressLastLength = 0;
@@ -118,6 +120,7 @@ export class InteractiveMode implements InteractiveModeContext {
118
120
  public lastStatusSpacer: Spacer | undefined = undefined;
119
121
  public lastStatusText: Text | undefined = undefined;
120
122
  public fileSlashCommands: Set<string> = new Set();
123
+ public skillCommands: Map<string, string> = new Map();
121
124
 
122
125
  private pendingSlashCommands: SlashCommand[] = [];
123
126
  private cleanupUnsubscribe?: () => void;
@@ -231,8 +234,18 @@ export class InteractiveMode implements InteractiveModeContext {
231
234
  description: `${loaded.command.description} (${loaded.source})`,
232
235
  }));
233
236
 
237
+ // Build skill commands from session.skills (if enabled)
238
+ const skillCommandList: SlashCommand[] = [];
239
+ if (this.settingsManager.getEnableSkillCommands?.()) {
240
+ for (const skill of this.session.skills) {
241
+ const commandName = `skill:${skill.name}`;
242
+ this.skillCommands.set(commandName, skill.filePath);
243
+ skillCommandList.push({ name: commandName, description: skill.description });
244
+ }
245
+ }
246
+
234
247
  // Store pending commands for init() where file commands are loaded async
235
- this.pendingSlashCommands = [...slashCommands, ...hookCommands, ...customCommands];
248
+ this.pendingSlashCommands = [...slashCommands, ...hookCommands, ...customCommands, ...skillCommandList];
236
249
 
237
250
  this.uiHelpers = new UiHelpers(this);
238
251
  this.voiceManager = new VoiceManager(this);
@@ -433,7 +446,7 @@ export class InteractiveMode implements InteractiveModeContext {
433
446
 
434
447
  if (!this.todoExpanded && visibleTodos.length < this.todoItems.length) {
435
448
  const remaining = this.todoItems.length - visibleTodos.length;
436
- lines.push(theme.fg("muted", `${indent}${hook} +${remaining} more (Ctrl+T to expand)`));
449
+ lines.push(theme.fg("muted", `${indent} ${hook} +${remaining} more (Ctrl+T to expand)`));
437
450
  }
438
451
 
439
452
  this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0));
@@ -486,6 +499,9 @@ export class InteractiveMode implements InteractiveModeContext {
486
499
  }
487
500
 
488
501
  async shutdown(): Promise<void> {
502
+ if (this.isShuttingDown) return;
503
+ this.isShuttingDown = true;
504
+
489
505
  this.voiceAutoModeEnabled = false;
490
506
  await this.voiceSupervisor.stop();
491
507
 
@@ -499,6 +515,11 @@ export class InteractiveMode implements InteractiveModeContext {
499
515
  process.exit(0);
500
516
  }
501
517
 
518
+ async checkShutdownRequested(): Promise<void> {
519
+ if (!this.shutdownRequested) return;
520
+ await this.shutdown();
521
+ }
522
+
502
523
  // Extension UI integration
503
524
  setToolUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void {
504
525
  this.toolUiContextSetter(uiContext, hasUI);
@@ -736,6 +757,11 @@ export class InteractiveMode implements InteractiveModeContext {
736
757
  this.ui.requestRender();
737
758
  }
738
759
 
760
+ async reloadTodos(): Promise<void> {
761
+ await this.loadTodoList();
762
+ this.ui.requestRender();
763
+ }
764
+
739
765
  openExternalEditor(): void {
740
766
  this.inputController.openExternalEditor();
741
767
  }
@@ -789,8 +815,8 @@ export class InteractiveMode implements InteractiveModeContext {
789
815
  this.extensionUiController.setHookStatus(key, text);
790
816
  }
791
817
 
792
- showHookSelector(title: string, options: string[]): Promise<string | undefined> {
793
- return this.extensionUiController.showHookSelector(title, options);
818
+ showHookSelector(title: string, options: string[], initialIndex?: number): Promise<string | undefined> {
819
+ return this.extensionUiController.showHookSelector(title, options, initialIndex);
794
820
  }
795
821
 
796
822
  hideHookSelector(): void {
@@ -74,6 +74,7 @@ export interface InteractiveModeContext {
74
74
  lastEscapeTime: number;
75
75
  lastVoiceInterruptAt: number;
76
76
  voiceAutoModeEnabled: boolean;
77
+ shutdownRequested: boolean;
77
78
  voiceProgressTimer: ReturnType<typeof setTimeout> | undefined;
78
79
  voiceProgressSpoken: boolean;
79
80
  voiceProgressLastLength: number;
@@ -83,11 +84,13 @@ export interface InteractiveModeContext {
83
84
  lastStatusSpacer: Spacer | undefined;
84
85
  lastStatusText: Text | undefined;
85
86
  fileSlashCommands: Set<string>;
87
+ skillCommands: Map<string, string>;
86
88
  todoItems: TodoItem[];
87
89
 
88
90
  // Lifecycle
89
91
  init(): Promise<void>;
90
92
  shutdown(): Promise<void>;
93
+ checkShutdownRequested(): Promise<void>;
91
94
 
92
95
  // Extension UI integration
93
96
  setToolUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void;
@@ -121,6 +124,7 @@ export interface InteractiveModeContext {
121
124
  updateEditorBorderColor(): void;
122
125
  rebuildChatFromMessages(): void;
123
126
  setTodos(todos: TodoItem[]): void;
127
+ reloadTodos(): Promise<void>;
124
128
  toggleTodoExpansion(): void;
125
129
 
126
130
  // Command handling
@@ -181,7 +185,7 @@ export interface InteractiveModeContext {
181
185
  ): Promise<void>;
182
186
  setHookWidget(key: string, content: unknown): void;
183
187
  setHookStatus(key: string, text: string | undefined): void;
184
- showHookSelector(title: string, options: string[]): Promise<string | undefined>;
188
+ showHookSelector(title: string, options: string[], initialIndex?: number): Promise<string | undefined>;
185
189
  hideHookSelector(): void;
186
190
  showHookInput(title: string, placeholder?: string): Promise<string | undefined>;
187
191
  hideHookInput(): void;
@@ -189,6 +189,14 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
189
189
  // Component factories are not supported in RPC mode - would need TUI access
190
190
  },
191
191
 
192
+ setFooter(_factory: unknown): void {
193
+ // Custom footer not supported in RPC mode - requires TUI access
194
+ },
195
+
196
+ setHeader(_factory: unknown): void {
197
+ // Custom header not supported in RPC mode - requires TUI access
198
+ },
199
+
192
200
  setTitle(title: string): void {
193
201
  // Fire and forget - host can implement terminal title control
194
202
  output({
@@ -257,9 +265,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
257
265
  return { success: false, error: "Theme switching not supported in RPC mode" };
258
266
  },
259
267
 
260
- setFooter() {},
261
- setHeader() {},
262
- setEditorComponent() {},
268
+ setEditorComponent(): void {
269
+ // Custom editor components not supported in RPC mode
270
+ },
263
271
  });
264
272
 
265
273
  // Set up extensions with RPC-based UI context
@@ -13,11 +13,25 @@ Tips:
13
13
  - 2-5 concise, distinct options
14
14
  - Users can always select "Other" for custom input
15
15
 
16
+ **Do NOT include an "Other" option in your options array.** The UI automatically adds "Other (type your own)" to every question. Adding your own creates duplicate "Other" options.
17
+
16
18
  <example>
17
19
  question: "Which authentication method should this API use?"
18
20
  options: [{"label": "JWT (Recommended)"}, {"label": "OAuth2"}, {"label": "Session cookies"}]
19
21
  </example>
20
22
 
23
+ ## Multi-part questions
24
+
25
+ When you have multiple related questions, use the `questions` array instead of asking one at a time. Each question has its own id, options, and optional `multi` flag.
26
+
27
+ <example>
28
+ questions: [
29
+ {"id": "auth", "question": "Which auth method?", "options": [{"label": "JWT"}, {"label": "OAuth2"}]},
30
+ {"id": "cache", "question": "Enable caching?", "options": [{"label": "Yes"}, {"label": "No"}]},
31
+ {"id": "features", "question": "Which features to include?", "options": [{"label": "Logging"}, {"label": "Metrics"}, {"label": "Tracing"}], "multi": true}
32
+ ]
33
+ </example>
34
+
21
35
  ## Critical: Resolve before asking
22
36
 
23
37
  **Exhaust all other options before asking.** Questions interrupt user flow.