@tt-a1i/openpi 0.3.0 → 0.4.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 (61) hide show
  1. package/README.md +87 -24
  2. package/SETUP.md +3 -3
  3. package/extensions/ask-user/index.ts +30 -14
  4. package/extensions/background-terminals/src/prompt.ts +1 -1
  5. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  6. package/extensions/capabilities/index.ts +35 -44
  7. package/extensions/capabilities/src/ui.ts +93 -0
  8. package/extensions/file-mutation-display/index.ts +34 -76
  9. package/extensions/file-mutation-display/render.ts +387 -88
  10. package/extensions/file-search/index.ts +8 -7
  11. package/extensions/file-search/src/binaries.ts +18 -18
  12. package/extensions/git-info/src/changed-files-view.ts +47 -14
  13. package/extensions/git-read/index.ts +330 -0
  14. package/extensions/git-read/src/args.ts +171 -0
  15. package/extensions/git-read/src/process.ts +81 -0
  16. package/extensions/git-read/src/prompt.ts +56 -0
  17. package/extensions/sessions/index.ts +70 -55
  18. package/extensions/setup/index.ts +6 -6
  19. package/extensions/shared/activity-status.ts +6 -5
  20. package/extensions/shared/below-editor-navigation.ts +26 -0
  21. package/extensions/shared/capability-intent.ts +53 -0
  22. package/extensions/shared/child-session.ts +7 -1
  23. package/extensions/shared/result-budget.ts +134 -0
  24. package/extensions/shared/screen-chrome.ts +133 -0
  25. package/extensions/shared/setup-config.ts +24 -5
  26. package/extensions/shared/spinner.ts +28 -0
  27. package/extensions/shared/text-projection.ts +56 -0
  28. package/extensions/shared/tool-surface.ts +13 -6
  29. package/extensions/subagents/index.ts +216 -170
  30. package/extensions/subagents/navigation.ts +52 -23
  31. package/extensions/subagents/src/agent-types.ts +37 -15
  32. package/extensions/subagents/src/backends/stub.ts +7 -0
  33. package/extensions/subagents/src/id-sequence.ts +84 -0
  34. package/extensions/subagents/src/manager.ts +620 -537
  35. package/extensions/subagents/src/prompt.ts +153 -38
  36. package/extensions/subagents/src/result-artifact.ts +142 -0
  37. package/extensions/subagents/src/result-delivery.ts +50 -5
  38. package/extensions/subagents/src/runtime.ts +8 -5
  39. package/extensions/subagents/src/ui/takeover.ts +84 -109
  40. package/extensions/subagents/src/ui/transcript.ts +76 -42
  41. package/extensions/subagents/src/ui/wait-result.ts +1 -1
  42. package/extensions/tasks/ui.ts +79 -62
  43. package/extensions/ui-customization/footer.ts +7 -4
  44. package/extensions/user-input-fold/index.ts +185 -0
  45. package/extensions/workflows/artifacts.ts +35 -0
  46. package/extensions/workflows/controller.ts +14 -2
  47. package/extensions/workflows/coordinator.ts +64 -0
  48. package/extensions/workflows/dashboard.ts +353 -173
  49. package/extensions/workflows/handoff.ts +62 -20
  50. package/extensions/workflows/index.ts +647 -387
  51. package/extensions/workflows/model.ts +57 -15
  52. package/extensions/workflows/navigation.ts +33 -14
  53. package/extensions/workflows/prompt.ts +104 -8
  54. package/extensions/workflows/replay-safety.ts +16 -6
  55. package/extensions/workflows/result-delivery.ts +189 -0
  56. package/extensions/workflows/sandbox-child.cjs +11 -0
  57. package/package.json +1 -1
  58. package/skills/subagents/SKILL.md +2 -2
  59. package/skills/workflows/REFERENCE.md +7 -4
  60. package/skills/workflows/SKILL.md +53 -10
  61. package/extensions/subagents/src/format.ts +0 -48
@@ -3,21 +3,22 @@
3
3
  *
4
4
  * Resolution order (per tool, first usable wins):
5
5
  * 1. A normally installed system binary (`fd`/`fdfind`, `rg`) — used silently.
6
- * 2. An existing fallback in this repository's `bin/` directory — used silently.
7
- * 3. A fresh download of an official release into `bin/` the only case that
8
- * should surface a UI notification.
6
+ * 2. An existing binary in the agent's managed bin directory
7
+ * (`~/.pi/agent/bin`), cached by an earlier downloadused silently.
8
+ * 3. A fresh download of an official release into that directory — the only
9
+ * case that should surface a UI notification.
9
10
  *
10
11
  * The decision logic is an Effect over a small injectable environment
11
12
  * (`BinaryEnv`) so tests can drive it without touching the filesystem or the
12
13
  * network. `liveBinaryEnv` is the real implementation.
13
14
  */
14
15
 
16
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
15
17
  import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
16
18
  import * as NodeServices from "@effect/platform-node/NodeServices";
17
19
  import { execFile } from "node:child_process";
18
20
  import { tmpdir } from "node:os";
19
21
  import { dirname, join } from "node:path";
20
- import { fileURLToPath } from "node:url";
21
22
  import { promisify } from "node:util";
22
23
  import { Crypto, Data, Effect, Encoding, FileSystem, Stream } from "effect";
23
24
  import { FetchHttpClient, HttpClient } from "effect/unstable/http";
@@ -56,13 +57,13 @@ const RG_SHA256: Readonly<Record<string, string>> = {
56
57
  };
57
58
 
58
59
  export type ToolName = "fd" | "rg";
59
- export type BinarySource = "system" | "bundled" | "installed";
60
+ export type BinarySource = "system" | "cached" | "installed";
60
61
 
61
62
  export interface ToolSpec {
62
63
  readonly tool: ToolName;
63
64
  /** Commands probed on PATH, in order. Debian/Ubuntu install fd as `fdfind`. */
64
65
  readonly systemCommands: readonly string[];
65
- /** Executable name used inside release archives and the repo bin directory. */
66
+ /** Executable name used inside release archives and the managed bin directory. */
66
67
  readonly binaryName: string;
67
68
  }
68
69
 
@@ -144,10 +145,9 @@ export function currentTarget(): PlatformTarget {
144
145
  return { os: process.platform, arch: process.arch };
145
146
  }
146
147
 
147
- /** Repository root (`~/.pi/agent`) resolved from this module's location. */
148
- export function repositoryBinDir() {
149
- const moduleDir = dirname(fileURLToPath(import.meta.url));
150
- return join(moduleDir, "..", "..", "..", "bin");
148
+ /** Pi agent's managed binaries directory (`~/.pi/agent/bin`). */
149
+ export function managedBinDir() {
150
+ return join(getAgentDir(), "bin");
151
151
  }
152
152
 
153
153
  export class UnsupportedPlatformError extends Data.TaggedError(
@@ -179,7 +179,7 @@ export interface ResolvedBinary {
179
179
  readonly version?: string;
180
180
  }
181
181
 
182
- /** Resolve one tool: system binary, existing bin fallback, or fresh install. */
182
+ /** Resolve one tool: system binary, cached fallback, or fresh install. */
183
183
  export function resolveBinary(
184
184
  spec: ToolSpec,
185
185
  binDir: string,
@@ -193,9 +193,9 @@ export function resolveBinary(
193
193
  }
194
194
  }
195
195
 
196
- const bundled = join(binDir, spec.binaryName);
197
- if (yield* env.probe(bundled, spec.tool)) {
198
- return { tool: spec.tool, command: bundled, source: "bundled" as const };
196
+ const cached = join(binDir, spec.binaryName);
197
+ if (yield* env.probe(cached, spec.tool)) {
198
+ return { tool: spec.tool, command: cached, source: "cached" as const };
199
199
  }
200
200
 
201
201
  const asset = releaseAsset(spec.tool, target);
@@ -205,17 +205,17 @@ export function resolveBinary(
205
205
  });
206
206
  }
207
207
 
208
- yield* env.install(asset, bundled);
208
+ yield* env.install(asset, cached);
209
209
 
210
- if (!(yield* env.probe(bundled, spec.tool))) {
210
+ if (!(yield* env.probe(cached, spec.tool))) {
211
211
  return yield* new InstallError({
212
- message: `${spec.tool} ${asset.version} was installed to ${bundled} but failed to run.`,
212
+ message: `${spec.tool} ${asset.version} was installed to ${cached} but failed to run.`,
213
213
  });
214
214
  }
215
215
 
216
216
  return {
217
217
  tool: spec.tool,
218
- command: bundled,
218
+ command: cached,
219
219
  source: "installed" as const,
220
220
  version: asset.version,
221
221
  };
@@ -7,6 +7,7 @@ import {
7
7
  visibleWidth,
8
8
  } from "@earendil-works/pi-tui";
9
9
  import { Effect } from "effect";
10
+ import { hintLine } from "../../shared/screen-chrome.ts";
10
11
  import { sanitizeTerminalText } from "../../shared/terminal-text.ts";
11
12
  import { runCommand } from "./process.ts";
12
13
 
@@ -174,6 +175,15 @@ export async function showChangedFiles(
174
175
  return Math.max(8, Math.floor(tui.terminal.rows * 0.8) - 2);
175
176
  }
176
177
 
178
+ /**
179
+ * Rows the diff pane actually paints: one row of the frame's budget goes
180
+ * to the hint line below it. Scroll limits must use this and not
181
+ * bodyHeight(), or the last diff line can never be scrolled into view.
182
+ */
183
+ function visibleRows() {
184
+ return Math.max(4, bodyHeight() - 1);
185
+ }
186
+
177
187
  function ensureSelectedFileVisible() {
178
188
  const visibleFiles = Math.max(1, Math.floor(bodyHeight() / 2));
179
189
  if (selectedIndex < sidebarOffset) sidebarOffset = selectedIndex;
@@ -192,7 +202,7 @@ export async function showChangedFiles(
192
202
  function moveDiff(amount: number) {
193
203
  const maxOffset = Math.max(
194
204
  0,
195
- files[selectedIndex]!.diff.length - bodyHeight(),
205
+ files[selectedIndex]!.diff.length - visibleRows(),
196
206
  );
197
207
  diffOffset = Math.max(0, Math.min(maxOffset, diffOffset + amount));
198
208
  tui.requestRender();
@@ -216,13 +226,18 @@ export async function showChangedFiles(
216
226
  return theme.fg("text", expanded);
217
227
  }
218
228
 
229
+ /**
230
+ * Frame edge with an optional label set into it. Muted, not accent: the
231
+ * frame is not the content, and the accent is spent on the focused pane
232
+ * seam where it actually tells you something.
233
+ */
219
234
  function border(width: number, label: string, top: boolean) {
220
235
  const left = top ? "┌" : "└";
221
236
  const right = top ? "┐" : "┘";
222
- const text = `─ ${label} `;
237
+ const text = label ? `─ ${label} ` : "─";
223
238
  const remaining = Math.max(0, width - visibleWidth(text) - 2);
224
239
  return theme.fg(
225
- "borderAccent",
240
+ "borderMuted",
226
241
  truncateToWidth(
227
242
  `${left}${text}${"─".repeat(remaining)}${right}`,
228
243
  width,
@@ -289,11 +304,11 @@ export async function showChangedFiles(
289
304
  return;
290
305
  }
291
306
  if (matchesKey(data, Key.ctrl("d"))) {
292
- moveDiff(Math.max(1, Math.floor(bodyHeight() / 2)));
307
+ moveDiff(Math.max(1, Math.floor(visibleRows() / 2)));
293
308
  return;
294
309
  }
295
310
  if (matchesKey(data, Key.ctrl("u"))) {
296
- moveDiff(-Math.max(1, Math.floor(bodyHeight() / 2)));
311
+ moveDiff(-Math.max(1, Math.floor(visibleRows() / 2)));
297
312
  return;
298
313
  }
299
314
  if (matchesKey(data, Key.home) || data === "g") {
@@ -304,21 +319,23 @@ export async function showChangedFiles(
304
319
  if (matchesKey(data, Key.end) || data === "G") {
305
320
  diffOffset = Math.max(
306
321
  0,
307
- files[selectedIndex]!.diff.length - bodyHeight(),
322
+ files[selectedIndex]!.diff.length - visibleRows(),
308
323
  );
309
324
  tui.requestRender();
310
325
  }
311
326
  }
312
327
 
313
328
  function render(width: number) {
314
- const height = bodyHeight();
329
+ const height = visibleRows();
315
330
  const sidebarWidth = Math.min(
316
331
  48,
317
332
  Math.max(24, Math.floor(width * 0.34)),
318
333
  );
319
334
  const diffWidth = Math.max(1, width - sidebarWidth - 3);
320
335
  const selectedFile = files[selectedIndex]!;
321
- const title = `local changes · ${files.length} ${files.length === 1 ? "file" : "files"} · ${focus === "files" ? "FILES" : "DIFF"}`;
336
+ // No shouted FILES/DIFF badge: the accent-lit pane seam already says
337
+ // where the keyboard is, without a second thing to read.
338
+ const title = `local changes · ${files.length} ${files.length === 1 ? "file" : "files"}`;
322
339
  const lines = [border(width, title, true)];
323
340
 
324
341
  for (let row = 0; row < height; row += 1) {
@@ -329,7 +346,7 @@ export async function showChangedFiles(
329
346
  if (file) {
330
347
  const isSelected = fileIndex === selectedIndex;
331
348
  if (row % 2 === 0) {
332
- const marker = isSelected ? " " : " ";
349
+ const marker = isSelected ? " " : " ";
333
350
  const isBinary =
334
351
  file.additions === null || file.deletions === null;
335
352
  const stats = isBinary
@@ -386,11 +403,27 @@ export async function showChangedFiles(
386
403
  );
387
404
  }
388
405
 
389
- const help =
390
- focus === "files"
391
- ? "j/k or ↑/↓ select · enter/space/l open diff · esc close"
392
- : "j/k or ↑/↓ scroll · ctrl-d/u page · g/G top/bottom · esc/h files";
393
- lines.push(border(width, help, false));
406
+ lines.push(border(width, "", false));
407
+ // Hints below the frame rather than crammed into its bottom edge, with
408
+ // keys a step brighter than the words describing them.
409
+ lines.push(
410
+ hintLine(
411
+ theme,
412
+ focus === "files"
413
+ ? [
414
+ ["j/k or ↑/↓", "select"],
415
+ ["enter/space/l", "open diff"],
416
+ ["esc", "close"],
417
+ ]
418
+ : [
419
+ ["j/k or ↑/↓", "scroll"],
420
+ ["ctrl-d/u", "page"],
421
+ ["g/G", "top/bottom"],
422
+ ["esc/h", "files"],
423
+ ],
424
+ width,
425
+ ),
426
+ );
394
427
  return lines;
395
428
  }
396
429
 
@@ -0,0 +1,330 @@
1
+ /**
2
+ * git-read — read-only git inspection tools for pi.
3
+ *
4
+ * Registers git_show, git_diff, and git_log: the minimal surface a review or
5
+ * exploration agent needs from git history without any write path. All three
6
+ * share the file-search output discipline (bounded preview, complete output
7
+ * persisted up to 10 MiB) and every user-controlled argument is validated
8
+ * before it reaches a child process, so a revision or path can never inject
9
+ * flags or escape the repository. The tools are child-safe by design and are
10
+ * classified in CHILD_SAFE_PACKAGE_TOOL_NAMES so review/advisor subagents can
11
+ * read diffs their tool boundary otherwise excludes (issue #61).
12
+ */
13
+
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import { StringEnum } from "@earendil-works/pi-ai";
17
+ import type {
18
+ AgentToolResult,
19
+ ExtensionAPI,
20
+ ExtensionContext,
21
+ } from "@earendil-works/pi-coding-agent";
22
+ import { Text } from "@earendil-works/pi-tui";
23
+ import { Cause, Effect, Exit } from "effect";
24
+ import { type Static, Type } from "typebox";
25
+ import { formatCapturedOutput } from "../file-search/src/output.ts";
26
+ import { sanitizeTerminalText } from "../shared/terminal-text.ts";
27
+ import {
28
+ OPENPI_TOOL_SURFACE,
29
+ patchOwnedTools,
30
+ } from "../shared/tool-surface.ts";
31
+ import {
32
+ buildDiffArgs,
33
+ buildLogArgs,
34
+ buildShowArgs,
35
+ type GitDiffParams,
36
+ type GitLogParams,
37
+ type GitShowParams,
38
+ InvalidPathError,
39
+ InvalidRevisionError,
40
+ } from "./src/args.ts";
41
+ import { type GitOutcome, runGit } from "./src/process.ts";
42
+ import {
43
+ GIT_DIFF_PARAMETER_DESCRIPTIONS,
44
+ GIT_DIFF_PROMPT_GUIDELINES,
45
+ GIT_DIFF_PROMPT_SNIPPET,
46
+ GIT_DIFF_TOOL_DESCRIPTION,
47
+ GIT_LOG_PARAMETER_DESCRIPTIONS,
48
+ GIT_LOG_PROMPT_GUIDELINES,
49
+ GIT_LOG_PROMPT_SNIPPET,
50
+ GIT_LOG_TOOL_DESCRIPTION,
51
+ GIT_SHOW_PARAMETER_DESCRIPTIONS,
52
+ GIT_SHOW_PROMPT_GUIDELINES,
53
+ GIT_SHOW_PROMPT_SNIPPET,
54
+ GIT_SHOW_TOOL_DESCRIPTION,
55
+ } from "./src/prompt.ts";
56
+
57
+ interface GitToolDetails {
58
+ readonly command: string;
59
+ readonly truncated: boolean;
60
+ readonly lineCount?: number;
61
+ readonly fullOutputPath?: string;
62
+ }
63
+
64
+ function causeMessage(cause: unknown): string {
65
+ if (
66
+ cause instanceof InvalidRevisionError ||
67
+ cause instanceof InvalidPathError
68
+ ) {
69
+ return cause.message;
70
+ }
71
+ if (cause instanceof Error) return cause.message;
72
+ return String(cause);
73
+ }
74
+
75
+ function unwrapToolExit<A, E>(exit: Exit.Exit<A, E>, tool: string) {
76
+ if (Exit.isSuccess(exit)) return exit.value;
77
+ if (Cause.hasInterruptsOnly(exit.cause)) {
78
+ throw new Error(`${tool} was cancelled.`);
79
+ }
80
+ throw new Error(causeMessage(Cause.squash(exit.cause)));
81
+ }
82
+
83
+ function gitResult(
84
+ outcome: GitOutcome,
85
+ command: string,
86
+ ): AgentToolResult<GitToolDetails> {
87
+ const formatted = formatCapturedOutput(outcome.output);
88
+ return {
89
+ content: [
90
+ {
91
+ type: "text",
92
+ text: sanitizeTerminalText(formatted.text) || "(no output)",
93
+ },
94
+ ],
95
+ details: {
96
+ command,
97
+ truncated: formatted.truncated,
98
+ lineCount: formatted.lineCount,
99
+ fullOutputPath: formatted.fullOutputPath,
100
+ },
101
+ };
102
+ }
103
+
104
+ function showParameters() {
105
+ return Type.Object({
106
+ revision: Type.String({
107
+ description: GIT_SHOW_PARAMETER_DESCRIPTIONS.revision,
108
+ }),
109
+ path: Type.Optional(
110
+ Type.String({ description: GIT_SHOW_PARAMETER_DESCRIPTIONS.path }),
111
+ ),
112
+ });
113
+ }
114
+
115
+ function diffParameters() {
116
+ return Type.Object({
117
+ from: Type.Optional(
118
+ Type.String({ description: GIT_DIFF_PARAMETER_DESCRIPTIONS.from }),
119
+ ),
120
+ to: Type.Optional(
121
+ Type.String({ description: GIT_DIFF_PARAMETER_DESCRIPTIONS.to }),
122
+ ),
123
+ staged: Type.Optional(
124
+ Type.Boolean({ description: GIT_DIFF_PARAMETER_DESCRIPTIONS.staged }),
125
+ ),
126
+ stat: Type.Optional(
127
+ Type.Boolean({ description: GIT_DIFF_PARAMETER_DESCRIPTIONS.stat }),
128
+ ),
129
+ path: Type.Optional(
130
+ Type.String({ description: GIT_DIFF_PARAMETER_DESCRIPTIONS.path }),
131
+ ),
132
+ });
133
+ }
134
+
135
+ function logParameters() {
136
+ return Type.Object({
137
+ revision: Type.Optional(
138
+ Type.String({ description: GIT_LOG_PARAMETER_DESCRIPTIONS.revision }),
139
+ ),
140
+ file: Type.Optional(
141
+ Type.String({ description: GIT_LOG_PARAMETER_DESCRIPTIONS.file }),
142
+ ),
143
+ limit: Type.Optional(
144
+ Type.Integer({
145
+ description: GIT_LOG_PARAMETER_DESCRIPTIONS.limit,
146
+ minimum: 1,
147
+ maximum: 1000,
148
+ }),
149
+ ),
150
+ oneline: Type.Optional(
151
+ Type.Boolean({ description: GIT_LOG_PARAMETER_DESCRIPTIONS.oneline }),
152
+ ),
153
+ });
154
+ }
155
+
156
+ function displayRevision(value: string | undefined, fallback = "HEAD") {
157
+ return value === undefined || value.trim() === "" ? fallback : value.trim();
158
+ }
159
+
160
+ export default function gitReadTools(pi: ExtensionAPI) {
161
+ const resultDirectories = new Set<string>();
162
+ const rememberOutput = (outcome: GitOutcome) => {
163
+ if (outcome.output.fullOutputPath) {
164
+ resultDirectories.add(path.dirname(outcome.output.fullOutputPath));
165
+ }
166
+ };
167
+
168
+ pi.on("session_start", () => {
169
+ patchOwnedTools(pi, "gitRead", {
170
+ enable: OPENPI_TOOL_SURFACE.gitRead.entry,
171
+ });
172
+ });
173
+
174
+ pi.on("session_shutdown", () => {
175
+ for (const directory of resultDirectories) {
176
+ try {
177
+ fs.rmSync(directory, { recursive: true, force: true });
178
+ } catch {
179
+ // Temporary git artifacts are best-effort cleanup.
180
+ }
181
+ }
182
+ resultDirectories.clear();
183
+ });
184
+
185
+ pi.registerTool<ReturnType<typeof showParameters>, GitToolDetails>({
186
+ name: "git_show",
187
+ label: "Git Show",
188
+ description: GIT_SHOW_TOOL_DESCRIPTION,
189
+ promptSnippet: GIT_SHOW_PROMPT_SNIPPET,
190
+ promptGuidelines: GIT_SHOW_PROMPT_GUIDELINES,
191
+ parameters: showParameters(),
192
+
193
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
194
+ const exit = await Effect.runPromiseExit(
195
+ Effect.gen(function* () {
196
+ const args = buildShowArgs(params as GitShowParams);
197
+ const outcome = yield* runGit(args, ctx.cwd);
198
+ rememberOutput(outcome);
199
+ return gitResult(outcome, args.join(" "));
200
+ }),
201
+ signal ? { signal } : undefined,
202
+ );
203
+ return unwrapToolExit(exit, "git_show");
204
+ },
205
+
206
+ renderCall(args) {
207
+ return new Text(`git show ${displayRevision(args.revision)}`, 0, 0);
208
+ },
209
+
210
+ renderResult(result, { expanded }, theme) {
211
+ const details = result.details;
212
+ let text = details?.command
213
+ ? `showed ${details.lineCount ?? 0} lines`
214
+ : "shown";
215
+ if (details?.truncated) text += " (truncated)";
216
+ if (expanded)
217
+ text += expandedResultPreview(result, details?.fullOutputPath, theme);
218
+ return new Text(text, 0, 0);
219
+ },
220
+ });
221
+
222
+ pi.registerTool<ReturnType<typeof diffParameters>, GitToolDetails>({
223
+ name: "git_diff",
224
+ label: "Git Diff",
225
+ description: GIT_DIFF_TOOL_DESCRIPTION,
226
+ promptSnippet: GIT_DIFF_PROMPT_SNIPPET,
227
+ promptGuidelines: GIT_DIFF_PROMPT_GUIDELINES,
228
+ parameters: diffParameters(),
229
+
230
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
231
+ const exit = await Effect.runPromiseExit(
232
+ Effect.gen(function* () {
233
+ const args = buildDiffArgs(params as GitDiffParams);
234
+ const outcome = yield* runGit(args, ctx.cwd);
235
+ rememberOutput(outcome);
236
+ return gitResult(outcome, args.join(" "));
237
+ }),
238
+ signal ? { signal } : undefined,
239
+ );
240
+ return unwrapToolExit(exit, "git_diff");
241
+ },
242
+
243
+ renderCall(args) {
244
+ const from = displayRevision(
245
+ args.from,
246
+ args.staged ? "HEAD (staged)" : "index",
247
+ );
248
+ const to = args.to ? displayRevision(args.to) : "worktree";
249
+ let text = `git diff ${from} → ${to}`;
250
+ if (args.stat) text += " (stat)";
251
+ if (args.path) text += ` ${args.path}`;
252
+ return new Text(text, 0, 0);
253
+ },
254
+
255
+ renderResult(result, { expanded }, theme) {
256
+ const details = result.details;
257
+ let text = details?.command
258
+ ? `${details.lineCount ?? 0} diff lines`
259
+ : "diffed";
260
+ if (details?.truncated) text += " (truncated)";
261
+ if (expanded)
262
+ text += expandedResultPreview(result, details?.fullOutputPath, theme);
263
+ return new Text(text, 0, 0);
264
+ },
265
+ });
266
+
267
+ pi.registerTool<ReturnType<typeof logParameters>, GitToolDetails>({
268
+ name: "git_log",
269
+ label: "Git Log",
270
+ description: GIT_LOG_TOOL_DESCRIPTION,
271
+ promptSnippet: GIT_LOG_PROMPT_SNIPPET,
272
+ promptGuidelines: GIT_LOG_PROMPT_GUIDELINES,
273
+ parameters: logParameters(),
274
+
275
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
276
+ const exit = await Effect.runPromiseExit(
277
+ Effect.gen(function* () {
278
+ const args = buildLogArgs(params as GitLogParams);
279
+ const outcome = yield* runGit(args, ctx.cwd);
280
+ rememberOutput(outcome);
281
+ return gitResult(outcome, args.join(" "));
282
+ }),
283
+ signal ? { signal } : undefined,
284
+ );
285
+ return unwrapToolExit(exit, "git_log");
286
+ },
287
+
288
+ renderCall(args) {
289
+ let text = `git log ${displayRevision(args.revision)}`;
290
+ if (args.file) text += ` -- ${args.file}`;
291
+ if (args.limit !== undefined) text += ` -n ${args.limit}`;
292
+ return new Text(text, 0, 0);
293
+ },
294
+
295
+ renderResult(result, { expanded }, theme) {
296
+ const details = result.details;
297
+ let text = details?.command
298
+ ? `${details.lineCount ?? 0} output lines`
299
+ : "logged";
300
+ if (details?.truncated) text += " (truncated)";
301
+ if (expanded)
302
+ text += expandedResultPreview(result, details?.fullOutputPath, theme);
303
+ return new Text(text, 0, 0);
304
+ },
305
+ });
306
+ }
307
+
308
+ const EXPANDED_PREVIEW_LINES = 20;
309
+
310
+ export function expandedResultPreview(
311
+ result: { content: { type: string; text?: string }[] },
312
+ fullOutputPath: string | undefined,
313
+ theme: { fg(color: string, text: string): string },
314
+ ) {
315
+ let text = "";
316
+ const content = result.content[0];
317
+ if (content?.type === "text" && content.text) {
318
+ const lines = sanitizeTerminalText(content.text).split("\n");
319
+ for (const line of lines.slice(0, EXPANDED_PREVIEW_LINES)) {
320
+ text += `\n${theme.fg("dim", line)}`;
321
+ }
322
+ if (lines.length > EXPANDED_PREVIEW_LINES) {
323
+ text += `\n${theme.fg("muted", `... ${lines.length - EXPANDED_PREVIEW_LINES} more lines`)}`;
324
+ }
325
+ }
326
+ if (fullOutputPath) {
327
+ text += `\n${theme.fg("dim", `Full output: ${sanitizeTerminalText(fullOutputPath)}`)}`;
328
+ }
329
+ return text;
330
+ }