pi-shorthand 0.11.0 → 0.13.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.
package/README.md CHANGED
@@ -40,6 +40,8 @@ Programs edit a private workspace, with host files outside the repository kept r
40
40
 
41
41
  The `code` tool uses Pi's working directory by default. Pass `cwd` to target another checkout; relative paths are resolved from Pi's working directory. For example, when Pi starts in a bare worktree container, `cwd: "child"` targets its `child` worktree. The chosen directory must be inside a Git worktree.
42
42
 
43
+ Scripts run by Pi's `codemode` tool can call `code` too. They get back what the run changed as data, not text.
44
+
43
45
  ## Without Pi
44
46
 
45
47
  The engine is published separately as [shorthand-code](https://github.com/sebinsua/shortsight/tree/main/packages/shorthand-code), with a `shorthand` command that runs a program from a file or stdin and prints the same result. See `shorthand --help`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-shorthand",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Pi tool for editing repositories with Bun programs, with first-class support for TypeScript and JavaScript.",
5
5
  "keywords": [
6
6
  "ast-grep",
@@ -26,7 +26,7 @@
26
26
  ],
27
27
  "type": "module",
28
28
  "dependencies": {
29
- "shorthand-code": "0.11.0"
29
+ "shorthand-code": "0.13.0"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "@earendil-works/pi-ai": "*",
package/src/display.ts CHANGED
@@ -25,8 +25,7 @@ const ANSI_CUBE_VALUES = [0, 95, 135, 175, 215, 255];
25
25
  const ANSI_GRAY_VALUES = Array.from({ length: 24 }, (_, index) => 8 + index * 10);
26
26
 
27
27
  const OUTPUT_PREVIEW_LINES = 5; // like Pi's bash tool
28
- const INLINE_DIFF_LINES = 40; // a longer diff collapses to a list of its files…
29
- const LISTED_FILES = 8; // …showing this many, then "and N more files"
28
+ const LISTED_FILES = 8; // a diff too tall for the screen lists this many files, then "and N more files"
30
29
  const EXPANDED_DIFF_LINES = 2000; // even expanded, a diff of hundreds of files stops here
31
30
 
32
31
  export function callLine(
@@ -60,7 +59,8 @@ function indent(line: string): string {
60
59
  return ` ${line}`;
61
60
  }
62
61
 
63
- export function resultLines(run: RunResult, expanded: boolean, theme: Theme): string[] {
62
+ /** `collapseDiff` swaps the applied diff for a list of its files; see FitsScreen for when. */
63
+ export function resultLines(run: RunResult, expanded: boolean, theme: Theme, collapseDiff = false): string[] {
64
64
  const applied = run.changes.filter((change) => run.applied.includes(change.path));
65
65
  const notApplied = run.changes.filter(
66
66
  (change) => !run.applied.includes(change.path) && !run.rolledBack.includes(change.path),
@@ -95,7 +95,7 @@ export function resultLines(run: RunResult, expanded: boolean, theme: Theme): st
95
95
  const sections: string[][] = [];
96
96
  const background: ToolBackground =
97
97
  run.exitCode === 0 && run.conflicts.length === 0 && run.rolledBack.length === 0 ? "toolSuccessBg" : "toolErrorBg";
98
- if (applied.length > 0) sections.push(diffLines(applied, expanded, theme, background));
98
+ if (applied.length > 0) sections.push(diffLines(applied, collapseDiff && !expanded, theme, background));
99
99
  if (output && (expanded || !error)) sections.push(outputLines(output, expanded, run.changes.length > 0, theme));
100
100
  if (notApplied.length > 0) sections.push(notAppliedLines(notApplied, expanded, theme, background));
101
101
  for (const section of sections) lines.push("", ...section);
@@ -157,14 +157,12 @@ function outputLines(output: string, expanded: boolean, labelled: boolean, theme
157
157
  return [...label, hint, ...lines.slice(-OUTPUT_PREVIEW_LINES)];
158
158
  }
159
159
 
160
- /** Each file's diff under its name, in Pi's own diff style. A long diff collapses to a list of files. */
161
- function diffLines(changes: FileChange[], expanded: boolean, theme: Theme, background: ToolBackground): string[] {
162
- const files = changes.map((change) => [fileLine(change, theme), ...renderFileDiff(change, theme, background)]);
163
- const total = files.reduce((sum, file) => sum + file.length, 0);
164
- if (!expanded && total > INLINE_DIFF_LINES) {
160
+ /** Each file's diff under its name, in Pi's own diff style, or when collapsed a list of the files. */
161
+ function diffLines(changes: FileChange[], collapse: boolean, theme: Theme, background: ToolBackground): string[] {
162
+ if (collapse) {
165
163
  return [...fileList(changes, theme), theme.fg("muted", `(${keyHint("app.tools.expand", "to see the diff")})`)];
166
164
  }
167
-
165
+ const files = changes.map((change) => [fileLine(change, theme), ...renderFileDiff(change, theme, background)]);
168
166
  const lines = files.flatMap((file, index) => (index === 0 ? file : ["", ...file]));
169
167
  if (lines.length <= EXPANDED_DIFF_LINES) return lines;
170
168
  const more = lines.length - EXPANDED_DIFF_LINES;
@@ -316,7 +314,7 @@ function colorDistance(first: number[], second: number[]): number {
316
314
 
317
315
  function notAppliedLines(changes: FileChange[], expanded: boolean, theme: Theme, background: ToolBackground): string[] {
318
316
  const heading = theme.fg("muted", `Would have changed ${fileCount(changes)} · `) + stats(changes, theme);
319
- if (expanded) return [heading, "", ...diffLines(changes, true, theme, background)];
317
+ if (expanded) return [heading, "", ...diffLines(changes, false, theme, background)];
320
318
  return [heading + theme.fg("muted", ` (${keyHint("app.tools.expand", "to see the diff")})`)];
321
319
  }
322
320
 
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@ import { createRequire } from "node:module";
7
7
  import * as path from "node:path";
8
8
  import { StringEnum } from "@earendil-works/pi-ai";
9
9
  import { type ExtensionAPI, type Theme, truncateHead, truncateTail } from "@earendil-works/pi-coding-agent";
10
- import { Text } from "@earendil-works/pi-tui";
10
+ import { type Component, Text } from "@earendil-works/pi-tui";
11
11
  import { Type } from "typebox";
12
12
  import {
13
13
  DEFAULT_TIMEOUT_SECONDS,
@@ -22,7 +22,7 @@ import {
22
22
  } from "shorthand-code";
23
23
  import { callLine, resultLines, unstructuredResultText } from "./display.ts";
24
24
 
25
- const DESCRIPTION = `Edit repository files with a TypeScript program run by Bun. Best for changes across many files, repeated edits and semantic TypeScript renames or moves; a small change to one file is quicker as a direct edit. Top-level await and ordinary Bun/Node APIs work. Use repository-relative paths. Set cwd to a checkout path when Pi's working directory is outside the repository, such as a child worktree in a bare worktree container. The program runs in an isolated workspace; changes apply on successful exit by default and the tool reports the diff. Run tests, type-checks and builds separately afterward with the shell tool.
25
+ const DESCRIPTION = `Edit repository files with a TypeScript program run by Bun. Best for changes across many files, repeated edits and semantic TypeScript renames or moves; a small change to one file is quicker as a direct edit. Top-level await and ordinary Bun/Node APIs work. Use repository-relative paths. Set cwd to a checkout path when Pi's working directory is outside the repository, such as a child worktree in a bare worktree container. The program runs in an isolated workspace; changes apply on successful exit by default and the tool reports the diff. Tests, type-checks and builds belong outside the program: in a shell call, or, when a codemode script calls code, later in that script.
26
26
 
27
27
  Common operations:
28
28
  - edit({ path, oldText, newText }) replaces exactly one literal occurrence; missing or ambiguous text is an error. Use text edits for known source, structural matching when it saves enumerating occurrences or preserves varying syntax.
@@ -35,7 +35,7 @@ Common operations:
35
35
  - await refactor.renameFile({ from, to }) moves a TypeScript file and updates module paths that resolve to it.
36
36
  - await refactor.move({ file, symbol, to }) moves a top-level declaration to another file and updates the imports that follow it.
37
37
 
38
- See the shorthand skill for renames, moves and call-site migrations. Read its advanced-refactors.md guide only to extract code, move syntax or use rule objects. The default timeout is two seconds; request more for longer programs. Time spent inside the helpers above does not count toward it, up to 60 extra seconds.`;
38
+ See the shorthand skill for renames, moves and call-site migrations. Read its advanced-refactors.md guide only to extract code, move syntax or use rule objects. The default timeout is five seconds; request more for longer programs. Time spent inside the helpers above does not count toward it, up to 60 extra seconds.`;
39
39
 
40
40
  const GRAPH_DESCRIPTION =
41
41
  "- await graph.query(request | request[]) queries symbols and relationships; graph results can be passed to sg as scope. The graph shows the repository before this program's edits.\n";
@@ -51,6 +51,59 @@ export function codeDescription(graphAvailable: boolean): string {
51
51
  : DESCRIPTION;
52
52
  }
53
53
 
54
+ const paths = (description: string) => Type.Array(Type.String(), { description });
55
+
56
+ /**
57
+ * What a script gets back from `code`, for example one run by Pi's codemode tool. The model still sees the text.
58
+ * A failed run resolves to this too, so a script checks `exitCode`.
59
+ */
60
+ export const CODE_OUTPUT_SCHEMA = Type.Object({
61
+ exitCode: Type.Optional(
62
+ Type.Union([Type.Number(), Type.Null()], { description: "0 on success; null when the program was killed" }),
63
+ ),
64
+ timedOut: Type.Optional(Type.Boolean()),
65
+ output: Type.Optional(Type.String({ description: "The program's stdout and stderr" })),
66
+ errorLine: Type.Optional(Type.String({ description: "On failure, the program line the error came from" })),
67
+ warnings: Type.Optional(Type.Array(Type.String(), { description: "Likely mistakes spotted before it ran" })),
68
+ changes: Type.Optional(
69
+ Type.Array(
70
+ Type.Object({
71
+ path: Type.String(),
72
+ kind: StringEnum(["added", "modified", "deleted"] as const),
73
+ patch: Type.String(),
74
+ }),
75
+ ),
76
+ ),
77
+ applied: Type.Optional(paths("Changed files written to the repository")),
78
+ conflicts: Type.Optional(paths("Files that changed outside the run while it ran; if any, nothing was applied")),
79
+ rolledBack: Type.Optional(paths("Changed files not kept after a failure")),
80
+ infrastructureError: Type.Optional(Type.String({ description: "Set, alone, when the program couldn't be run" })),
81
+ });
82
+
83
+ export function codeOutput(result: RunResult) {
84
+ const { exitCode, timedOut, output, errorLine, warnings, applied, conflicts, rolledBack } = result;
85
+ const changes = result.changes.map((change) => ({ path: change.path, kind: change.kind, patch: change.patch }));
86
+ return {
87
+ exitCode,
88
+ timedOut,
89
+ output,
90
+ ...(errorLine === undefined ? {} : { errorLine }),
91
+ warnings,
92
+ changes,
93
+ applied,
94
+ conflicts,
95
+ rolledBack,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Base name of the temporary files that hold a large run's full diff or output. A call a codemode script makes has
101
+ * an id like `<parent id>/1`, and ids can hold `|`, so anything but a safe file-name character is replaced.
102
+ */
103
+ export function spillName(toolCallId: string): string {
104
+ return `pi-shorthand-${toolCallId.replace(/[^\w.-]/g, "_")}`;
105
+ }
106
+
54
107
  export default async function (pi: ExtensionAPI, findGraph: () => Promise<unknown> = sightreadAvailable) {
55
108
  // The shorthand skill ships with shorthand-code, whose `shorthand --skill` prints it for other agents.
56
109
  pi.on("resources_discover", () => ({ skillPaths: [SKILLS_DIRECTORY] }));
@@ -67,7 +120,7 @@ export default async function (pi: ExtensionAPI, findGraph: () => Promise<unknow
67
120
  label: "Code",
68
121
  description: codeDescription(graphAvailable),
69
122
  promptSnippet:
70
- "Make multi-file, repetitive or rename/move changes with one Bun program; a small change to one file is quicker as a direct edit. Run verification separately afterward",
123
+ "Make multi-file, repetitive or rename/move changes with one Bun program; a small change to one file is quicker as a direct edit. Keep verification outside the program",
71
124
 
72
125
  parameters: Type.Object({
73
126
  title: Type.String({ description: "A few words describing the change, shown to the user" }),
@@ -90,6 +143,7 @@ export default async function (pi: ExtensionAPI, findGraph: () => Promise<unknow
90
143
  }),
91
144
  ),
92
145
  }),
146
+ outputSchema: CODE_OUTPUT_SCHEMA,
93
147
 
94
148
  async execute(toolCallId, params, signal, onUpdate, ctx) {
95
149
  // While it runs, show how long it's been going and the latest reported step.
@@ -122,6 +176,7 @@ export default async function (pi: ExtensionAPI, findGraph: () => Promise<unknow
122
176
  isError: true,
123
177
  content: [{ type: "text" as const, text: [error.message, ...diagnosticLines(error.diagnostics)].join("\n") }],
124
178
  details: { infrastructureError: error.message, diagnostics: error.diagnostics },
179
+ structuredContent: { infrastructureError: error.message },
125
180
  };
126
181
  } finally {
127
182
  clearInterval(progress);
@@ -130,10 +185,11 @@ export default async function (pi: ExtensionAPI, findGraph: () => Promise<unknow
130
185
  content: [
131
186
  {
132
187
  type: "text",
133
- text: textForModel(result, { name: `pi-shorthand-${toolCallId}`, truncateHead, truncateTail }),
188
+ text: textForModel(result, { name: spillName(toolCallId), truncateHead, truncateTail }),
134
189
  },
135
190
  ],
136
191
  details: result,
192
+ structuredContent: codeOutput(result),
137
193
  };
138
194
  },
139
195
 
@@ -151,7 +207,7 @@ export function renderCodeResult(
151
207
  result: { content: readonly unknown[]; details?: unknown },
152
208
  { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
153
209
  theme: Theme,
154
- ): Text {
210
+ ): Component {
155
211
  if (isPartial) {
156
212
  const progress = (result.details as { progress?: string } | undefined)?.progress;
157
213
  return new Text(theme.fg("muted", progress ? `running… ${progress}` : "running…"), 0, 0);
@@ -170,5 +226,32 @@ export function renderCodeResult(
170
226
  }
171
227
  const run = result.details as RunResult | undefined;
172
228
  if (!run) return new Text(theme.fg("error", unstructuredResultText(result.content)), 0, 0);
173
- return new Text(resultLines(run, expanded, theme).join("\n"), 0, 0);
229
+ const full = new Text(resultLines(run, expanded, theme).join("\n"), 0, 0);
230
+ if (expanded) return full;
231
+ return new FitsScreen(full, new Text(resultLines(run, false, theme, true).join("\n"), 0, 0));
232
+ }
233
+
234
+ /** Rows Pi keeps for itself below a tool result: the editor, its borders and the footer. */
235
+ const PI_CHROME_ROWS = 8;
236
+
237
+ /**
238
+ * Shows the whole result while it fits on screen once wrapped, like Pi's edit tool, and otherwise the version
239
+ * with the diff collapsed to a list of files. Measured on each render, so resizing the terminal is followed.
240
+ */
241
+ export class FitsScreen implements Component {
242
+ constructor(
243
+ private readonly full: Component,
244
+ private readonly collapsed: Component,
245
+ private readonly rows = () => process.stdout.rows || 24,
246
+ ) {}
247
+
248
+ render(width: number): string[] {
249
+ const lines = this.full.render(width);
250
+ return lines.length <= this.rows() - PI_CHROME_ROWS ? lines : this.collapsed.render(width);
251
+ }
252
+
253
+ invalidate(): void {
254
+ this.full.invalidate();
255
+ this.collapsed.invalidate();
256
+ }
174
257
  }