@tt-a1i/openpi 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +102 -40
  2. package/SETUP.md +22 -6
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/background-terminals/index.ts +30 -2
  6. package/extensions/background-terminals/src/domain.ts +2 -0
  7. package/extensions/background-terminals/src/manager.ts +486 -106
  8. package/extensions/background-terminals/src/output.ts +33 -0
  9. package/extensions/background-terminals/src/prompt.ts +13 -5
  10. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  11. package/extensions/clear-context/index.ts +83 -0
  12. package/extensions/context-pivot/index.ts +16 -6
  13. package/extensions/cron/schedule.ts +7 -1
  14. package/extensions/file-mutation-display/render.ts +17 -257
  15. package/extensions/file-search/src/binaries.ts +57 -41
  16. package/extensions/git-read/index.ts +1 -3
  17. package/extensions/model-info/index.ts +21 -33
  18. package/extensions/model-info/session-metrics.ts +96 -0
  19. package/extensions/plan-mode/bash-policy.ts +54 -9
  20. package/extensions/plan-mode/index.ts +7 -2
  21. package/extensions/post-edit/index.ts +16 -6
  22. package/extensions/sessions/git-stats.ts +258 -72
  23. package/extensions/sessions/index.ts +153 -86
  24. package/extensions/sessions/preview-cache.ts +104 -0
  25. package/extensions/sessions/preview-loader.ts +856 -0
  26. package/extensions/sessions/sessions.ts +43 -4
  27. package/extensions/setup/index.ts +123 -127
  28. package/extensions/shared/activity-status.ts +30 -0
  29. package/extensions/shared/agent-session-page.ts +319 -0
  30. package/extensions/shared/agent-tool-renderer.ts +218 -0
  31. package/extensions/shared/agent-transcript.ts +524 -0
  32. package/extensions/shared/capability-intent.ts +1 -1
  33. package/extensions/shared/child-session.ts +437 -21
  34. package/extensions/shared/result-delivery.ts +34 -0
  35. package/extensions/shared/setup-config.ts +73 -33
  36. package/extensions/shared/setup-episode-state.ts +1 -1
  37. package/extensions/shared/terminal-text.ts +110 -23
  38. package/extensions/shared/text-projection.ts +72 -15
  39. package/extensions/shared/tool-activity.ts +382 -0
  40. package/extensions/shared/tool-surface.ts +29 -2
  41. package/extensions/shared/transcript-viewport.ts +46 -0
  42. package/extensions/shared/web-observer-registry.ts +390 -0
  43. package/extensions/shared/worktree.ts +11 -0
  44. package/extensions/subagents/index.ts +270 -59
  45. package/extensions/subagents/navigation.ts +34 -5
  46. package/extensions/subagents/src/backend.ts +12 -1
  47. package/extensions/subagents/src/backends/pi.ts +375 -66
  48. package/extensions/subagents/src/domain.ts +5 -0
  49. package/extensions/subagents/src/manager.ts +34 -2
  50. package/extensions/subagents/src/prompt.ts +32 -4
  51. package/extensions/subagents/src/result-artifact.ts +4 -0
  52. package/extensions/subagents/src/result-delivery.ts +7 -1
  53. package/extensions/subagents/src/runtime.ts +15 -1
  54. package/extensions/subagents/src/ui/takeover.ts +73 -257
  55. package/extensions/subagents/src/ui/transcript.ts +38 -535
  56. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  57. package/extensions/suggestions/src/ui.ts +10 -4
  58. package/extensions/tasks/index.ts +0 -3
  59. package/extensions/ui-customization/footer.ts +0 -40
  60. package/extensions/ui-customization/index.ts +0 -4
  61. package/extensions/user-input-fold/index.ts +1 -1
  62. package/extensions/web/index.ts +234 -0
  63. package/extensions/workflows/artifacts.ts +137 -47
  64. package/extensions/workflows/completion-projection.ts +457 -0
  65. package/extensions/workflows/coordinator.ts +8 -10
  66. package/extensions/workflows/dashboard.ts +167 -228
  67. package/extensions/workflows/handoff.ts +70 -16
  68. package/extensions/workflows/index.ts +488 -198
  69. package/extensions/workflows/journal.ts +148 -13
  70. package/extensions/workflows/model.ts +74 -4
  71. package/extensions/workflows/navigation.ts +32 -8
  72. package/extensions/workflows/progress-projection.ts +306 -0
  73. package/extensions/workflows/prompt.ts +66 -6
  74. package/extensions/workflows/replay-safety.ts +42 -21
  75. package/extensions/workflows/result-delivery.ts +128 -64
  76. package/extensions/workflows/retention.ts +593 -0
  77. package/extensions/workflows/runner.ts +388 -279
  78. package/extensions/workflows/sandbox-child.cjs +25 -3
  79. package/extensions/workflows/sandbox.ts +62 -8
  80. package/extensions/workflows/serialization.ts +325 -17
  81. package/extensions/workflows/tool-renderer.ts +22 -0
  82. package/extensions/workflows/transcript.ts +149 -0
  83. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  84. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  85. package/package.json +28 -8
  86. package/skills/subagents/REFERENCE.md +189 -0
  87. package/skills/subagents/SKILL.md +1 -1
  88. package/skills/workflows/REFERENCE.md +4 -2
  89. package/web/adapter/pi-adapter.ts +661 -0
  90. package/web/host/browser-launcher.ts +20 -0
  91. package/web/host/static-assets.ts +4 -0
  92. package/web/host/terminal-status.ts +38 -0
  93. package/web/host/web-host.ts +789 -0
  94. package/web/http-dispatcher.ts +125 -0
  95. package/web/protocol/types.ts +462 -0
  96. package/web/runtime/pi-runtime.ts +991 -0
  97. package/web/runtime/types.ts +71 -0
  98. package/web/runtime/web-host-lease.ts +497 -0
  99. package/web/trace.ts +18 -0
  100. package/web/ui/app.js +1398 -0
  101. package/web/ui/index.html +139 -0
  102. package/web/ui/styles.css +598 -0
  103. package/web/vite.config.mjs +34 -0
  104. package/extensions/execution-convergence/active-evidence.ts +0 -129
  105. package/extensions/execution-convergence/index.ts +0 -442
  106. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  107. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  108. package/extensions/setup/intercom.ts +0 -603
  109. package/extensions/subagents/src/backends/stub.ts +0 -303
@@ -13,13 +13,17 @@
13
13
  */
14
14
 
15
15
  import type { OutputView } from "./domain.ts";
16
+ import { TerminalTextSanitizer } from "../../shared/terminal-text.ts";
16
17
 
17
18
  export class OutputBuffer {
18
19
  private chunks: string[] = [];
20
+ private modelSafeChunks: string[] = [];
19
21
  /** Bytes currently retained across `chunks`. */
20
22
  private retainedBytes = 0;
23
+ private modelSafeRetainedBytes = 0;
21
24
  /** Cached join of `chunks`; invalidated on push so 1Hz UI ticks are cheap. */
22
25
  private cachedText: string | undefined = "";
26
+ private cachedModelSafeText: string | undefined = "";
23
27
  /** Bumped on every push; lets the UI cache derived line layouts. */
24
28
  version = 0;
25
29
  totalBytes = 0;
@@ -28,6 +32,7 @@ export class OutputBuffer {
28
32
 
29
33
  private readonly maxRetainedBytes: number;
30
34
  private readonly spill?: (chunk: string) => unknown;
35
+ private readonly modelSanitizer = new TerminalTextSanitizer();
31
36
 
32
37
  constructor(maxRetainedBytes: number, spill?: (chunk: string) => unknown) {
33
38
  this.maxRetainedBytes = maxRetainedBytes;
@@ -36,6 +41,7 @@ export class OutputBuffer {
36
41
 
37
42
  push(chunk: string) {
38
43
  if (chunk.length === 0) return true;
44
+ this.retainModelSafeText(this.modelSanitizer.push(chunk));
39
45
  let bytes = Buffer.byteLength(chunk, "utf8");
40
46
  this.totalBytes += bytes;
41
47
  const spillAccepted = this.spill?.(chunk) !== false;
@@ -74,11 +80,38 @@ export class OutputBuffer {
74
80
 
75
81
  view(): OutputView {
76
82
  this.cachedText ??= this.chunks.join("");
83
+ this.cachedModelSafeText ??= this.modelSafeChunks.join("");
77
84
  return {
78
85
  text: this.cachedText,
86
+ modelSafeText: this.cachedModelSafeText,
79
87
  totalBytes: this.totalBytes,
80
88
  truncatedBytes: this.truncatedBytes,
81
89
  spillPath: this.spillPath,
82
90
  };
83
91
  }
92
+
93
+ private retainModelSafeText(text: string) {
94
+ if (text.length === 0) return;
95
+ let bytes = Buffer.byteLength(text, "utf8");
96
+ if (bytes > this.maxRetainedBytes) {
97
+ this.modelSafeChunks = [];
98
+ this.modelSafeRetainedBytes = 0;
99
+ const raw = Buffer.from(text, "utf8");
100
+ let start = raw.length - this.maxRetainedBytes;
101
+ while (start < raw.length && (raw[start] & 0xc0) === 0x80) start++;
102
+ text = raw.subarray(start).toString("utf8");
103
+ bytes = raw.length - start;
104
+ }
105
+ this.modelSafeChunks.push(text);
106
+ this.modelSafeRetainedBytes += bytes;
107
+ while (
108
+ this.modelSafeRetainedBytes > this.maxRetainedBytes &&
109
+ this.modelSafeChunks.length > 1
110
+ ) {
111
+ const evicted = this.modelSafeChunks.shift();
112
+ if (evicted === undefined) break;
113
+ this.modelSafeRetainedBytes -= Buffer.byteLength(evicted, "utf8");
114
+ }
115
+ this.cachedModelSafeText = undefined;
116
+ }
84
117
  }
@@ -6,6 +6,7 @@ import {
6
6
  formatSize,
7
7
  truncateTail,
8
8
  } from "@earendil-works/pi-coding-agent";
9
+ import { sanitizeTerminalText } from "../../shared/terminal-text.ts";
9
10
  import {
10
11
  formatDuration,
11
12
  formatElapsed,
@@ -139,7 +140,7 @@ function outputSection(
139
140
  maxLines: number,
140
141
  ) {
141
142
  if (view.totalBytes === 0) return `${label}: (empty)`;
142
- const truncation = truncateTail(view.text, {
143
+ const truncation = truncateTail(view.modelSafeText, {
143
144
  maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES),
144
145
  maxLines: Math.min(maxLines, DEFAULT_MAX_LINES),
145
146
  });
@@ -186,12 +187,15 @@ export function buildTerminalBatchResultMessage(
186
187
  messages: readonly string[],
187
188
  omitted = 0,
188
189
  ) {
189
- if (messages.length === 1 && omitted === 0) return messages[0]!;
190
- const summaries = messages.map(
190
+ const sanitizedMessages = messages.map(sanitizeTerminalText);
191
+ if (sanitizedMessages.length === 1 && omitted === 0) {
192
+ return sanitizedMessages[0]!;
193
+ }
194
+ const summaries = sanitizedMessages.map(
191
195
  (message) => message.split("\n", 1)[0] || "Background terminal result",
192
196
  );
193
197
  const header = [
194
- `${messages.length} background terminal result${messages.length === 1 ? "" : "s"}:`,
198
+ `${sanitizedMessages.length} background terminal result${sanitizedMessages.length === 1 ? "" : "s"}:`,
195
199
  ...summaries.map((summary) => `- ${summary}`),
196
200
  omitted > 0
197
201
  ? `- ${omitted} older result${omitted === 1 ? "" : "s"} omitted from this bounded batch; use bg_list/bg_status for retained details.`
@@ -206,7 +210,7 @@ export function buildTerminalBatchResultMessage(
206
210
  `${header}${logsHeader}${truncationMarker}`,
207
211
  "utf8",
208
212
  );
209
- const logs = truncateTail(messages.join("\n\n"), {
213
+ const logs = truncateTail(sanitizedMessages.join("\n\n"), {
210
214
  maxBytes: Math.max(1, RESULT_BATCH_MAX - fixedBytes),
211
215
  maxLines: DEFAULT_MAX_LINES,
212
216
  });
@@ -220,6 +224,10 @@ export function buildKillReport(results: ReadonlyArray<KillResult>) {
220
224
  if (entry.killed) {
221
225
  return `Killed ${entry.id} "${entry.title}" (${entry.exit}).`;
222
226
  }
227
+ if (entry.terminationFailed) {
228
+ const detail = entry.errorText ? ` ${entry.errorText}.` : "";
229
+ return `Could not confirm process-tree termination for ${entry.id} "${entry.title}" (${entry.exit}).${detail}`;
230
+ }
223
231
  if (entry.wasRunning) {
224
232
  // The natural exit won the race with the kill signal.
225
233
  return `${entry.id} "${entry.title}" exited on its own before the kill landed (${entry.exit}).`;
@@ -1,3 +1,5 @@
1
+ import type { ConsumableResultDeliveryQueue } from "../../shared/result-delivery.ts";
2
+
1
3
  /**
2
4
  * Deferred one-shot delivery map (same semantics as subagents'): a settled
3
5
  * terminal's result is held here until it is either drained into a follow-up
@@ -8,7 +10,7 @@
8
10
  export function createDeferredResultDelivery<T extends { id: string }>() {
9
11
  const pending = new Map<string, T>();
10
12
 
11
- return {
13
+ const queue = {
12
14
  defer(result: T) {
13
15
  pending.set(result.id, result);
14
16
  return pending.size;
@@ -38,6 +40,7 @@ export function createDeferredResultDelivery<T extends { id: string }>() {
38
40
  pending.clear();
39
41
  },
40
42
  };
43
+ return queue satisfies ConsumableResultDeliveryQueue<T>;
41
44
  }
42
45
 
43
46
  /**
@@ -0,0 +1,83 @@
1
+ import {
2
+ type ExtensionAPI,
3
+ type ExtensionContext,
4
+ type KeybindingsManager,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { Key, matchesKey, type EditorComponent } from "@earendil-works/pi-tui";
7
+ import {
8
+ BelowEditorNavigationEditor,
9
+ BelowEditorStripState,
10
+ } from "../shared/below-editor-navigation.ts";
11
+ import {
12
+ registerEditorLayer,
13
+ removeEditorLayer,
14
+ } from "../shared/editor-layers.ts";
15
+
16
+ export function shouldClearContext(
17
+ data: string,
18
+ editorText: string,
19
+ isIdle: boolean,
20
+ ) {
21
+ return isIdle && editorText.length === 0 && matchesKey(data, Key.ctrl("c"));
22
+ }
23
+
24
+ export class ClearContextEditor extends BelowEditorNavigationEditor {
25
+ private readonly isIdle: () => boolean;
26
+
27
+ constructor(
28
+ base: EditorComponent,
29
+ keybindings: KeybindingsManager,
30
+ isIdle: () => boolean,
31
+ ) {
32
+ super(
33
+ base,
34
+ keybindings,
35
+ new BelowEditorStripState(),
36
+ () => false,
37
+ () => undefined,
38
+ () => undefined,
39
+ );
40
+ this.isIdle = isIdle;
41
+ }
42
+
43
+ override handleInput(data: string) {
44
+ if (shouldClearContext(data, this.getText(), this.isIdle())) {
45
+ // Route through Pi's built-in /new command so session replacement keeps
46
+ // Pi's persistence, cleanup, and transcript lifecycle as the source of truth.
47
+ this.setText("/new");
48
+ this.onSubmit?.("/new");
49
+ return;
50
+ }
51
+
52
+ super.handleInput(data);
53
+ }
54
+ }
55
+
56
+ function installClearContextShortcut(pi: ExtensionAPI, ctx: ExtensionContext) {
57
+ if (ctx.mode !== "tui") return () => {};
58
+
59
+ registerEditorLayer(pi, ctx, {
60
+ id: "clear-context",
61
+ order: 100,
62
+ wrap: (base, _tui, _theme, keybindings) =>
63
+ new ClearContextEditor(base, keybindings, () => ctx.isIdle()),
64
+ });
65
+
66
+ return () => removeEditorLayer(pi, "clear-context");
67
+ }
68
+
69
+ export default function clearContext(pi: ExtensionAPI) {
70
+ let removeShortcut = () => {};
71
+
72
+ pi.on("session_start", (_event, ctx) => {
73
+ removeShortcut();
74
+ removeShortcut = installClearContextShortcut(pi, ctx);
75
+ });
76
+
77
+ pi.on("session_shutdown", () => {
78
+ removeShortcut();
79
+ removeShortcut = () => {};
80
+ });
81
+ }
82
+
83
+ export { installClearContextShortcut };
@@ -11,6 +11,9 @@ import {
11
11
 
12
12
  export const MIN_CONTEXT_PIVOT_TOKENS = 30_000;
13
13
  const STATUS_KEY = "context-pivot";
14
+ const NOTHING_TO_COMPACT_ERROR = "Nothing to compact (session too small)";
15
+ const NO_DISCARDABLE_HISTORY_MESSAGE =
16
+ "Context pivot could not run: this session has no discardable history to compact. Continue in the current session, or use /sessions to choose another session; to begin cleanly, start a new Session in Pi.";
14
17
 
15
18
  interface PendingPivot {
16
19
  brief: string;
@@ -57,6 +60,13 @@ export function buildPivotSummary(brief: string): string {
57
60
  ].join("\n");
58
61
  }
59
62
 
63
+ function formatContextPivotError(error: unknown): string {
64
+ const message = error instanceof Error ? error.message : String(error);
65
+ return message === NOTHING_TO_COMPACT_ERROR
66
+ ? NO_DISCARDABLE_HISTORY_MESSAGE
67
+ : `Context pivot failed: ${message}`;
68
+ }
69
+
60
70
  function impossibleKeptId(entries: readonly SessionEntry[]) {
61
71
  return `${entries.at(-1)?.id ?? "context-pivot"}-context-pivot-cut`;
62
72
  }
@@ -73,7 +83,7 @@ function validateBrief(brief: string, ctx: ExtensionContext) {
73
83
  }
74
84
  if (tokens < MIN_CONTEXT_PIVOT_TOKENS) {
75
85
  throw new Error(
76
- `Context is only ${Math.round(tokens).toLocaleString()} tokens; use context_pivot once context reaches at least ${MIN_CONTEXT_PIVOT_TOKENS.toLocaleString()} tokens, or /handoff for a genuinely new session.`,
86
+ `Context is only ${Math.round(tokens).toLocaleString()} tokens; use context_pivot once context reaches at least ${MIN_CONTEXT_PIVOT_TOKENS.toLocaleString()} tokens, or use /sessions to browse or switch an existing session; start a new Session in Pi when a clean session is needed.`,
77
87
  );
78
88
  }
79
89
  }
@@ -127,7 +137,7 @@ export default function contextPivot(pi: ExtensionAPI) {
127
137
  name: "context_pivot",
128
138
  label: "Context Pivot",
129
139
  description:
130
- "Deliberately replace a long, noisy active context with a concise brief for the next phase while staying in the same Pi session. Use once context is at least 30k tokens and the work is moving between phases such as research → implementation or implementation → review; below 30k it is rejected. Use /handoff instead for a genuinely new session.",
140
+ "Deliberately replace a long, noisy active context with a concise brief for the next phase while staying in the same Pi session. Use once context is at least 30k tokens and the work is moving between phases such as research → implementation or implementation → review; below 30k it is rejected. Use /sessions to browse or switch an existing session; start a new Session in Pi when a genuinely new session is needed.",
131
141
  promptSnippet:
132
142
  "Compress a long current session into a clean brief before changing phase",
133
143
  promptGuidelines: [
@@ -176,11 +186,11 @@ export default function contextPivot(pi: ExtensionAPI) {
176
186
  onError: (error) => {
177
187
  if (pivotGeneration === generation) pending = undefined;
178
188
  clear();
189
+ const message = formatContextPivotError(error);
179
190
  if (ctx.hasUI) {
180
- ctx.ui.notify(
181
- `Context pivot failed: ${error instanceof Error ? error.message : String(error)}`,
182
- "error",
183
- );
191
+ ctx.ui.notify(message, "error");
192
+ } else {
193
+ console.error(message);
184
194
  }
185
195
  },
186
196
  });
@@ -81,8 +81,14 @@ export function parseCronCommand(raw: string): ParsedCronCommand {
81
81
  error: `Minimum interval is ${MIN_INTERVAL_MS / 1000}s (the scheduler polls about that often).`,
82
82
  };
83
83
  }
84
- const prompt = schedule[3].trim().slice(0, CRON_PROMPT_MAX_CHARS);
84
+ const prompt = schedule[3].trim();
85
85
  if (!prompt) return { action: "help", error: "Provide a prompt to run." };
86
+ if (prompt.length > CRON_PROMPT_MAX_CHARS) {
87
+ return {
88
+ action: "help",
89
+ error: `Prompt is too long. Maximum is ${CRON_PROMPT_MAX_CHARS} characters.`,
90
+ };
91
+ }
86
92
  return {
87
93
  action: "add",
88
94
  intervalMs,
@@ -1,13 +1,11 @@
1
- import { isAbsolute, relative } from "node:path";
2
- import { stripVTControlCharacters } from "node:util";
3
1
  import type {
4
2
  AgentToolResult,
5
3
  Theme,
6
4
  ToolDefinition,
7
5
  } from "@earendil-works/pi-coding-agent";
8
- import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
6
+ import type { Component } from "@earendil-works/pi-tui";
9
7
  import type { TSchema } from "typebox";
10
- import { spinnerFrame } from "../shared/spinner.ts";
8
+ import { renderPaddedToolActivityLine } from "../shared/tool-activity.ts";
11
9
 
12
10
  type ActivityStatus = "pending" | "success" | "error";
13
11
 
@@ -23,33 +21,11 @@ type ActivityRenderState<TDetails> = {
23
21
  };
24
22
  };
25
23
 
26
- type ActivityRow = {
27
- verb: string;
28
- target: string;
29
- detail?: string;
30
- };
31
-
32
- const HORIZONTAL_PADDING = " ";
33
-
34
24
  const emptyComponent: Component = {
35
25
  render: () => [],
36
26
  invalidate() {},
37
27
  };
38
28
 
39
- function record(value: unknown): Record<string, unknown> {
40
- return value !== null && typeof value === "object"
41
- ? (value as Record<string, unknown>)
42
- : {};
43
- }
44
-
45
- function string(value: unknown) {
46
- return typeof value === "string" ? value : "";
47
- }
48
-
49
- function number(value: unknown) {
50
- return typeof value === "number" ? value : undefined;
51
- }
52
-
53
29
  function textOutput(result: AgentToolResult<unknown> | undefined) {
54
30
  return (
55
31
  result?.content
@@ -59,228 +35,6 @@ function textOutput(result: AgentToolResult<unknown> | undefined) {
59
35
  );
60
36
  }
61
37
 
62
- function resultCount(result: AgentToolResult<unknown> | undefined) {
63
- return textOutput(result)
64
- .split(/\r?\n/)
65
- .filter((line) => line.trim().length > 0 && !line.trim().startsWith("["))
66
- .length;
67
- }
68
-
69
- function grepMatchCount(result: AgentToolResult<unknown> | undefined) {
70
- const output = textOutput(result).trim();
71
- if (!output || output === "No matches found") return 0;
72
- return output.split(/\r?\n/).filter((line) => /^.+:\d+:/.test(line)).length;
73
- }
74
-
75
- function itemCount(
76
- result: AgentToolResult<unknown> | undefined,
77
- emptyMessage: string,
78
- ) {
79
- const output = textOutput(result).trim();
80
- return !output || output === emptyMessage ? 0 : resultCount(result);
81
- }
82
-
83
- function plural(count: number, singular: string) {
84
- const pluralForm =
85
- singular === "match"
86
- ? "matches"
87
- : singular === "entry"
88
- ? "entries"
89
- : `${singular}s`;
90
- return `${count} ${count === 1 ? singular : pluralForm}`;
91
- }
92
-
93
- function editStats(details: unknown) {
94
- const diff = string(record(details).diff);
95
- if (!diff) return undefined;
96
- let additions = 0;
97
- let removals = 0;
98
- for (const line of diff.split(/\r?\n/)) {
99
- if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
100
- if (line.startsWith("-") && !line.startsWith("---")) removals += 1;
101
- }
102
- return { additions, removals };
103
- }
104
-
105
- function range(args: Record<string, unknown>) {
106
- const offset = number(args.offset);
107
- const limit = number(args.limit);
108
- if (offset === undefined && limit === undefined) return "";
109
- const start = offset ?? 1;
110
- return limit === undefined ? `:${start}-` : `:${start}-${start + limit - 1}`;
111
- }
112
-
113
- function displayPath(path: string, cwd: string) {
114
- if (!isAbsolute(path)) return path;
115
- const local = relative(cwd, path);
116
- if (local === "") return ".";
117
- return local.startsWith("..") || isAbsolute(local) ? path : local;
118
- }
119
-
120
- function activityRow(
121
- name: string,
122
- argsValue: unknown,
123
- result: AgentToolResult<unknown> | undefined,
124
- cwd: string,
125
- ): ActivityRow {
126
- const args = record(argsValue);
127
- const path = displayPath(string(args.path) || ".", cwd);
128
- switch (name) {
129
- case "read":
130
- return { verb: "Read", target: `${path}${range(args)}` };
131
- case "bash":
132
- return {
133
- verb: "Ran",
134
- target: string(args.command).replace(/\s+/g, " ").trim(),
135
- };
136
- case "write": {
137
- const content = string(args.content);
138
- const lines =
139
- content.length === 0
140
- ? 0
141
- : content.replace(/\r?\n$/, "").split(/\r?\n/).length;
142
- return { verb: "Wrote", target: path, detail: plural(lines, "line") };
143
- }
144
- case "edit":
145
- return { verb: "Edited", target: path };
146
- case "grep":
147
- return {
148
- verb: "Searched",
149
- target: string(args.pattern),
150
- detail: `in ${path} ${plural(grepMatchCount(result), "match")}`,
151
- };
152
- case "find":
153
- return {
154
- verb: "Searched",
155
- target: string(args.pattern),
156
- detail: `in ${path} ${plural(itemCount(result, "No files found matching pattern"), "result")}`,
157
- };
158
- case "ls":
159
- return {
160
- verb: "Listed",
161
- target: path,
162
- detail: plural(itemCount(result, "(empty directory)"), "entry"),
163
- };
164
- default:
165
- return { verb: name, target: "" };
166
- }
167
- }
168
-
169
- function pendingVerb(name: string) {
170
- switch (name) {
171
- case "read":
172
- return "Reading";
173
- case "bash":
174
- return "Running";
175
- case "write":
176
- return "Writing";
177
- case "edit":
178
- return "Editing";
179
- case "grep":
180
- case "find":
181
- return "Searching";
182
- case "ls":
183
- return "Listing";
184
- default:
185
- return "Running";
186
- }
187
- }
188
-
189
- function activityIcon(name: string) {
190
- switch (name) {
191
- case "read":
192
- return "\ueaa4"; // Nerd Fonts Codicon: book
193
- case "bash":
194
- return "\uea85"; // Nerd Fonts Codicon: terminal
195
- case "write":
196
- case "edit":
197
- return "\uea73"; // Nerd Fonts Codicon: edit
198
- case "grep":
199
- case "find":
200
- return "\uea6d"; // Nerd Fonts Codicon: search
201
- case "ls":
202
- return "\uea83"; // Nerd Fonts Codicon: folder
203
- default:
204
- return "✓";
205
- }
206
- }
207
-
208
- function errorSummary(result: AgentToolResult<unknown> | undefined) {
209
- const lines = textOutput(result)
210
- .split(/\r?\n/)
211
- .map((line) => stripVTControlCharacters(line).trim())
212
- .filter(Boolean);
213
- return (
214
- [...lines]
215
- .reverse()
216
- .find((line) =>
217
- /(?:command (?:exited|timed out|aborted)|error|denied|failed)/i.test(
218
- line,
219
- ),
220
- ) ?? lines[0]
221
- );
222
- }
223
-
224
- function duration(
225
- state: NonNullable<ActivityRenderState<unknown>["openpiActivity"]>,
226
- ) {
227
- if (state.startedAt === undefined) return undefined;
228
- const seconds = Math.floor(
229
- ((state.endedAt ?? Date.now()) - state.startedAt) / 1000,
230
- );
231
- return seconds > 0 ? `${seconds}s` : undefined;
232
- }
233
-
234
- function activityText(
235
- name: string,
236
- args: unknown,
237
- state: NonNullable<ActivityRenderState<unknown>["openpiActivity"]>,
238
- theme: Theme,
239
- cwd: string,
240
- ) {
241
- const row = activityRow(name, args, state.result, cwd);
242
- const elapsed = duration(state);
243
- const verbText = (
244
- state.status === "pending"
245
- ? pendingVerb(name)
246
- : state.status === "error"
247
- ? "Failed"
248
- : row.verb
249
- ).padEnd(8);
250
- const verb = theme.fg(
251
- state.status === "error"
252
- ? "error"
253
- : state.status === "success"
254
- ? "muted"
255
- : "toolTitle",
256
- verbText,
257
- );
258
- if (state.status === "pending") {
259
- const detail = elapsed ? ` · ${elapsed}` : "";
260
- return `${theme.fg("warning", spinnerFrame(Date.now()))} ${verb} ${row.target}${theme.fg("dim", detail)}`;
261
- }
262
- if (state.status === "error") {
263
- const summary = errorSummary(state.result);
264
- const detail = [elapsed, summary].filter(Boolean).join(" · ");
265
- return `${theme.fg("error", "✕")} ${verb} ${row.target}${detail ? theme.fg("dim", ` · ${detail}`) : ""}`;
266
- }
267
- const parts: string[] = [];
268
- if (name === "edit") {
269
- // Kimi-style diff stats: additions green, removals red.
270
- const stats = editStats(state.result?.details);
271
- if (stats) {
272
- parts.push(
273
- `${theme.fg("success", `+${stats.additions}`)} ${theme.fg("error", `-${stats.removals}`)}`,
274
- );
275
- }
276
- } else if (row.detail) {
277
- parts.push(theme.fg("dim", row.detail));
278
- }
279
- if (elapsed) parts.push(theme.fg("dim", elapsed));
280
- const detail = parts.join(theme.fg("dim", " · "));
281
- return `${theme.fg("dim", activityIcon(name))} ${verb} ${theme.fg("muted", row.target)}${detail ? ` ${detail}` : ""}`;
282
- }
283
-
284
38
  function activityComponent(
285
39
  name: string,
286
40
  args: unknown,
@@ -290,15 +44,21 @@ function activityComponent(
290
44
  ): Component {
291
45
  return {
292
46
  render(width) {
293
- const contentWidth = width - HORIZONTAL_PADDING.length * 2;
294
- if (contentWidth <= 0) return [];
295
- return [
296
- `${HORIZONTAL_PADDING}${truncateToWidth(
297
- activityText(name, args, state, theme, cwd),
298
- contentWidth,
299
- "…",
300
- )}${HORIZONTAL_PADDING}`,
301
- ];
47
+ const line = renderPaddedToolActivityLine(
48
+ {
49
+ name,
50
+ args,
51
+ output: textOutput(state.result),
52
+ details: state.result?.details,
53
+ status: state.status,
54
+ cwd,
55
+ startedAt: state.startedAt,
56
+ endedAt: state.endedAt,
57
+ },
58
+ theme,
59
+ width,
60
+ );
61
+ return line ? [line] : [];
302
62
  },
303
63
  invalidate() {},
304
64
  };