@oh-my-pi/pi-coding-agent 13.14.0 → 13.15.2

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 (90) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/package.json +10 -8
  3. package/src/autoresearch/command-initialize.md +34 -0
  4. package/src/autoresearch/command-resume.md +17 -0
  5. package/src/autoresearch/contract.ts +332 -0
  6. package/src/autoresearch/dashboard.ts +447 -0
  7. package/src/autoresearch/git.ts +243 -0
  8. package/src/autoresearch/helpers.ts +458 -0
  9. package/src/autoresearch/index.ts +693 -0
  10. package/src/autoresearch/prompt.md +227 -0
  11. package/src/autoresearch/resume-message.md +16 -0
  12. package/src/autoresearch/state.ts +386 -0
  13. package/src/autoresearch/tools/init-experiment.ts +310 -0
  14. package/src/autoresearch/tools/log-experiment.ts +833 -0
  15. package/src/autoresearch/tools/run-experiment.ts +640 -0
  16. package/src/autoresearch/types.ts +218 -0
  17. package/src/cli/args.ts +8 -2
  18. package/src/cli/initial-message.ts +58 -0
  19. package/src/config/keybindings.ts +417 -212
  20. package/src/config/model-registry.ts +1 -0
  21. package/src/config/model-resolver.ts +57 -9
  22. package/src/config/settings-schema.ts +38 -10
  23. package/src/config/settings.ts +1 -4
  24. package/src/exec/bash-executor.ts +7 -5
  25. package/src/export/html/template.css +43 -13
  26. package/src/export/html/template.generated.ts +1 -1
  27. package/src/export/html/template.html +1 -0
  28. package/src/export/html/template.js +107 -0
  29. package/src/extensibility/extensions/types.ts +31 -8
  30. package/src/internal-urls/docs-index.generated.ts +1 -1
  31. package/src/lsp/index.ts +1 -1
  32. package/src/main.ts +44 -44
  33. package/src/mcp/oauth-discovery.ts +1 -1
  34. package/src/modes/acp/acp-agent.ts +957 -0
  35. package/src/modes/acp/acp-event-mapper.ts +531 -0
  36. package/src/modes/acp/acp-mode.ts +13 -0
  37. package/src/modes/acp/index.ts +2 -0
  38. package/src/modes/components/agent-dashboard.ts +5 -4
  39. package/src/modes/components/bash-execution.ts +40 -11
  40. package/src/modes/components/custom-editor.ts +47 -47
  41. package/src/modes/components/extensions/extension-dashboard.ts +2 -1
  42. package/src/modes/components/history-search.ts +2 -1
  43. package/src/modes/components/hook-editor.ts +2 -1
  44. package/src/modes/components/hook-input.ts +8 -7
  45. package/src/modes/components/hook-selector.ts +15 -10
  46. package/src/modes/components/keybinding-hints.ts +9 -9
  47. package/src/modes/components/login-dialog.ts +3 -3
  48. package/src/modes/components/mcp-add-wizard.ts +2 -1
  49. package/src/modes/components/model-selector.ts +14 -3
  50. package/src/modes/components/oauth-selector.ts +2 -1
  51. package/src/modes/components/python-execution.ts +2 -3
  52. package/src/modes/components/session-selector.ts +2 -1
  53. package/src/modes/components/settings-selector.ts +2 -1
  54. package/src/modes/components/status-line-segment-editor.ts +2 -1
  55. package/src/modes/components/tool-execution.ts +4 -5
  56. package/src/modes/components/tree-selector.ts +3 -2
  57. package/src/modes/components/user-message-selector.ts +3 -8
  58. package/src/modes/components/user-message.ts +16 -0
  59. package/src/modes/controllers/command-controller.ts +0 -2
  60. package/src/modes/controllers/extension-ui-controller.ts +89 -4
  61. package/src/modes/controllers/input-controller.ts +29 -23
  62. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  63. package/src/modes/index.ts +1 -0
  64. package/src/modes/interactive-mode.ts +17 -5
  65. package/src/modes/print-mode.ts +1 -1
  66. package/src/modes/prompt-action-autocomplete.ts +7 -7
  67. package/src/modes/rpc/rpc-mode.ts +7 -2
  68. package/src/modes/rpc/rpc-types.ts +1 -0
  69. package/src/modes/theme/theme.ts +53 -44
  70. package/src/modes/types.ts +9 -2
  71. package/src/modes/utils/hotkeys-markdown.ts +19 -19
  72. package/src/modes/utils/keybinding-matchers.ts +21 -0
  73. package/src/modes/utils/ui-helpers.ts +1 -1
  74. package/src/patch/hashline.ts +139 -127
  75. package/src/patch/index.ts +77 -59
  76. package/src/patch/shared.ts +19 -11
  77. package/src/prompts/tools/hashline.md +43 -116
  78. package/src/sdk.ts +34 -17
  79. package/src/session/agent-session.ts +123 -30
  80. package/src/session/session-manager.ts +32 -31
  81. package/src/session/streaming-output.ts +87 -37
  82. package/src/tools/ask.ts +56 -30
  83. package/src/tools/bash-interactive.ts +2 -6
  84. package/src/tools/bash-interceptor.ts +1 -39
  85. package/src/tools/bash-skill-urls.ts +1 -1
  86. package/src/tools/browser.ts +1 -1
  87. package/src/tools/gemini-image.ts +1 -1
  88. package/src/tools/python.ts +2 -2
  89. package/src/tools/resolve.ts +1 -1
  90. package/src/utils/child-process.ts +88 -0
@@ -0,0 +1,531 @@
1
+ import type {
2
+ SessionNotification,
3
+ SessionUpdate,
4
+ ToolCallContent,
5
+ ToolCallLocation,
6
+ ToolKind,
7
+ } from "@agentclientprotocol/sdk";
8
+ import type { AgentSessionEvent } from "../../session/agent-session";
9
+ import type { TodoStatus } from "../../tools/todo-write";
10
+
11
+ interface ContentArrayContainer {
12
+ content?: unknown;
13
+ }
14
+
15
+ interface TypedValue {
16
+ type?: unknown;
17
+ }
18
+
19
+ interface TextLikeContent extends TypedValue {
20
+ text?: unknown;
21
+ }
22
+
23
+ interface BinaryLikeContent extends TypedValue {
24
+ data?: unknown;
25
+ mimeType?: unknown;
26
+ }
27
+
28
+ interface PathContainer {
29
+ path?: unknown;
30
+ }
31
+
32
+ interface OldPathContainer {
33
+ oldPath?: unknown;
34
+ }
35
+
36
+ interface NewPathContainer {
37
+ newPath?: unknown;
38
+ }
39
+
40
+ interface CommandContainer {
41
+ command?: unknown;
42
+ }
43
+
44
+ interface PatternContainer {
45
+ pattern?: unknown;
46
+ }
47
+
48
+ interface QueryContainer {
49
+ query?: unknown;
50
+ }
51
+
52
+ interface ErrorMessageContainer {
53
+ errorMessage?: unknown;
54
+ }
55
+
56
+ interface MessageContainer {
57
+ message?: unknown;
58
+ }
59
+
60
+ interface ResourceLinkLikeContent extends TypedValue {
61
+ uri?: unknown;
62
+ name?: unknown;
63
+ title?: unknown;
64
+ description?: unknown;
65
+ mimeType?: unknown;
66
+ size?: unknown;
67
+ }
68
+
69
+ interface BlobResourceLike {
70
+ uri?: unknown;
71
+ blob?: unknown;
72
+ mimeType?: unknown;
73
+ }
74
+
75
+ interface TextResourceLike {
76
+ uri?: unknown;
77
+ text?: unknown;
78
+ mimeType?: unknown;
79
+ }
80
+
81
+ interface EmbeddedResourceLikeContent extends TypedValue {
82
+ resource?: unknown;
83
+ }
84
+
85
+ interface TextMessageLike {
86
+ role?: unknown;
87
+ }
88
+
89
+ const ACP_TEXT_LIMIT = 4_000;
90
+
91
+ export function mapToolKind(toolName: string): ToolKind {
92
+ switch (toolName) {
93
+ case "read":
94
+ return "read";
95
+ case "write":
96
+ case "edit":
97
+ return "edit";
98
+ case "delete":
99
+ return "delete";
100
+ case "move":
101
+ return "move";
102
+ case "bash":
103
+ case "python":
104
+ return "execute";
105
+ case "grep":
106
+ case "find":
107
+ case "ast_grep":
108
+ return "search";
109
+ case "fetch":
110
+ case "web_search":
111
+ return "fetch";
112
+ case "todo_write":
113
+ return "think";
114
+ default:
115
+ return "other";
116
+ }
117
+ }
118
+
119
+ export function mapAgentSessionEventToAcpSessionUpdates(
120
+ event: AgentSessionEvent,
121
+ sessionId: string,
122
+ ): SessionNotification[] {
123
+ switch (event.type) {
124
+ case "message_update":
125
+ return mapAssistantMessageUpdate(event, sessionId);
126
+ case "tool_execution_start": {
127
+ const update: SessionUpdate = {
128
+ sessionUpdate: "tool_call",
129
+ toolCallId: event.toolCallId,
130
+ title: buildToolTitle(event.toolName, event.args, event.intent),
131
+ kind: mapToolKind(event.toolName),
132
+ status: "pending",
133
+ rawInput: event.args,
134
+ };
135
+ const locations = extractToolLocations(event.args);
136
+ if (locations.length > 0) {
137
+ update.locations = locations;
138
+ }
139
+ return [toSessionNotification(sessionId, update)];
140
+ }
141
+ case "tool_execution_update": {
142
+ const content = extractToolCallContent(event.partialResult);
143
+ const update: SessionUpdate = {
144
+ sessionUpdate: "tool_call_update",
145
+ toolCallId: event.toolCallId,
146
+ status: "in_progress",
147
+ rawOutput: event.partialResult,
148
+ };
149
+ if (content.length > 0) {
150
+ update.content = content;
151
+ }
152
+ return [toSessionNotification(sessionId, update)];
153
+ }
154
+ case "tool_execution_end": {
155
+ const content = extractToolCallContent(event.result);
156
+ const update: SessionUpdate = {
157
+ sessionUpdate: "tool_call_update",
158
+ toolCallId: event.toolCallId,
159
+ status: event.isError ? "failed" : "completed",
160
+ rawOutput: event.result,
161
+ };
162
+ if (content.length > 0) {
163
+ update.content = content;
164
+ }
165
+ return [toSessionNotification(sessionId, update)];
166
+ }
167
+ case "todo_reminder": {
168
+ const entries = event.todos.map(todo => ({
169
+ content: todo.content,
170
+ priority: "medium" as const,
171
+ status: mapTodoStatus(todo.status),
172
+ }));
173
+ return [toSessionNotification(sessionId, { sessionUpdate: "plan", entries })];
174
+ }
175
+ case "todo_auto_clear":
176
+ return [toSessionNotification(sessionId, { sessionUpdate: "plan", entries: [] })];
177
+ default:
178
+ return [];
179
+ }
180
+ }
181
+
182
+ function mapAssistantMessageUpdate(
183
+ event: Extract<AgentSessionEvent, { type: "message_update" }>,
184
+ sessionId: string,
185
+ ): SessionNotification[] {
186
+ if (!isAssistantMessage(event.message)) {
187
+ return [];
188
+ }
189
+
190
+ let sessionUpdate: "agent_message_chunk" | "agent_thought_chunk";
191
+ let text: string;
192
+ switch (event.assistantMessageEvent.type) {
193
+ case "text_delta":
194
+ sessionUpdate = "agent_message_chunk";
195
+ text = event.assistantMessageEvent.delta;
196
+ break;
197
+ case "thinking_delta":
198
+ sessionUpdate = "agent_thought_chunk";
199
+ text = event.assistantMessageEvent.delta;
200
+ break;
201
+ case "error":
202
+ sessionUpdate = "agent_message_chunk";
203
+ text = event.assistantMessageEvent.error.errorMessage ?? "Unknown error";
204
+ break;
205
+ default:
206
+ return [];
207
+ }
208
+ if (text.length === 0) {
209
+ return [];
210
+ }
211
+
212
+ return [
213
+ toSessionNotification(sessionId, {
214
+ sessionUpdate,
215
+ content: { type: "text", text },
216
+ }),
217
+ ];
218
+ }
219
+
220
+ function toSessionNotification(sessionId: string, update: SessionUpdate): SessionNotification {
221
+ return { sessionId, update };
222
+ }
223
+
224
+ const todoStatusMap: Record<TodoStatus, "pending" | "in_progress" | "completed"> = {
225
+ pending: "pending",
226
+ in_progress: "in_progress",
227
+ completed: "completed",
228
+ abandoned: "completed",
229
+ };
230
+
231
+ function mapTodoStatus(status: TodoStatus): "pending" | "in_progress" | "completed" {
232
+ return todoStatusMap[status];
233
+ }
234
+
235
+ function buildToolTitle(toolName: string, args: unknown, intent: string | undefined): string {
236
+ const trimmedIntent = intent?.trim();
237
+ if (trimmedIntent) {
238
+ return trimmedIntent;
239
+ }
240
+
241
+ const subject =
242
+ extractStringProperty<PathContainer>(args, "path") ??
243
+ extractStringProperty<CommandContainer>(args, "command") ??
244
+ extractStringProperty<PatternContainer>(args, "pattern") ??
245
+ extractStringProperty<QueryContainer>(args, "query");
246
+ if (subject) {
247
+ return `${toolName}: ${subject}`;
248
+ }
249
+
250
+ return toolName;
251
+ }
252
+
253
+ function extractToolLocations(args: unknown): ToolCallLocation[] {
254
+ const locations: ToolCallLocation[] = [];
255
+ const path = extractStringProperty<PathContainer>(args, "path");
256
+ if (path) {
257
+ locations.push({ path });
258
+ }
259
+
260
+ const oldPath = extractStringProperty<OldPathContainer>(args, "oldPath");
261
+ if (oldPath && oldPath !== path) {
262
+ locations.push({ path: oldPath });
263
+ }
264
+
265
+ const newPath = extractStringProperty<NewPathContainer>(args, "newPath");
266
+ if (newPath && newPath !== path && newPath !== oldPath) {
267
+ locations.push({ path: newPath });
268
+ }
269
+
270
+ return locations;
271
+ }
272
+
273
+ function extractToolCallContent(value: unknown): ToolCallContent[] {
274
+ const richContent = extractStructuredToolCallContent(value);
275
+ const fallbackText = extractReadableText(value);
276
+ if (!fallbackText) {
277
+ return richContent;
278
+ }
279
+ if (hasEquivalentTextContent(richContent, fallbackText)) {
280
+ return richContent;
281
+ }
282
+ return [...richContent, textToolCallContent(fallbackText)];
283
+ }
284
+
285
+ function extractStructuredToolCallContent(value: unknown): ToolCallContent[] {
286
+ const blocks = getContentBlocks(value);
287
+ if (!blocks) {
288
+ return [];
289
+ }
290
+
291
+ const content: ToolCallContent[] = [];
292
+ for (const block of blocks) {
293
+ const toolCallContent = toToolCallContent(block);
294
+ if (toolCallContent) {
295
+ content.push(toolCallContent);
296
+ }
297
+ }
298
+ return content;
299
+ }
300
+
301
+ function getContentBlocks(value: unknown): unknown[] | undefined {
302
+ if (Array.isArray(value)) {
303
+ return value;
304
+ }
305
+ if (typeof value !== "object" || value === null || !("content" in value)) {
306
+ return undefined;
307
+ }
308
+ const content = (value as ContentArrayContainer).content;
309
+ return Array.isArray(content) ? content : undefined;
310
+ }
311
+
312
+ function toToolCallContent(value: unknown): ToolCallContent | undefined {
313
+ const type = getContentType(value);
314
+ if (!type) {
315
+ return undefined;
316
+ }
317
+
318
+ switch (type) {
319
+ case "text": {
320
+ const text = extractStructuredText(value);
321
+ return text ? textToolCallContent(text) : undefined;
322
+ }
323
+ case "image":
324
+ case "audio": {
325
+ const data = extractStringProperty<BinaryLikeContent>(value, "data");
326
+ const mimeType = extractStringProperty<BinaryLikeContent>(value, "mimeType");
327
+ if (!data || !mimeType) {
328
+ return undefined;
329
+ }
330
+ return {
331
+ type: "content",
332
+ content: {
333
+ type,
334
+ data,
335
+ mimeType,
336
+ },
337
+ };
338
+ }
339
+ case "resource_link": {
340
+ const uri = extractStringProperty<ResourceLinkLikeContent>(value, "uri");
341
+ const name = extractStringProperty<ResourceLinkLikeContent>(value, "name");
342
+ if (!uri || !name) {
343
+ return undefined;
344
+ }
345
+ const resourceLinkContent: {
346
+ type: "resource_link";
347
+ uri: string;
348
+ name: string;
349
+ title?: string;
350
+ description?: string;
351
+ mimeType?: string;
352
+ size?: number;
353
+ } = {
354
+ type: "resource_link",
355
+ uri,
356
+ name,
357
+ };
358
+ const title = extractStringProperty<ResourceLinkLikeContent>(value, "title");
359
+ if (title) {
360
+ resourceLinkContent.title = title;
361
+ }
362
+ const description = extractStringProperty<ResourceLinkLikeContent>(value, "description");
363
+ if (description) {
364
+ resourceLinkContent.description = description;
365
+ }
366
+ const mimeType = extractStringProperty<ResourceLinkLikeContent>(value, "mimeType");
367
+ if (mimeType) {
368
+ resourceLinkContent.mimeType = mimeType;
369
+ }
370
+ const size = extractNumberProperty<ResourceLinkLikeContent>(value, "size");
371
+ if (size !== undefined) {
372
+ resourceLinkContent.size = size;
373
+ }
374
+ return {
375
+ type: "content",
376
+ content: resourceLinkContent,
377
+ };
378
+ }
379
+ case "resource": {
380
+ const resource = extractEmbeddedResource(value);
381
+ return resource
382
+ ? {
383
+ type: "content",
384
+ content: {
385
+ type: "resource",
386
+ resource,
387
+ },
388
+ }
389
+ : undefined;
390
+ }
391
+ default:
392
+ return undefined;
393
+ }
394
+ }
395
+
396
+ function extractEmbeddedResource(
397
+ value: unknown,
398
+ ): { uri: string; text: string; mimeType?: string } | { uri: string; blob: string; mimeType?: string } | undefined {
399
+ if (typeof value !== "object" || value === null || !("resource" in value)) {
400
+ return undefined;
401
+ }
402
+
403
+ const resource = (value as EmbeddedResourceLikeContent).resource;
404
+ if (typeof resource !== "object" || resource === null) {
405
+ return undefined;
406
+ }
407
+
408
+ const uri = extractStringProperty<TextResourceLike>(resource, "uri");
409
+ if (!uri) {
410
+ return undefined;
411
+ }
412
+
413
+ const text = extractStringProperty<TextResourceLike>(resource, "text");
414
+ if (text) {
415
+ const mimeType = extractStringProperty<TextResourceLike>(resource, "mimeType");
416
+ return mimeType ? { uri, text, mimeType } : { uri, text };
417
+ }
418
+
419
+ const blob = extractStringProperty<BlobResourceLike>(resource, "blob");
420
+ if (!blob) {
421
+ return undefined;
422
+ }
423
+ const mimeType = extractStringProperty<BlobResourceLike>(resource, "mimeType");
424
+ return mimeType ? { uri, blob, mimeType } : { uri, blob };
425
+ }
426
+
427
+ function textToolCallContent(text: string): ToolCallContent {
428
+ return {
429
+ type: "content",
430
+ content: {
431
+ type: "text",
432
+ text,
433
+ },
434
+ };
435
+ }
436
+
437
+ function hasEquivalentTextContent(content: ToolCallContent[], text: string): boolean {
438
+ return content.some(item => item.type === "content" && item.content.type === "text" && item.content.text === text);
439
+ }
440
+
441
+ function extractReadableText(value: unknown): string | undefined {
442
+ if (typeof value === "string") {
443
+ return normalizeText(value);
444
+ }
445
+ if (value instanceof Error) {
446
+ return normalizeText(value.message);
447
+ }
448
+ if (typeof value !== "object" || value === null) {
449
+ return undefined;
450
+ }
451
+
452
+ const directText =
453
+ extractStringProperty<TextLikeContent>(value, "text") ??
454
+ extractStringProperty<ErrorMessageContainer>(value, "errorMessage") ??
455
+ extractStringProperty<MessageContainer>(value, "message");
456
+ if (directText) {
457
+ return normalizeText(directText);
458
+ }
459
+
460
+ const contentBlocks = getContentBlocks(value);
461
+ if (contentBlocks) {
462
+ const text = contentBlocks
463
+ .map(block => extractStructuredText(block))
464
+ .filter((chunk): chunk is string => typeof chunk === "string" && chunk.length > 0)
465
+ .join("\n");
466
+ if (text.length > 0) {
467
+ return normalizeText(text);
468
+ }
469
+ }
470
+
471
+ const serialized = safeJsonStringify(value);
472
+ return normalizeText(serialized);
473
+ }
474
+
475
+ function extractStructuredText(value: unknown): string | undefined {
476
+ const text = extractStringProperty<TextLikeContent>(value, "text");
477
+ if (!text) {
478
+ return undefined;
479
+ }
480
+ return limitText(text);
481
+ }
482
+
483
+ function getContentType(value: unknown): string | undefined {
484
+ if (typeof value !== "object" || value === null || !("type" in value)) {
485
+ return undefined;
486
+ }
487
+ const type = (value as TypedValue).type;
488
+ return typeof type === "string" ? type : undefined;
489
+ }
490
+
491
+ function extractStringProperty<T extends object>(value: unknown, key: keyof T): string | undefined {
492
+ if (typeof value !== "object" || value === null || !(key in value)) {
493
+ return undefined;
494
+ }
495
+ const property = (value as T)[key];
496
+ return typeof property === "string" && property.length > 0 ? property : undefined;
497
+ }
498
+
499
+ function extractNumberProperty<T extends object>(value: unknown, key: keyof T): number | undefined {
500
+ if (typeof value !== "object" || value === null || !(key in value)) {
501
+ return undefined;
502
+ }
503
+ const property = (value as T)[key];
504
+ return typeof property === "number" && Number.isFinite(property) ? property : undefined;
505
+ }
506
+
507
+ function isAssistantMessage(value: unknown): boolean {
508
+ return (
509
+ typeof value === "object" && value !== null && "role" in value && (value as TextMessageLike).role === "assistant"
510
+ );
511
+ }
512
+
513
+ function normalizeText(text: string | undefined): string | undefined {
514
+ if (!text) {
515
+ return undefined;
516
+ }
517
+ const normalized = text.trim();
518
+ return normalized.length > 0 ? limitText(normalized) : undefined;
519
+ }
520
+
521
+ function limitText(text: string): string {
522
+ return text.length > ACP_TEXT_LIMIT ? `${text.slice(0, ACP_TEXT_LIMIT - 1)}…` : text;
523
+ }
524
+
525
+ function safeJsonStringify(value: unknown): string | undefined {
526
+ try {
527
+ return JSON.stringify(value);
528
+ } catch {
529
+ return undefined;
530
+ }
531
+ }
@@ -0,0 +1,13 @@
1
+ import * as stream from "node:stream";
2
+ import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
3
+ import type { AgentSession } from "../../session/agent-session";
4
+ import { AcpAgent } from "./acp-agent";
5
+
6
+ export async function runAcpMode(session: AgentSession): Promise<never> {
7
+ const input = stream.Writable.toWeb(process.stdout);
8
+ const output = stream.Readable.toWeb(process.stdin);
9
+ const transport = ndJsonStream(input, output);
10
+ const connection = new AgentSideConnection(conn => new AcpAgent(conn, session), transport);
11
+ await connection.closed;
12
+ process.exit(0);
13
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./acp-agent";
2
+ export * from "./acp-mode";
@@ -50,6 +50,7 @@ import { discoverAgents } from "../../task/discovery";
50
50
  import type { AgentDefinition, AgentSource } from "../../task/types";
51
51
  import { shortenPath } from "../../tools/render-utils";
52
52
  import { theme } from "../theme/theme";
53
+ import { matchesAppInterrupt } from "../utils/keybinding-matchers";
53
54
  import { DynamicBorder } from "./dynamic-border";
54
55
 
55
56
  type SourceTabId = "all" | AgentSource;
@@ -993,7 +994,7 @@ export class AgentDashboard extends Container {
993
994
  }
994
995
 
995
996
  if (this.#createSpec) {
996
- if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
997
+ if (matchesAppInterrupt(data)) {
997
998
  this.#clearCreateFlow();
998
999
  this.#buildLayout();
999
1000
  return;
@@ -1017,7 +1018,7 @@ export class AgentDashboard extends Container {
1017
1018
  }
1018
1019
 
1019
1020
  if (this.#createInput || this.#createGenerating) {
1020
- if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
1021
+ if (matchesAppInterrupt(data)) {
1021
1022
  if (!this.#createGenerating) {
1022
1023
  this.#clearCreateFlow();
1023
1024
  this.#buildLayout();
@@ -1037,7 +1038,7 @@ export class AgentDashboard extends Container {
1037
1038
  }
1038
1039
 
1039
1040
  if (this.#editInput) {
1040
- if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
1041
+ if (matchesAppInterrupt(data)) {
1041
1042
  this.#cancelModelEdit();
1042
1043
  return;
1043
1044
  }
@@ -1048,7 +1049,7 @@ export class AgentDashboard extends Container {
1048
1049
  return;
1049
1050
  }
1050
1051
 
1051
- if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
1052
+ if (matchesAppInterrupt(data)) {
1052
1053
  if (this.#searchQuery.length > 0) {
1053
1054
  this.#searchQuery = "";
1054
1055
  this.#applyFilters();
@@ -6,13 +6,17 @@ import { sanitizeText } from "@oh-my-pi/pi-natives";
6
6
  import { Container, ImageProtocol, Loader, Spacer, TERMINAL, Text, type TUI } from "@oh-my-pi/pi-tui";
7
7
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
8
8
  import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
9
- import { getSixelLineMask, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
9
+ import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
10
10
  import { DynamicBorder } from "./dynamic-border";
11
11
  import { truncateToVisualLines } from "./visual-truncate";
12
12
 
13
13
  // Preview line limit when not expanded (matches tool execution behavior)
14
14
  const PREVIEW_LINES = 20;
15
+ const STREAMING_LINE_CAP = PREVIEW_LINES * 5;
15
16
  const MAX_DISPLAY_LINE_CHARS = 4000;
17
+ // Minimum interval between processing incoming chunks for display (ms).
18
+ // Chunks arriving faster than this are accumulated and processed in one batch.
19
+ const CHUNK_THROTTLE_MS = 50;
16
20
 
17
21
  export class BashExecutionComponent extends Container {
18
22
  #outputLines: string[] = [];
@@ -21,7 +25,10 @@ export class BashExecutionComponent extends Container {
21
25
  #loader: Loader;
22
26
  #truncation?: TruncationMeta;
23
27
  #expanded = false;
28
+ #displayDirty = false;
29
+ #chunkGate = false;
24
30
  #contentContainer: Container;
31
+ #headerText: Text;
25
32
 
26
33
  constructor(
27
34
  private readonly command: string,
@@ -45,8 +52,8 @@ export class BashExecutionComponent extends Container {
45
52
  this.addChild(this.#contentContainer);
46
53
 
47
54
  // Command header
48
- const header = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
49
- this.#contentContainer.addChild(header);
55
+ this.#headerText = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
56
+ this.#contentContainer.addChild(this.#headerText);
50
57
 
51
58
  // Loader
52
59
  this.#loader = new Loader(
@@ -72,14 +79,22 @@ export class BashExecutionComponent extends Container {
72
79
 
73
80
  override invalidate(): void {
74
81
  super.invalidate();
82
+ this.#displayDirty = false;
75
83
  this.#updateDisplay();
76
84
  }
77
85
 
78
86
  appendOutput(chunk: string): void {
79
- const clean = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
80
-
81
- // Append to output lines
82
- const incomingLines = clean.split("\n");
87
+ // During high-throughput output (e.g. seq 1 500M), processing every
88
+ // chunk would saturate the event loop. Instead, accept one chunk per
89
+ // throttle window and drop the rest — the OutputSink captures everything
90
+ // for the artifact, and setComplete() replaces with the final output.
91
+ if (this.#chunkGate) return;
92
+ this.#chunkGate = true;
93
+ setTimeout(() => {
94
+ this.#chunkGate = false;
95
+ }, CHUNK_THROTTLE_MS);
96
+
97
+ const incomingLines = chunk.split("\n");
83
98
  if (this.#outputLines.length > 0 && incomingLines.length > 0) {
84
99
  const lastIndex = this.#outputLines.length - 1;
85
100
  const mergedLines = [`${this.#outputLines[lastIndex]}${incomingLines[0]}`, ...incomingLines.slice(1)];
@@ -90,7 +105,12 @@ export class BashExecutionComponent extends Container {
90
105
  this.#outputLines.push(...this.#clampLinesPreservingSixel(incomingLines));
91
106
  }
92
107
 
93
- this.#updateDisplay();
108
+ // Cap stored lines during streaming to avoid unbounded memory growth
109
+ if (this.#outputLines.length > STREAMING_LINE_CAP) {
110
+ this.#outputLines = this.#outputLines.slice(-STREAMING_LINE_CAP);
111
+ }
112
+
113
+ this.#displayDirty = true;
94
114
  }
95
115
 
96
116
  setComplete(
@@ -115,6 +135,14 @@ export class BashExecutionComponent extends Container {
115
135
  this.#updateDisplay();
116
136
  }
117
137
 
138
+ override render(width: number): string[] {
139
+ if (this.#displayDirty) {
140
+ this.#displayDirty = false;
141
+ this.#updateDisplay();
142
+ }
143
+ return super.render(width);
144
+ }
145
+
118
146
  #updateDisplay(): void {
119
147
  const availableLines = this.#outputLines;
120
148
 
@@ -122,15 +150,16 @@ export class BashExecutionComponent extends Container {
122
150
  const previewLogicalLines = availableLines.slice(-PREVIEW_LINES);
123
151
  const hiddenLineCount = availableLines.length - previewLogicalLines.length;
124
152
  const sixelLineMask =
125
- TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(availableLines) : undefined;
153
+ TERMINAL.imageProtocol === ImageProtocol.Sixel && isSixelPassthroughEnabled()
154
+ ? getSixelLineMask(availableLines)
155
+ : undefined;
126
156
  const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
127
157
 
128
158
  // Rebuild content container
129
159
  this.#contentContainer.clear();
130
160
 
131
161
  // Command header
132
- const header = new Text(theme.fg("bashMode", theme.bold(`$ ${this.command}`)), 1, 0);
133
- this.#contentContainer.addChild(header);
162
+ this.#contentContainer.addChild(this.#headerText);
134
163
 
135
164
  // Output
136
165
  if (availableLines.length > 0) {