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

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 +45 -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
@@ -5,11 +5,10 @@
5
5
  import { homedir } from "node:os";
6
6
  import { join, resolve } from "node:path";
7
7
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
8
- import { parse as parseYAML } from "yaml";
9
8
  import { readDirEntries, readFile } from "../capability/fs";
10
9
  import type { Skill, SkillFrontmatter } from "../capability/skill";
11
10
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
12
- import { logger } from "../core/logger";
11
+ import { parseFrontmatter } from "../core/frontmatter";
13
12
 
14
13
  const VALID_THINKING_LEVELS: readonly string[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
15
14
  const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
@@ -120,42 +119,6 @@ export function createSourceMeta(provider: string, path: string, level: "user" |
120
119
  };
121
120
  }
122
121
 
123
- /**
124
- * Strip YAML frontmatter from content.
125
- * Returns { frontmatter, body, raw }
126
- */
127
- export function parseFrontmatter(content: string): {
128
- frontmatter: Record<string, unknown>;
129
- body: string;
130
- raw: string;
131
- } {
132
- const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
133
-
134
- if (!normalized.startsWith("---")) {
135
- return { frontmatter: {}, body: normalized, raw: "" };
136
- }
137
-
138
- const endIndex = normalized.indexOf("\n---", 3);
139
- if (endIndex === -1) {
140
- return { frontmatter: {}, body: normalized, raw: "" };
141
- }
142
-
143
- const raw = normalized.slice(4, endIndex);
144
- const body = normalized.slice(endIndex + 4).trim();
145
-
146
- try {
147
- // Replace tabs with spaces for YAML compatibility, use failsafe mode for robustness
148
- const frontmatter = parseYAML(raw.replaceAll("\t", " "), { compat: "failsafe" }) as Record<
149
- string,
150
- unknown
151
- > | null;
152
- return { frontmatter: frontmatter ?? {}, body, raw };
153
- } catch (error) {
154
- logger.warn("Failed to parse YAML frontmatter", { error: String(error) });
155
- return { frontmatter: {}, body, raw };
156
- }
157
- }
158
-
159
122
  /**
160
123
  * Parse thinking level from frontmatter.
161
124
  * Supports keys: thinkingLevel, thinking-level, thinking
@@ -272,7 +235,7 @@ export async function loadSkillsFromDir(
272
235
  return { item: null as Skill | null, warning: null as string | null };
273
236
  }
274
237
 
275
- const { frontmatter, body } = parseFrontmatter(content);
238
+ const { frontmatter, body } = parseFrontmatter(content, { source: skillFile });
276
239
  if (requireDescription && !frontmatter.description) {
277
240
  return { item: null as Skill | null, warning: null as string | null };
278
241
  }
@@ -16,13 +16,13 @@ import { registerProvider } from "../capability/index";
16
16
  import { type MCPServer, mcpCapability } from "../capability/mcp";
17
17
  import { type Rule, ruleCapability } from "../capability/rule";
18
18
  import type { LoadContext, LoadResult } from "../capability/types";
19
+ import { parseFrontmatter } from "../core/frontmatter";
19
20
  import {
20
21
  createSourceMeta,
21
22
  expandEnvVarsDeep,
22
23
  getProjectPath,
23
24
  getUserPath,
24
25
  loadFilesFromDir,
25
- parseFrontmatter,
26
26
  parseJSON,
27
27
  } from "./helpers";
28
28
 
@@ -105,7 +105,7 @@ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
105
105
  if (userPath) {
106
106
  const content = await readFile(userPath);
107
107
  if (content) {
108
- const { frontmatter, body } = parseFrontmatter(content);
108
+ const { frontmatter, body } = parseFrontmatter(content, { source: userPath });
109
109
 
110
110
  // Validate and normalize globs
111
111
  let globs: string[] | undefined;
@@ -134,7 +134,7 @@ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
134
134
  const result = await loadFilesFromDir<Rule>(ctx, projectRulesDir, PROVIDER_ID, "project", {
135
135
  extensions: ["md"],
136
136
  transform: (name, content, path, source) => {
137
- const { frontmatter, body } = parseFrontmatter(content);
137
+ const { frontmatter, body } = parseFrontmatter(content, { source: path });
138
138
  const ruleName = name.replace(/\.md$/, "");
139
139
 
140
140
  // Validate and normalize globs
@@ -9,7 +9,6 @@ import {
9
9
  isCtrlP,
10
10
  isCtrlT,
11
11
  isCtrlV,
12
- isCtrlY,
13
12
  isCtrlZ,
14
13
  isEscape,
15
14
  isShiftCtrlP,
@@ -36,7 +35,7 @@ export class CustomEditor extends Editor {
36
35
  public onCtrlZ?: () => void;
37
36
  public onQuestionMark?: () => void;
38
37
  public onCapsLock?: () => void;
39
- public onCtrlY?: () => void;
38
+ public onAltP?: () => void;
40
39
  /** Called when Ctrl+V is pressed. Returns true if handled (image found), false to fall through to text paste. */
41
40
  public onCtrlV?: () => Promise<boolean>;
42
41
  /** Called when Alt+Up is pressed (dequeue keybinding). */
@@ -84,9 +83,9 @@ export class CustomEditor extends Editor {
84
83
  return;
85
84
  }
86
85
 
87
- // Intercept Ctrl+Y for voice input
88
- if (isCtrlY(data) && this.onCtrlY) {
89
- this.onCtrlY();
86
+ // Intercept Alt+P for quick model switching
87
+ if (matchesKey(data, "alt+p") && this.onAltP) {
88
+ this.onAltP();
90
89
  return;
91
90
  }
92
91
 
@@ -102,12 +101,6 @@ export class CustomEditor extends Editor {
102
101
  return;
103
102
  }
104
103
 
105
- // Intercept Ctrl+Y for role-based model cycling
106
- if (isCtrlY(data) && this.onCtrlY) {
107
- this.onCtrlY();
108
- return;
109
- }
110
-
111
104
  // Intercept Ctrl+L for model selector
112
105
  if (isCtrlL(data) && this.onCtrlL) {
113
106
  this.onCtrlL();
@@ -19,6 +19,7 @@ export { LoginDialogComponent } from "./login-dialog";
19
19
  export { ModelSelectorComponent } from "./model-selector";
20
20
  export { OAuthSelectorComponent } from "./oauth-selector";
21
21
  export { QueueModeSelectorComponent } from "./queue-mode-selector";
22
+ export { ReadToolGroupComponent } from "./read-tool-group";
22
23
  export { SessionSelectorComponent } from "./session-selector";
23
24
  export {
24
25
  type SettingChangeHandler,
@@ -30,7 +31,7 @@ export { ShowImagesSelectorComponent } from "./show-images-selector";
30
31
  export { StatusLineComponent } from "./status-line";
31
32
  export { ThemeSelectorComponent } from "./theme-selector";
32
33
  export { ThinkingSelectorComponent } from "./thinking-selector";
33
- export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution";
34
+ export { ToolExecutionComponent, type ToolExecutionHandle, type ToolExecutionOptions } from "./tool-execution";
34
35
  export { TreeSelectorComponent } from "./tree-selector";
35
36
  export { TtsrNotificationComponent } from "./ttsr-notification";
36
37
  export { UserMessageComponent } from "./user-message";
@@ -0,0 +1,118 @@
1
+ import type { Component } from "@oh-my-pi/pi-tui";
2
+ import { Container, Text } from "@oh-my-pi/pi-tui";
3
+ import { shortenPath } from "../../../core/tools/render-utils";
4
+ import { theme } from "../theme/theme";
5
+ import type { ToolExecutionHandle } from "./tool-execution";
6
+
7
+ type ReadRenderArgs = {
8
+ path?: string;
9
+ file_path?: string;
10
+ offset?: number;
11
+ limit?: number;
12
+ };
13
+
14
+ type ReadEntry = {
15
+ toolCallId: string;
16
+ path: string;
17
+ offset?: number;
18
+ limit?: number;
19
+ status: "pending" | "success" | "error";
20
+ };
21
+
22
+ export class ReadToolGroupComponent extends Container implements ToolExecutionHandle {
23
+ private entries = new Map<string, ReadEntry>();
24
+ private text: Text;
25
+
26
+ constructor() {
27
+ super();
28
+ this.text = new Text("", 0, 0);
29
+ this.addChild(this.text);
30
+ this.updateDisplay();
31
+ }
32
+
33
+ updateArgs(args: ReadRenderArgs, toolCallId?: string): void {
34
+ if (!toolCallId) return;
35
+ const rawPath = args.file_path || args.path || "";
36
+ const entry: ReadEntry = this.entries.get(toolCallId) ?? {
37
+ toolCallId,
38
+ path: rawPath,
39
+ offset: args.offset,
40
+ limit: args.limit,
41
+ status: "pending",
42
+ };
43
+ entry.path = rawPath;
44
+ entry.offset = args.offset;
45
+ entry.limit = args.limit;
46
+ this.entries.set(toolCallId, entry);
47
+ this.updateDisplay();
48
+ }
49
+
50
+ updateResult(
51
+ result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
52
+ isPartial = false,
53
+ toolCallId?: string,
54
+ ): void {
55
+ if (!toolCallId) return;
56
+ const entry = this.entries.get(toolCallId);
57
+ if (!entry) return;
58
+ if (isPartial) return;
59
+ entry.status = result.isError ? "error" : "success";
60
+ this.updateDisplay();
61
+ }
62
+
63
+ setArgsComplete(_toolCallId?: string): void {
64
+ this.updateDisplay();
65
+ }
66
+
67
+ setExpanded(_expanded: boolean): void {
68
+ this.updateDisplay();
69
+ }
70
+
71
+ getComponent(): Component {
72
+ return this;
73
+ }
74
+
75
+ private updateDisplay(): void {
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
+
81
+ if (entries.length === 0) {
82
+ this.text.setText(` ${theme.format.bullet} ${header}`);
83
+ return;
84
+ }
85
+
86
+ const lines = [` ${theme.format.bullet} ${header}`];
87
+ const total = entries.length;
88
+ for (const [index, entry] of entries.entries()) {
89
+ const connector = index === total - 1 ? theme.tree.last : theme.tree.branch;
90
+ const statusSymbol = this.formatStatus(entry.status);
91
+ const pathDisplay = this.formatPath(entry);
92
+ lines.push(` ${theme.fg("dim", connector)} ${statusSymbol} ${pathDisplay}`.trimEnd());
93
+ }
94
+
95
+ this.text.setText(lines.join("\n"));
96
+ }
97
+
98
+ private formatPath(entry: ReadEntry): string {
99
+ const filePath = shortenPath(entry.path);
100
+ let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", theme.format.ellipsis);
101
+ if (entry.offset !== undefined || entry.limit !== undefined) {
102
+ const startLine = entry.offset ?? 1;
103
+ const endLine = entry.limit !== undefined ? startLine + entry.limit - 1 : "";
104
+ pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
105
+ }
106
+ return pathDisplay;
107
+ }
108
+
109
+ private formatStatus(status: ReadEntry["status"]): string {
110
+ if (status === "success") {
111
+ return theme.fg("success", theme.status.success);
112
+ }
113
+ if (status === "error") {
114
+ return theme.fg("error", theme.status.error);
115
+ }
116
+ return theme.fg("dim", theme.status.pending);
117
+ }
118
+ }
@@ -0,0 +1,112 @@
1
+ import * as path from "node:path";
2
+ import { Text } from "@oh-my-pi/pi-tui";
3
+ import { logger } from "../../../core/logger";
4
+ import { getArtifactsDir } from "../../../core/tools/task/artifacts";
5
+ import { theme } from "../theme/theme";
6
+ import type { TodoItem } from "../types";
7
+
8
+ const TODO_FILE_NAME = "todos.json";
9
+
10
+ interface TodoFile {
11
+ updatedAt: number;
12
+ todos: TodoItem[];
13
+ }
14
+
15
+ async function loadTodoFile(filePath: string): Promise<TodoFile | null> {
16
+ const file = Bun.file(filePath);
17
+ if (!(await file.exists())) return null;
18
+ try {
19
+ const text = await file.text();
20
+ const data = JSON.parse(text) as TodoFile;
21
+ if (!data || !Array.isArray(data.todos)) return null;
22
+ return data;
23
+ } catch (error) {
24
+ logger.warn("Failed to read todo file", { path: filePath, error: String(error) });
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export class TodoDisplayComponent {
30
+ public todos: TodoItem[] = [];
31
+ private expanded = false;
32
+ private visible = false;
33
+
34
+ constructor(private readonly sessionFile: string | null) {}
35
+
36
+ async loadTodos(): Promise<void> {
37
+ if (!this.sessionFile) {
38
+ this.todos = [];
39
+ this.visible = false;
40
+ return;
41
+ }
42
+
43
+ const artifactsDir = getArtifactsDir(this.sessionFile);
44
+ if (!artifactsDir) {
45
+ this.todos = [];
46
+ this.visible = false;
47
+ return;
48
+ }
49
+
50
+ const todoPath = path.join(artifactsDir, TODO_FILE_NAME);
51
+ const data = await loadTodoFile(todoPath);
52
+ this.todos = data?.todos ?? [];
53
+ this.visible = this.todos.length > 0;
54
+ }
55
+
56
+ setTodos(todos: TodoItem[]): void {
57
+ this.todos = todos;
58
+ this.visible = this.todos.length > 0;
59
+ }
60
+
61
+ setExpanded(expanded: boolean): void {
62
+ this.expanded = expanded;
63
+ }
64
+
65
+ isVisible(): boolean {
66
+ return this.visible;
67
+ }
68
+
69
+ render(_width: number): string[] {
70
+ if (!this.visible || this.todos.length === 0) {
71
+ return [];
72
+ }
73
+
74
+ const lines: string[] = [];
75
+ const maxItems = this.expanded ? this.todos.length : Math.min(5, this.todos.length);
76
+ const hasMore = !this.expanded && this.todos.length > 5;
77
+
78
+ for (let i = 0; i < maxItems; i++) {
79
+ const todo = this.todos[i];
80
+ const prefix = i === 0 ? ` ${theme.tree.hook} ` : " ";
81
+
82
+ let checkbox: string;
83
+ let text: string;
84
+
85
+ if (todo.status === "completed") {
86
+ checkbox = theme.checkbox.checked;
87
+ text = theme.fg("success", `${prefix}${checkbox} ${theme.strikethrough(todo.content)}`);
88
+ } else if (todo.status === "in_progress") {
89
+ checkbox = theme.checkbox.unchecked;
90
+ const displayText = todo.activeForm !== todo.content ? todo.activeForm : todo.content;
91
+ text = theme.fg("accent", `${prefix}${checkbox} ${displayText}`);
92
+ } else {
93
+ checkbox = theme.checkbox.unchecked;
94
+ text = theme.fg("dim", `${prefix}${checkbox} ${todo.content}`);
95
+ }
96
+
97
+ lines.push(text);
98
+ }
99
+
100
+ if (hasMore) {
101
+ lines.push(theme.fg("dim", ` ${theme.tree.hook} +${this.todos.length - 5} more (Ctrl+T to expand)`));
102
+ }
103
+
104
+ return lines;
105
+ }
106
+
107
+ getRenderedComponent(): Text | null {
108
+ if (!this.visible) return null;
109
+ const lines = this.render(80);
110
+ return new Text(lines.join("\n"), 0, 0);
111
+ }
112
+ }
@@ -86,6 +86,21 @@ export interface ToolExecutionOptions {
86
86
  showImages?: boolean; // default: true (only used if terminal supports images)
87
87
  }
88
88
 
89
+ export interface ToolExecutionHandle {
90
+ updateArgs(args: any, toolCallId?: string): void;
91
+ updateResult(
92
+ result: {
93
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
94
+ details?: any;
95
+ isError?: boolean;
96
+ },
97
+ isPartial?: boolean,
98
+ toolCallId?: string,
99
+ ): void;
100
+ setArgsComplete(toolCallId?: string): void;
101
+ setExpanded(expanded: boolean): void;
102
+ }
103
+
89
104
  /**
90
105
  * Component that renders a tool call with its result (updateable)
91
106
  */
@@ -152,7 +167,7 @@ export class ToolExecutionComponent extends Container {
152
167
  this.updateDisplay();
153
168
  }
154
169
 
155
- updateArgs(args: any): void {
170
+ updateArgs(args: any, _toolCallId?: string): void {
156
171
  this.args = args;
157
172
  this.updateDisplay();
158
173
  }
@@ -161,7 +176,7 @@ export class ToolExecutionComponent extends Container {
161
176
  * Signal that args are complete (tool is about to execute).
162
177
  * This triggers diff computation for edit tool.
163
178
  */
164
- setArgsComplete(): void {
179
+ setArgsComplete(_toolCallId?: string): void {
165
180
  this.maybeComputeEditDiff();
166
181
  }
167
182
 
@@ -206,6 +221,7 @@ export class ToolExecutionComponent extends Container {
206
221
  isError?: boolean;
207
222
  },
208
223
  isPartial = false,
224
+ _toolCallId?: string,
209
225
  ): void {
210
226
  this.result = result;
211
227
  this.isPartial = isPartial;
@@ -334,11 +334,11 @@ export class CommandController {
334
334
  | \`Shift+Tab\` | Cycle thinking level |
335
335
  | \`Ctrl+P\` | Cycle role models (slow/default/smol) |
336
336
  | \`Shift+Ctrl+P\` | Cycle role models (temporary) |
337
- | \`Ctrl+Y\` | Select model (temporary) |
337
+ | \`Alt+P\` | Select model (temporary) |
338
338
  | \`Ctrl+L\` | Select model (set roles) |
339
339
  | \`Ctrl+R\` | Search prompt history |
340
340
  | \`Ctrl+O\` | Toggle tool output expansion |
341
- | \`Ctrl+T\` | Toggle thinking block visibility |
341
+ | \`Ctrl+T\` | Toggle todo list expansion |
342
342
  | \`Ctrl+G\` | Edit message in external editor |
343
343
  | \`/\` | Slash commands |
344
344
  | \`!\` | Run bash command |
@@ -2,14 +2,33 @@ import { Loader, Text } from "@oh-my-pi/pi-tui";
2
2
  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
+ import { ReadToolGroupComponent } from "../components/read-tool-group";
5
6
  import { ToolExecutionComponent } from "../components/tool-execution";
6
7
  import { TtsrNotificationComponent } from "../components/ttsr-notification";
7
8
  import { getSymbolTheme, theme } from "../theme/theme";
8
- import type { InteractiveModeContext } from "../types";
9
+ import type { InteractiveModeContext, TodoItem } from "../types";
9
10
 
10
11
  export class EventController {
12
+ private lastReadGroup: ReadToolGroupComponent | undefined = undefined;
13
+ private lastThinkingCount = 0;
14
+
11
15
  constructor(private ctx: InteractiveModeContext) {}
12
16
 
17
+ private resetReadGroup(): void {
18
+ this.lastReadGroup = undefined;
19
+ }
20
+
21
+ private getReadGroup(): ReadToolGroupComponent {
22
+ if (!this.lastReadGroup) {
23
+ this.ctx.chatContainer.addChild(new Text("", 0, 0));
24
+ const group = new ReadToolGroupComponent();
25
+ group.setExpanded(this.ctx.toolOutputExpanded);
26
+ this.ctx.chatContainer.addChild(group);
27
+ this.lastReadGroup = group;
28
+ }
29
+ return this.lastReadGroup;
30
+ }
31
+
13
32
  subscribeToAgent(): void {
14
33
  this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => {
15
34
  await this.handleEvent(event);
@@ -53,17 +72,21 @@ export class EventController {
53
72
 
54
73
  case "message_start":
55
74
  if (event.message.role === "hookMessage" || event.message.role === "custom") {
75
+ this.resetReadGroup();
56
76
  this.ctx.addMessageToChat(event.message);
57
77
  this.ctx.ui.requestRender();
58
78
  } else if (event.message.role === "user") {
79
+ this.resetReadGroup();
59
80
  this.ctx.addMessageToChat(event.message);
60
81
  this.ctx.editor.setText("");
61
82
  this.ctx.updatePendingMessagesDisplay();
62
83
  this.ctx.ui.requestRender();
63
84
  } else if (event.message.role === "fileMention") {
85
+ this.resetReadGroup();
64
86
  this.ctx.addMessageToChat(event.message);
65
87
  this.ctx.ui.requestRender();
66
88
  } else if (event.message.role === "assistant") {
89
+ this.lastThinkingCount = 0;
67
90
  this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock);
68
91
  this.ctx.streamingMessage = event.message;
69
92
  this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
@@ -77,29 +100,45 @@ export class EventController {
77
100
  this.ctx.streamingMessage = event.message;
78
101
  this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
79
102
 
103
+ const thinkingCount = this.ctx.streamingMessage.content.filter(
104
+ (content) => content.type === "thinking" && content.thinking.trim(),
105
+ ).length;
106
+ if (thinkingCount > this.lastThinkingCount) {
107
+ this.resetReadGroup();
108
+ this.lastThinkingCount = thinkingCount;
109
+ }
110
+
80
111
  for (const content of this.ctx.streamingMessage.content) {
81
- if (content.type === "toolCall") {
82
- if (!this.ctx.pendingTools.has(content.id)) {
83
- this.ctx.chatContainer.addChild(new Text("", 0, 0));
84
- const tool = this.ctx.session.getToolByName(content.name);
85
- const component = new ToolExecutionComponent(
86
- content.name,
87
- content.arguments,
88
- {
89
- showImages: this.ctx.settingsManager.getShowImages(),
90
- },
91
- tool,
92
- this.ctx.ui,
93
- this.ctx.sessionManager.getCwd(),
94
- );
95
- component.setExpanded(this.ctx.toolOutputExpanded);
96
- this.ctx.chatContainer.addChild(component);
97
- this.ctx.pendingTools.set(content.id, component);
98
- } else {
99
- const component = this.ctx.pendingTools.get(content.id);
100
- if (component) {
101
- component.updateArgs(content.arguments);
102
- }
112
+ if (content.type !== "toolCall") continue;
113
+
114
+ if (!this.ctx.pendingTools.has(content.id)) {
115
+ if (content.name === "read") {
116
+ const group = this.getReadGroup();
117
+ group.updateArgs(content.arguments, content.id);
118
+ this.ctx.pendingTools.set(content.id, group);
119
+ continue;
120
+ }
121
+
122
+ this.resetReadGroup();
123
+ this.ctx.chatContainer.addChild(new Text("", 0, 0));
124
+ const tool = this.ctx.session.getToolByName(content.name);
125
+ const component = new ToolExecutionComponent(
126
+ content.name,
127
+ content.arguments,
128
+ {
129
+ showImages: this.ctx.settingsManager.getShowImages(),
130
+ },
131
+ tool,
132
+ this.ctx.ui,
133
+ this.ctx.sessionManager.getCwd(),
134
+ );
135
+ component.setExpanded(this.ctx.toolOutputExpanded);
136
+ this.ctx.chatContainer.addChild(component);
137
+ this.ctx.pendingTools.set(content.id, component);
138
+ } else {
139
+ const component = this.ctx.pendingTools.get(content.id);
140
+ if (component) {
141
+ component.updateArgs(content.arguments, content.id);
103
142
  }
104
143
  }
105
144
  }
@@ -133,17 +172,21 @@ export class EventController {
133
172
  } else {
134
173
  errorMessage = this.ctx.streamingMessage.errorMessage || "Error";
135
174
  }
136
- for (const [, component] of this.ctx.pendingTools.entries()) {
137
- component.updateResult({
138
- content: [{ type: "text", text: errorMessage }],
139
- isError: true,
140
- });
175
+ for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
176
+ component.updateResult(
177
+ {
178
+ content: [{ type: "text", text: errorMessage }],
179
+ isError: true,
180
+ },
181
+ false,
182
+ toolCallId,
183
+ );
141
184
  }
142
185
  }
143
186
  this.ctx.pendingTools.clear();
144
187
  } else {
145
- for (const [, component] of this.ctx.pendingTools.entries()) {
146
- component.setArgsComplete();
188
+ for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
189
+ component.setArgsComplete(toolCallId);
147
190
  }
148
191
  }
149
192
  this.ctx.streamingComponent = undefined;
@@ -156,6 +199,15 @@ export class EventController {
156
199
 
157
200
  case "tool_execution_start": {
158
201
  if (!this.ctx.pendingTools.has(event.toolCallId)) {
202
+ if (event.toolName === "read") {
203
+ const group = this.getReadGroup();
204
+ group.updateArgs(event.args, event.toolCallId);
205
+ this.ctx.pendingTools.set(event.toolCallId, group);
206
+ this.ctx.ui.requestRender();
207
+ break;
208
+ }
209
+
210
+ this.resetReadGroup();
159
211
  const tool = this.ctx.session.getToolByName(event.toolName);
160
212
  const component = new ToolExecutionComponent(
161
213
  event.toolName,
@@ -178,7 +230,7 @@ export class EventController {
178
230
  case "tool_execution_update": {
179
231
  const component = this.ctx.pendingTools.get(event.toolCallId);
180
232
  if (component) {
181
- component.updateResult({ ...event.partialResult, isError: false }, true);
233
+ component.updateResult({ ...event.partialResult, isError: false }, true, event.toolCallId);
182
234
  this.ctx.ui.requestRender();
183
235
  }
184
236
  break;
@@ -187,10 +239,17 @@ export class EventController {
187
239
  case "tool_execution_end": {
188
240
  const component = this.ctx.pendingTools.get(event.toolCallId);
189
241
  if (component) {
190
- component.updateResult({ ...event.result, isError: event.isError });
242
+ component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
191
243
  this.ctx.pendingTools.delete(event.toolCallId);
192
244
  this.ctx.ui.requestRender();
193
245
  }
246
+ // Update todo display when todo_write tool completes
247
+ if (event.toolName === "todo_write" && !event.isError) {
248
+ const details = event.result.details as { todos?: TodoItem[] } | undefined;
249
+ if (details?.todos) {
250
+ this.ctx.setTodos(details.todos);
251
+ }
252
+ }
194
253
  break;
195
254
  }
196
255