@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.
- package/CHANGELOG.md +30 -0
- package/package.json +5 -5
- package/src/cli/config-cli.ts +344 -0
- package/src/core/agent-session.ts +112 -1
- package/src/core/extensions/types.ts +2 -0
- package/src/core/hooks/loader.ts +6 -3
- package/src/core/hooks/tool-wrapper.ts +1 -0
- package/src/core/session-manager.ts +4 -1
- package/src/core/settings-manager.ts +43 -0
- package/src/core/tools/ask.ts +272 -99
- package/src/core/tools/index.ts +1 -1
- package/src/core/tools/schema-validation.test.ts +30 -0
- package/src/core/tools/task/model-resolver.ts +28 -2
- package/src/main.ts +12 -0
- package/src/modes/interactive/components/hook-editor.ts +1 -1
- package/src/modes/interactive/components/hook-message.ts +5 -0
- package/src/modes/interactive/components/hook-selector.ts +3 -1
- package/src/modes/interactive/components/index.ts +1 -0
- package/src/modes/interactive/components/read-tool-group.ts +12 -4
- package/src/modes/interactive/components/settings-defs.ts +9 -0
- package/src/modes/interactive/components/todo-display.ts +1 -1
- package/src/modes/interactive/components/todo-reminder.ts +42 -0
- package/src/modes/interactive/controllers/command-controller.ts +12 -0
- package/src/modes/interactive/controllers/event-controller.ts +18 -8
- package/src/modes/interactive/controllers/extension-ui-controller.ts +9 -2
- package/src/modes/interactive/controllers/input-controller.ts +48 -16
- package/src/modes/interactive/controllers/selector-controller.ts +38 -8
- package/src/modes/interactive/interactive-mode.ts +30 -4
- package/src/modes/interactive/types.ts +5 -1
- package/src/modes/rpc/rpc-mode.ts +11 -3
- 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
|
|
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} ${
|
|
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", `
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
|
345
|
-
if (
|
|
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
|
-
|
|
348
|
-
const
|
|
349
|
-
const
|
|
350
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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, {
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
package/src/prompts/tools/ask.md
CHANGED
|
@@ -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.
|