@oh-my-pi/pi-coding-agent 4.3.2 → 4.4.5

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 (54) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +5 -6
  3. package/src/core/frontmatter.ts +98 -0
  4. package/src/core/keybindings.ts +1 -1
  5. package/src/core/prompt-templates.ts +5 -34
  6. package/src/core/sdk.ts +3 -0
  7. package/src/core/skills.ts +3 -3
  8. package/src/core/slash-commands.ts +14 -5
  9. package/src/core/tools/calculator.ts +1 -1
  10. package/src/core/tools/edit.ts +2 -2
  11. package/src/core/tools/exa/render.ts +23 -11
  12. package/src/core/tools/index.test.ts +2 -0
  13. package/src/core/tools/index.ts +3 -0
  14. package/src/core/tools/jtd-to-json-schema.ts +1 -6
  15. package/src/core/tools/ls.ts +5 -2
  16. package/src/core/tools/lsp/config.ts +2 -2
  17. package/src/core/tools/lsp/render.ts +33 -12
  18. package/src/core/tools/notebook.ts +1 -1
  19. package/src/core/tools/output.ts +1 -1
  20. package/src/core/tools/read.ts +15 -49
  21. package/src/core/tools/render-utils.ts +61 -24
  22. package/src/core/tools/renderers.ts +2 -0
  23. package/src/core/tools/schema-validation.test.ts +501 -0
  24. package/src/core/tools/task/agents.ts +6 -2
  25. package/src/core/tools/task/commands.ts +9 -3
  26. package/src/core/tools/task/discovery.ts +3 -2
  27. package/src/core/tools/task/render.ts +10 -7
  28. package/src/core/tools/todo-write.ts +256 -0
  29. package/src/core/tools/web-fetch.ts +4 -2
  30. package/src/core/tools/web-scrapers/choosealicense.ts +2 -2
  31. package/src/core/tools/web-search/render.ts +13 -10
  32. package/src/core/tools/write.ts +2 -2
  33. package/src/discovery/builtin.ts +4 -4
  34. package/src/discovery/cline.ts +4 -3
  35. package/src/discovery/codex.ts +3 -3
  36. package/src/discovery/cursor.ts +2 -2
  37. package/src/discovery/github.ts +3 -2
  38. package/src/discovery/helpers.test.ts +14 -10
  39. package/src/discovery/helpers.ts +2 -39
  40. package/src/discovery/windsurf.ts +3 -3
  41. package/src/modes/interactive/components/custom-editor.ts +4 -11
  42. package/src/modes/interactive/components/index.ts +2 -1
  43. package/src/modes/interactive/components/read-tool-group.ts +118 -0
  44. package/src/modes/interactive/components/todo-display.ts +112 -0
  45. package/src/modes/interactive/components/tool-execution.ts +18 -2
  46. package/src/modes/interactive/controllers/command-controller.ts +2 -2
  47. package/src/modes/interactive/controllers/event-controller.ts +91 -32
  48. package/src/modes/interactive/controllers/input-controller.ts +19 -13
  49. package/src/modes/interactive/interactive-mode.ts +103 -3
  50. package/src/modes/interactive/theme/theme.ts +4 -0
  51. package/src/modes/interactive/types.ts +14 -2
  52. package/src/modes/interactive/utils/ui-helpers.ts +55 -26
  53. package/src/prompts/system/system-prompt.md +177 -126
  54. package/src/prompts/tools/todo-write.md +187 -0
@@ -60,14 +60,14 @@ export class InputController {
60
60
  this.ctx.editor.onShiftTab = () => this.cycleThinkingLevel();
61
61
  this.ctx.editor.onCtrlP = () => this.cycleRoleModel();
62
62
  this.ctx.editor.onShiftCtrlP = () => this.cycleRoleModel({ temporary: true });
63
- this.ctx.editor.onCtrlY = () => this.ctx.showModelSelector({ temporaryOnly: true });
63
+ this.ctx.editor.onAltP = () => this.ctx.showModelSelector({ temporaryOnly: true });
64
64
 
65
65
  // Global debug handler on TUI (works regardless of focus)
66
66
  this.ctx.ui.onDebug = () => this.ctx.handleDebugCommand();
67
67
  this.ctx.editor.onCtrlL = () => this.ctx.showModelSelector();
68
68
  this.ctx.editor.onCtrlR = () => this.ctx.showHistorySearch();
69
69
  this.ctx.editor.onCtrlO = () => this.toggleToolOutputExpansion();
70
- this.ctx.editor.onCtrlT = () => this.toggleThinkingBlockVisibility();
70
+ this.ctx.editor.onCtrlT = () => this.ctx.toggleTodoExpansion();
71
71
  this.ctx.editor.onCtrlG = () => this.openExternalEditor();
72
72
  this.ctx.editor.onQuestionMark = () => this.ctx.handleHotkeysCommand();
73
73
  this.ctx.editor.onCtrlV = () => this.handleImagePaste();
@@ -85,28 +85,34 @@ export class InputController {
85
85
  };
86
86
 
87
87
  this.ctx.editor.onAltEnter = async (text: string) => {
88
- text = text.trim();
89
- if (!text) return;
88
+ const trimmedText = text.trim();
90
89
 
91
90
  // Queue follow-up messages while compaction is running
92
91
  if (this.ctx.session.isCompacting) {
93
- this.ctx.queueCompactionMessage(text, "followUp");
92
+ if (!trimmedText) {
93
+ this.ctx.editor.handleInput("\n");
94
+ return;
95
+ }
96
+ this.ctx.queueCompactionMessage(trimmedText, "followUp");
94
97
  return;
95
98
  }
96
99
 
97
- // Alt+Enter queues a follow-up message (waits until agent finishes)
98
- // This handles extension commands (execute immediately), prompt template expansion, and queueing
100
+ // Alt+Enter queues a follow-up message while streaming
99
101
  if (this.ctx.session.isStreaming) {
100
- this.ctx.editor.addToHistory(text);
102
+ if (!trimmedText) {
103
+ this.ctx.editor.handleInput("\n");
104
+ return;
105
+ }
106
+ this.ctx.editor.addToHistory(trimmedText);
101
107
  this.ctx.editor.setText("");
102
- await this.ctx.session.prompt(text, { streamingBehavior: "followUp" });
108
+ await this.ctx.session.prompt(trimmedText, { streamingBehavior: "followUp" });
103
109
  this.ctx.updatePendingMessagesDisplay();
104
110
  this.ctx.ui.requestRender();
111
+ return;
105
112
  }
106
- // If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit)
107
- else if (this.ctx.editor.onSubmit) {
108
- this.ctx.editor.onSubmit(text);
109
- }
113
+
114
+ // Default behavior: insert a new line
115
+ this.ctx.editor.handleInput("\n");
110
116
  };
111
117
  }
112
118
 
@@ -16,6 +16,7 @@ import {
16
16
  Text,
17
17
  TUI,
18
18
  } from "@oh-my-pi/pi-tui";
19
+ import chalk from "chalk";
19
20
  import type { AgentSession, AgentSessionEvent } from "../../core/agent-session";
20
21
  import type { ExtensionUIContext } from "../../core/extensions/index";
21
22
  import { HistoryStorage } from "../../core/history-storage";
@@ -26,6 +27,7 @@ import { getRecentSessions } from "../../core/session-manager";
26
27
  import type { SettingsManager } from "../../core/settings-manager";
27
28
  import { loadSlashCommands } from "../../core/slash-commands";
28
29
  import { setTerminalTitle } from "../../core/title-generator";
30
+ import { getArtifactsDir } from "../../core/tools/task/artifacts";
29
31
  import { VoiceSupervisor } from "../../core/voice-supervisor";
30
32
  import { registerAsyncCleanup } from "../cleanup";
31
33
  import type { AssistantMessageComponent } from "./components/assistant-message";
@@ -36,7 +38,7 @@ import type { HookEditorComponent } from "./components/hook-editor";
36
38
  import type { HookInputComponent } from "./components/hook-input";
37
39
  import type { HookSelectorComponent } from "./components/hook-selector";
38
40
  import { StatusLineComponent } from "./components/status-line";
39
- import type { ToolExecutionComponent } from "./components/tool-execution";
41
+ import type { ToolExecutionHandle } from "./components/tool-execution";
40
42
  import { WelcomeComponent } from "./components/welcome";
41
43
  import { CommandController } from "./controllers/command-controller";
42
44
  import { EventController } from "./controllers/event-controller";
@@ -45,10 +47,12 @@ import { InputController } from "./controllers/input-controller";
45
47
  import { SelectorController } from "./controllers/selector-controller";
46
48
  import type { Theme } from "./theme/theme";
47
49
  import { getEditorTheme, getMarkdownTheme, onThemeChange, theme } from "./theme/theme";
48
- import type { CompactionQueuedMessage, InteractiveModeContext } from "./types";
50
+ import type { CompactionQueuedMessage, InteractiveModeContext, TodoItem } from "./types";
49
51
  import { UiHelpers } from "./utils/ui-helpers";
50
52
  import { VoiceManager } from "./utils/voice-manager";
51
53
 
54
+ const TODO_FILE_NAME = "todos.json";
55
+
52
56
  /** Options for creating an InteractiveMode instance (for future API use) */
53
57
  export interface InteractiveModeOptions {
54
58
  /** Providers that were migrated during startup */
@@ -75,6 +79,7 @@ export class InteractiveMode implements InteractiveModeContext {
75
79
  public chatContainer: Container;
76
80
  public pendingMessagesContainer: Container;
77
81
  public statusContainer: Container;
82
+ public todoContainer: Container;
78
83
  public editor: CustomEditor;
79
84
  public editorContainer: Container;
80
85
  public statusLine: StatusLineComponent;
@@ -83,10 +88,12 @@ export class InteractiveMode implements InteractiveModeContext {
83
88
  public isBackgrounded = false;
84
89
  public isBashMode = false;
85
90
  public toolOutputExpanded = false;
91
+ public todoExpanded = false;
92
+ public todoItems: TodoItem[] = [];
86
93
  public hideThinkingBlock = false;
87
94
  public pendingImages: ImageContent[] = [];
88
95
  public compactionQueuedMessages: CompactionQueuedMessage[] = [];
89
- public pendingTools = new Map<string, ToolExecutionComponent>();
96
+ public pendingTools = new Map<string, ToolExecutionHandle>();
90
97
  public pendingBashComponents: BashExecutionComponent[] = [];
91
98
  public bashComponent: BashExecutionComponent | undefined = undefined;
92
99
  public streamingComponent: AssistantMessageComponent | undefined = undefined;
@@ -148,6 +155,7 @@ export class InteractiveMode implements InteractiveModeContext {
148
155
  this.chatContainer = new Container();
149
156
  this.pendingMessagesContainer = new Container();
150
157
  this.statusContainer = new Container();
158
+ this.todoContainer = new Container();
151
159
  this.editor = new CustomEditor(getEditorTheme());
152
160
  this.editor.setUseTerminalCursor(true);
153
161
  this.editor.onAutocompleteCancel = () => {
@@ -308,6 +316,7 @@ export class InteractiveMode implements InteractiveModeContext {
308
316
  this.ui.addChild(this.chatContainer);
309
317
  this.ui.addChild(this.pendingMessagesContainer);
310
318
  this.ui.addChild(this.statusContainer);
319
+ this.ui.addChild(this.todoContainer);
311
320
  this.ui.addChild(new Spacer(1));
312
321
  this.ui.addChild(this.editorContainer);
313
322
  this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border)
@@ -316,6 +325,9 @@ export class InteractiveMode implements InteractiveModeContext {
316
325
  this.inputController.setupKeyHandlers();
317
326
  this.inputController.setupEditorSubmitHandler();
318
327
 
328
+ // Load initial todos
329
+ await this.loadTodoList();
330
+
319
331
  // Start the UI
320
332
  this.ui.start();
321
333
  this.isInitialized = true;
@@ -379,6 +391,82 @@ export class InteractiveMode implements InteractiveModeContext {
379
391
  this.renderSessionContext(context);
380
392
  }
381
393
 
394
+ private formatTodoLine(todo: TodoItem, prefix: string): string {
395
+ const checkbox = theme.checkbox;
396
+ const label = todo.status === "in_progress" ? todo.activeForm : todo.content;
397
+ switch (todo.status) {
398
+ case "completed":
399
+ return theme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(todo.content)}`);
400
+ case "in_progress":
401
+ return theme.fg("accent", `${prefix}${checkbox.unchecked} ${label}`);
402
+ default:
403
+ return theme.fg("dim", `${prefix}${checkbox.unchecked} ${label}`);
404
+ }
405
+ }
406
+
407
+ private getCollapsedTodos(todos: TodoItem[]): TodoItem[] {
408
+ let startIndex = 0;
409
+ for (let i = todos.length - 1; i >= 0; i -= 1) {
410
+ if (todos[i].status === "completed") {
411
+ startIndex = i;
412
+ break;
413
+ }
414
+ }
415
+ return todos.slice(startIndex, startIndex + 5);
416
+ }
417
+
418
+ private renderTodoList(): void {
419
+ this.todoContainer.clear();
420
+ if (this.todoItems.length === 0) {
421
+ return;
422
+ }
423
+
424
+ const visibleTodos = this.todoExpanded ? this.todoItems : this.getCollapsedTodos(this.todoItems);
425
+ const indent = " ";
426
+ const hook = theme.tree.hook;
427
+ const lines = [indent + theme.bold(theme.fg("accent", "Todos"))];
428
+
429
+ visibleTodos.forEach((todo, index) => {
430
+ const prefix = `${indent}${index === 0 ? hook : " "} `;
431
+ lines.push(this.formatTodoLine(todo, prefix));
432
+ });
433
+
434
+ if (!this.todoExpanded && visibleTodos.length < this.todoItems.length) {
435
+ const remaining = this.todoItems.length - visibleTodos.length;
436
+ lines.push(theme.fg("muted", `${indent}${hook} +${remaining} more (Ctrl+T to expand)`));
437
+ }
438
+
439
+ this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0));
440
+ }
441
+
442
+ private async loadTodoList(): Promise<void> {
443
+ const sessionFile = this.sessionManager.getSessionFile() ?? null;
444
+ if (!sessionFile) {
445
+ this.renderTodoList();
446
+ return;
447
+ }
448
+ const artifactsDir = getArtifactsDir(sessionFile);
449
+ if (!artifactsDir) {
450
+ this.renderTodoList();
451
+ return;
452
+ }
453
+ const todoPath = path.join(artifactsDir, TODO_FILE_NAME);
454
+ const file = Bun.file(todoPath);
455
+ if (!(await file.exists())) {
456
+ this.renderTodoList();
457
+ return;
458
+ }
459
+ try {
460
+ const data = (await file.json()) as { todos?: TodoItem[] };
461
+ if (data?.todos && Array.isArray(data.todos)) {
462
+ this.todoItems = data.todos;
463
+ }
464
+ } catch (error) {
465
+ logger.warn("Failed to load todos", { path: todoPath, error: String(error) });
466
+ }
467
+ this.renderTodoList();
468
+ }
469
+
382
470
  stop(): void {
383
471
  if (this.loadingAnimation) {
384
472
  this.loadingAnimation.stop();
@@ -636,6 +724,18 @@ export class InteractiveMode implements InteractiveModeContext {
636
724
  this.inputController.toggleThinkingBlockVisibility();
637
725
  }
638
726
 
727
+ toggleTodoExpansion(): void {
728
+ this.todoExpanded = !this.todoExpanded;
729
+ this.renderTodoList();
730
+ this.ui.requestRender();
731
+ }
732
+
733
+ setTodos(todos: TodoItem[]): void {
734
+ this.todoItems = todos;
735
+ this.renderTodoList();
736
+ this.ui.requestRender();
737
+ }
738
+
639
739
  openExternalEditor(): void {
640
740
  this.inputController.openExternalEditor();
641
741
  }
@@ -1325,6 +1325,10 @@ export class Theme {
1325
1325
  return chalk.underline(text);
1326
1326
  }
1327
1327
 
1328
+ strikethrough(text: string): string {
1329
+ return chalk.strikethrough(text);
1330
+ }
1331
+
1328
1332
  inverse(text: string): string {
1329
1333
  return chalk.inverse(text);
1330
1334
  }
@@ -15,7 +15,7 @@ import type { HookEditorComponent } from "./components/hook-editor";
15
15
  import type { HookInputComponent } from "./components/hook-input";
16
16
  import type { HookSelectorComponent } from "./components/hook-selector";
17
17
  import type { StatusLineComponent } from "./components/status-line";
18
- import type { ToolExecutionComponent } from "./components/tool-execution";
18
+ import type { ToolExecutionHandle } from "./components/tool-execution";
19
19
  import type { Theme } from "./theme/theme";
20
20
 
21
21
  export type CompactionQueuedMessage = {
@@ -23,12 +23,20 @@ export type CompactionQueuedMessage = {
23
23
  mode: "steer" | "followUp";
24
24
  };
25
25
 
26
+ export type TodoItem = {
27
+ id: string;
28
+ content: string;
29
+ activeForm: string;
30
+ status: "pending" | "in_progress" | "completed";
31
+ };
32
+
26
33
  export interface InteractiveModeContext {
27
34
  // UI access
28
35
  ui: TUI;
29
36
  chatContainer: Container;
30
37
  pendingMessagesContainer: Container;
31
38
  statusContainer: Container;
39
+ todoContainer: Container;
32
40
  editor: CustomEditor;
33
41
  editorContainer: Container;
34
42
  statusLine: StatusLineComponent;
@@ -46,10 +54,11 @@ export interface InteractiveModeContext {
46
54
  isBackgrounded: boolean;
47
55
  isBashMode: boolean;
48
56
  toolOutputExpanded: boolean;
57
+ todoExpanded: boolean;
49
58
  hideThinkingBlock: boolean;
50
59
  pendingImages: ImageContent[];
51
60
  compactionQueuedMessages: CompactionQueuedMessage[];
52
- pendingTools: Map<string, ToolExecutionComponent>;
61
+ pendingTools: Map<string, ToolExecutionHandle>;
53
62
  pendingBashComponents: BashExecutionComponent[];
54
63
  bashComponent: BashExecutionComponent | undefined;
55
64
  streamingComponent: AssistantMessageComponent | undefined;
@@ -74,6 +83,7 @@ export interface InteractiveModeContext {
74
83
  lastStatusSpacer: Spacer | undefined;
75
84
  lastStatusText: Text | undefined;
76
85
  fileSlashCommands: Set<string>;
86
+ todoItems: TodoItem[];
77
87
 
78
88
  // Lifecycle
79
89
  init(): Promise<void>;
@@ -110,6 +120,8 @@ export interface InteractiveModeContext {
110
120
  updateEditorTopBorder(): void;
111
121
  updateEditorBorderColor(): void;
112
122
  rebuildChatFromMessages(): void;
123
+ setTodos(todos: TodoItem[]): void;
124
+ toggleTodoExpansion(): void;
113
125
 
114
126
  // Command handling
115
127
  handleExportCommand(text: string): Promise<void>;
@@ -10,6 +10,7 @@ import { BranchSummaryMessageComponent } from "../components/branch-summary-mess
10
10
  import { CompactionSummaryMessageComponent } from "../components/compaction-summary-message";
11
11
  import { CustomMessageComponent } from "../components/custom-message";
12
12
  import { DynamicBorder } from "../components/dynamic-border";
13
+ import { ReadToolGroupComponent } from "../components/read-tool-group";
13
14
  import { ToolExecutionComponent } from "../components/tool-execution";
14
15
  import { UserMessageComponent } from "../components/user-message";
15
16
  import { theme } from "../theme/theme";
@@ -159,47 +160,75 @@ export class UiHelpers {
159
160
  this.ctx.updateEditorBorderColor();
160
161
  }
161
162
 
163
+ let readGroup: ReadToolGroupComponent | null = null;
162
164
  for (const message of sessionContext.messages) {
163
165
  // Assistant messages need special handling for tool calls
164
166
  if (message.role === "assistant") {
165
167
  this.ctx.addMessageToChat(message);
168
+ readGroup = null;
169
+ const hasErrorStop = message.stopReason === "aborted" || message.stopReason === "error";
170
+ const errorMessage = hasErrorStop
171
+ ? message.stopReason === "aborted"
172
+ ? (() => {
173
+ const retryAttempt = this.ctx.session.retryAttempt;
174
+ return retryAttempt > 0
175
+ ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
176
+ : "Operation aborted";
177
+ })()
178
+ : message.errorMessage || "Error"
179
+ : null;
180
+
166
181
  // Render tool call components
167
182
  for (const content of message.content) {
168
- if (content.type === "toolCall") {
169
- const tool = this.ctx.session.getToolByName(content.name);
170
- const component = new ToolExecutionComponent(
171
- content.name,
172
- content.arguments,
173
- { showImages: this.ctx.settingsManager.getShowImages() },
174
- tool,
175
- this.ctx.ui,
176
- this.ctx.sessionManager.getCwd(),
177
- );
178
- component.setExpanded(this.ctx.toolOutputExpanded);
179
- this.ctx.chatContainer.addChild(component);
183
+ if (content.type !== "toolCall") continue;
180
184
 
181
- if (message.stopReason === "aborted" || message.stopReason === "error") {
182
- let errorMessage: string;
183
- if (message.stopReason === "aborted") {
184
- const retryAttempt = this.ctx.session.retryAttempt;
185
- errorMessage =
186
- retryAttempt > 0
187
- ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
188
- : "Operation aborted";
189
- } else {
190
- errorMessage = message.errorMessage || "Error";
191
- }
192
- component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
185
+ if (content.name === "read") {
186
+ if (!readGroup) {
187
+ readGroup = new ReadToolGroupComponent();
188
+ readGroup.setExpanded(this.ctx.toolOutputExpanded);
189
+ this.ctx.chatContainer.addChild(readGroup);
190
+ }
191
+ readGroup.updateArgs(content.arguments, content.id);
192
+ if (hasErrorStop && errorMessage) {
193
+ readGroup.updateResult(
194
+ { content: [{ type: "text", text: errorMessage }], isError: true },
195
+ false,
196
+ content.id,
197
+ );
193
198
  } else {
194
- this.ctx.pendingTools.set(content.id, component);
199
+ this.ctx.pendingTools.set(content.id, readGroup);
195
200
  }
201
+ continue;
202
+ }
203
+
204
+ readGroup = null;
205
+ const tool = this.ctx.session.getToolByName(content.name);
206
+ const component = new ToolExecutionComponent(
207
+ content.name,
208
+ content.arguments,
209
+ { showImages: this.ctx.settingsManager.getShowImages() },
210
+ tool,
211
+ this.ctx.ui,
212
+ this.ctx.sessionManager.getCwd(),
213
+ );
214
+ component.setExpanded(this.ctx.toolOutputExpanded);
215
+ this.ctx.chatContainer.addChild(component);
216
+
217
+ if (hasErrorStop && errorMessage) {
218
+ component.updateResult(
219
+ { content: [{ type: "text", text: errorMessage }], isError: true },
220
+ false,
221
+ content.id,
222
+ );
223
+ } else {
224
+ this.ctx.pendingTools.set(content.id, component);
196
225
  }
197
226
  }
198
227
  } else if (message.role === "toolResult") {
199
228
  // Match tool results to pending tool components
200
229
  const component = this.ctx.pendingTools.get(message.toolCallId);
201
230
  if (component) {
202
- component.updateResult(message);
231
+ component.updateResult(message, false, message.toolCallId);
203
232
  this.ctx.pendingTools.delete(message.toolCallId);
204
233
  }
205
234
  } else {