@oh-my-pi/pi-coding-agent 15.9.5 → 15.10.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 (192) hide show
  1. package/CHANGELOG.md +98 -1
  2. package/dist/types/cli/args.d.ts +1 -1
  3. package/dist/types/cli/gallery-cli.d.ts +43 -0
  4. package/dist/types/cli/gallery-fixtures/agentic.d.ts +2 -0
  5. package/dist/types/cli/gallery-fixtures/codeintel.d.ts +3 -0
  6. package/dist/types/cli/gallery-fixtures/edit.d.ts +3 -0
  7. package/dist/types/cli/gallery-fixtures/fs.d.ts +2 -0
  8. package/dist/types/cli/gallery-fixtures/index.d.ts +4 -0
  9. package/dist/types/cli/gallery-fixtures/interaction.d.ts +3 -0
  10. package/dist/types/cli/gallery-fixtures/memory.d.ts +2 -0
  11. package/dist/types/cli/gallery-fixtures/misc.d.ts +3 -0
  12. package/dist/types/cli/gallery-fixtures/search.d.ts +3 -0
  13. package/dist/types/cli/gallery-fixtures/shell.d.ts +3 -0
  14. package/dist/types/cli/gallery-fixtures/types.d.ts +44 -0
  15. package/dist/types/cli/gallery-fixtures/web.d.ts +2 -0
  16. package/dist/types/cli/gallery-screenshot.d.ts +35 -0
  17. package/dist/types/commands/gallery.d.ts +47 -0
  18. package/dist/types/config/keybindings.d.ts +10 -2
  19. package/dist/types/config/model-id-affixes.d.ts +2 -0
  20. package/dist/types/config/model-registry.d.ts +8 -1
  21. package/dist/types/config/settings-schema.d.ts +43 -7
  22. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  23. package/dist/types/eval/backend.d.ts +6 -6
  24. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  25. package/dist/types/eval/idle-timeout.d.ts +16 -14
  26. package/dist/types/eval/js/executor.d.ts +3 -3
  27. package/dist/types/eval/py/executor.d.ts +2 -2
  28. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  29. package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
  30. package/dist/types/lsp/types.d.ts +10 -0
  31. package/dist/types/main.d.ts +3 -2
  32. package/dist/types/memory-backend/index.d.ts +2 -1
  33. package/dist/types/memory-backend/resolve.d.ts +1 -1
  34. package/dist/types/memory-backend/types.d.ts +1 -1
  35. package/dist/types/modes/components/assistant-message.d.ts +5 -0
  36. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  37. package/dist/types/modes/components/custom-editor.d.ts +2 -1
  38. package/dist/types/modes/components/model-selector.d.ts +1 -0
  39. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  40. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  42. package/dist/types/modes/index.d.ts +5 -4
  43. package/dist/types/modes/interactive-mode.d.ts +2 -2
  44. package/dist/types/modes/setup-version.d.ts +11 -0
  45. package/dist/types/modes/setup-wizard/index.d.ts +2 -1
  46. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
  47. package/dist/types/modes/types.d.ts +2 -2
  48. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  49. package/dist/types/sdk.d.ts +1 -1
  50. package/dist/types/task/executor.d.ts +7 -0
  51. package/dist/types/telemetry-export.d.ts +1 -1
  52. package/dist/types/tools/eval-render.d.ts +1 -0
  53. package/dist/types/tools/fetch.d.ts +15 -7
  54. package/dist/types/tools/render-utils.d.ts +33 -0
  55. package/dist/types/tools/renderers.d.ts +16 -2
  56. package/dist/types/tools/search.d.ts +1 -1
  57. package/dist/types/tools/write.d.ts +2 -0
  58. package/dist/types/tui/code-cell.d.ts +6 -0
  59. package/dist/types/tui/output-block.d.ts +11 -0
  60. package/dist/types/web/scrapers/github.d.ts +22 -0
  61. package/dist/types/web/search/providers/perplexity.d.ts +8 -1
  62. package/dist/types/web/search/types.d.ts +1 -1
  63. package/package.json +9 -9
  64. package/scripts/dev-launch +42 -0
  65. package/scripts/dev-launch-preload.ts +19 -0
  66. package/src/autoresearch/dashboard.ts +11 -21
  67. package/src/cli/args.ts +2 -2
  68. package/src/cli/claude-trace-cli.ts +13 -1
  69. package/src/cli/gallery-cli.ts +223 -0
  70. package/src/cli/gallery-fixtures/agentic.ts +292 -0
  71. package/src/cli/gallery-fixtures/codeintel.ts +188 -0
  72. package/src/cli/gallery-fixtures/edit.ts +194 -0
  73. package/src/cli/gallery-fixtures/fs.ts +153 -0
  74. package/src/cli/gallery-fixtures/index.ts +40 -0
  75. package/src/cli/gallery-fixtures/interaction.ts +49 -0
  76. package/src/cli/gallery-fixtures/memory.ts +81 -0
  77. package/src/cli/gallery-fixtures/misc.ts +221 -0
  78. package/src/cli/gallery-fixtures/search.ts +213 -0
  79. package/src/cli/gallery-fixtures/shell.ts +167 -0
  80. package/src/cli/gallery-fixtures/types.ts +41 -0
  81. package/src/cli/gallery-fixtures/web.ts +158 -0
  82. package/src/cli/gallery-screenshot.ts +279 -0
  83. package/src/cli-commands.ts +1 -0
  84. package/src/commands/gallery.ts +52 -0
  85. package/src/commands/launch.ts +1 -1
  86. package/src/config/keybindings.ts +68 -2
  87. package/src/config/model-equivalence.ts +35 -12
  88. package/src/config/model-id-affixes.ts +39 -22
  89. package/src/config/model-registry.ts +16 -16
  90. package/src/config/settings-schema.ts +29 -6
  91. package/src/config/settings.ts +11 -0
  92. package/src/dap/client.ts +14 -16
  93. package/src/debug/raw-sse.ts +18 -4
  94. package/src/edit/file-snapshot-store.ts +1 -1
  95. package/src/edit/index.ts +1 -1
  96. package/src/edit/renderer.ts +43 -55
  97. package/src/edit/streaming.ts +1 -1
  98. package/src/eval/__tests__/agent-bridge.test.ts +102 -58
  99. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  100. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  101. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  102. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  103. package/src/eval/agent-bridge.ts +38 -12
  104. package/src/eval/backend.ts +6 -6
  105. package/src/eval/bridge-timeout.ts +44 -0
  106. package/src/eval/idle-timeout.ts +33 -15
  107. package/src/eval/js/executor.ts +10 -10
  108. package/src/eval/llm-bridge.ts +4 -5
  109. package/src/eval/py/executor.ts +6 -6
  110. package/src/eval/py/kernel.ts +11 -1
  111. package/src/eval/py/spawn-options.ts +126 -0
  112. package/src/export/ttsr.ts +9 -0
  113. package/src/extensibility/extensions/runner.ts +3 -0
  114. package/src/extensibility/plugins/doctor.ts +0 -1
  115. package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
  116. package/src/goals/tools/goal-tool.ts +2 -2
  117. package/src/internal-urls/docs-index.generated.ts +7 -6
  118. package/src/lsp/client.ts +179 -52
  119. package/src/lsp/index.ts +38 -4
  120. package/src/lsp/render.ts +3 -3
  121. package/src/lsp/types.ts +10 -0
  122. package/src/main.ts +47 -52
  123. package/src/memory-backend/index.ts +13 -1
  124. package/src/memory-backend/resolve.ts +3 -5
  125. package/src/memory-backend/types.ts +1 -1
  126. package/src/modes/components/agent-dashboard.ts +13 -4
  127. package/src/modes/components/assistant-message.ts +22 -1
  128. package/src/modes/components/copy-selector.ts +249 -0
  129. package/src/modes/components/custom-editor.ts +10 -1
  130. package/src/modes/components/extensions/extension-list.ts +17 -8
  131. package/src/modes/components/history-search.ts +19 -11
  132. package/src/modes/components/model-selector.ts +125 -29
  133. package/src/modes/components/oauth-selector.ts +28 -12
  134. package/src/modes/components/session-observer-overlay.ts +13 -15
  135. package/src/modes/components/session-selector.ts +24 -13
  136. package/src/modes/components/status-line.ts +3 -5
  137. package/src/modes/components/tool-execution.ts +83 -24
  138. package/src/modes/components/tree-selector.ts +19 -7
  139. package/src/modes/components/user-message-selector.ts +25 -14
  140. package/src/modes/controllers/command-controller.ts +13 -118
  141. package/src/modes/controllers/event-controller.ts +26 -10
  142. package/src/modes/controllers/input-controller.ts +11 -3
  143. package/src/modes/controllers/selector-controller.ts +40 -3
  144. package/src/modes/index.ts +5 -4
  145. package/src/modes/interactive-mode.ts +21 -7
  146. package/src/modes/setup-version.ts +11 -0
  147. package/src/modes/setup-wizard/index.ts +3 -2
  148. package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
  149. package/src/modes/theme/theme.ts +46 -10
  150. package/src/modes/types.ts +2 -2
  151. package/src/modes/utils/context-usage.ts +10 -6
  152. package/src/modes/utils/copy-targets.ts +254 -0
  153. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  154. package/src/prompts/tools/ast-edit.md +1 -1
  155. package/src/prompts/tools/ast-grep.md +1 -1
  156. package/src/prompts/tools/read.md +1 -1
  157. package/src/prompts/tools/search.md +1 -1
  158. package/src/sdk.ts +21 -23
  159. package/src/session/agent-session.ts +13 -9
  160. package/src/slash-commands/builtin-registry.ts +4 -12
  161. package/src/slash-commands/helpers/usage-report.ts +2 -0
  162. package/src/task/executor.ts +20 -2
  163. package/src/task/render.ts +37 -11
  164. package/src/telemetry-export.ts +25 -7
  165. package/src/tools/bash.ts +18 -8
  166. package/src/tools/browser/render.ts +5 -4
  167. package/src/tools/debug.ts +3 -3
  168. package/src/tools/eval-backends.ts +6 -17
  169. package/src/tools/eval-render.ts +28 -10
  170. package/src/tools/eval.ts +19 -23
  171. package/src/tools/fetch.ts +99 -89
  172. package/src/tools/read.ts +7 -7
  173. package/src/tools/render-utils.ts +63 -3
  174. package/src/tools/renderers.ts +16 -1
  175. package/src/tools/report-tool-issue.ts +1 -1
  176. package/src/tools/search.ts +173 -81
  177. package/src/tools/ssh.ts +21 -8
  178. package/src/tools/todo.ts +20 -7
  179. package/src/tools/write.ts +39 -9
  180. package/src/tui/code-cell.ts +19 -4
  181. package/src/tui/output-block.ts +14 -0
  182. package/src/web/scrapers/github.ts +255 -3
  183. package/src/web/scrapers/youtube.ts +3 -2
  184. package/src/web/search/providers/perplexity.ts +199 -51
  185. package/src/web/search/render.ts +42 -57
  186. package/src/web/search/types.ts +5 -1
  187. package/dist/types/eval/heartbeat.d.ts +0 -45
  188. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  189. package/src/eval/__tests__/shared-executors.test.ts +0 -609
  190. package/src/eval/heartbeat.ts +0 -74
  191. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
  192. /package/dist/types/eval/__tests__/{shared-executors.test.d.ts → kernel-spawn.test.d.ts} +0 -0
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Render `omp gallery` output to PNG screenshots via VHS.
3
+ *
4
+ * ANSI escapes are invisible to anything that can only read raw bytes (e.g.
5
+ * agents), so `--screenshot` drives the rendered gallery through a real virtual
6
+ * terminal (VHS + ttyd + ffmpeg) and writes the captured frame to disk. The
7
+ * gallery is pre-rendered to truecolor ANSI in this process — where the user's
8
+ * theme and symbol preset are correct — then `cat`'d inside VHS so the captured
9
+ * pixels match exactly what the live TUI would draw.
10
+ *
11
+ * VHS is a hard dependency of this path: if it is not installed we fail loudly
12
+ * rather than degrade to a lossy fallback.
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ import { $which } from "@oh-my-pi/pi-utils";
18
+ import { theme } from "../modes/theme/theme";
19
+ import type { GallerySection } from "./gallery-cli";
20
+
21
+ /** Nerd Font family so the gallery's icon glyphs (PUA) render instead of tofu. */
22
+ export const DEFAULT_SCREENSHOT_FONT = "JetBrainsMono Nerd Font";
23
+ export const DEFAULT_SCREENSHOT_FONT_SIZE = 18;
24
+
25
+ /** Inner padding (px) VHS leaves around the terminal grid. */
26
+ const PADDING = 14;
27
+ const LINE_HEIGHT = 1.0;
28
+ /**
29
+ * Upper-bound cell metrics relative to font size. Real monospace cells are
30
+ * smaller, so over-provisioning the canvas guarantees the gallery never
31
+ * soft-wraps (too few columns) or scrolls off the top (too few rows). The slack
32
+ * shows up only as a modest background margin, which is harmless for review.
33
+ */
34
+ const CELL_WIDTH_RATIO = 0.65;
35
+ const CELL_HEIGHT_RATIO = 1.5;
36
+ /** Keep each image well under headless-Chromium's tall-canvas limits. */
37
+ const MAX_IMAGE_HEIGHT_PX = 8000;
38
+
39
+ export interface GalleryScreenshotOptions {
40
+ /** Gallery render width in columns (matches the ANSI line width). */
41
+ width: number;
42
+ /** VHS `FontFamily`. */
43
+ font?: string;
44
+ /** VHS `FontSize`. */
45
+ fontSize?: number;
46
+ /**
47
+ * Output destination. When omitted, PNGs land in a fresh temp directory.
48
+ * With multiple images the path is suffixed (`name-01.png`, `name-02.png`).
49
+ */
50
+ out?: string;
51
+ }
52
+
53
+ /**
54
+ * Capture the gallery sections as one or more PNGs and return their absolute
55
+ * paths. Tall galleries are split across images so no single capture exceeds
56
+ * the terminal-canvas height limit.
57
+ */
58
+ export async function captureGalleryScreenshots(
59
+ sections: GallerySection[],
60
+ options: GalleryScreenshotOptions,
61
+ ): Promise<string[]> {
62
+ const vhs = $which("vhs");
63
+ if (!vhs) {
64
+ throw new Error(
65
+ "`omp gallery --screenshot` requires VHS, which is not installed. " +
66
+ "Install it (e.g. `brew install vhs`, or see https://github.com/charmbracelet/vhs) and retry.",
67
+ );
68
+ }
69
+
70
+ const font = options.font ?? DEFAULT_SCREENSHOT_FONT;
71
+ const fontSize = options.fontSize ?? DEFAULT_SCREENSHOT_FONT_SIZE;
72
+ const cellHeight = fontSize * Math.max(LINE_HEIGHT, 1) * CELL_HEIGHT_RATIO;
73
+ const cellWidth = fontSize * CELL_WIDTH_RATIO;
74
+ const rowBudget = Math.max(40, Math.floor((MAX_IMAGE_HEIGHT_PX - 2 * PADDING) / cellHeight) - 2);
75
+ const chunks = chunkGallerySections(sections, rowBudget);
76
+ const themeJson = buildVhsTheme();
77
+
78
+ const baseDir = options.out
79
+ ? path.dirname(path.resolve(options.out))
80
+ : fs.mkdtempSync(path.join(os.tmpdir(), "omp-gallery-"));
81
+ await fs.promises.mkdir(baseDir, { recursive: true });
82
+
83
+ const outPaths: string[] = [];
84
+ for (let i = 0; i < chunks.length; i++) {
85
+ if (chunks.length > 1) {
86
+ process.stderr.write(`Rendering gallery screenshot ${i + 1}/${chunks.length}…\n`);
87
+ }
88
+ const outPng = resolveScreenshotOutputPath(options.out, baseDir, i, chunks.length);
89
+ const lines = chunks[i].flatMap(section => section.lines);
90
+ await renderChunk({ vhs, lines, outPng, font, fontSize, cellWidth, cellHeight, width: options.width, themeJson });
91
+ outPaths.push(outPng);
92
+ }
93
+ return outPaths;
94
+ }
95
+
96
+ interface RenderChunkArgs {
97
+ vhs: string;
98
+ lines: string[];
99
+ outPng: string;
100
+ font: string;
101
+ fontSize: number;
102
+ cellWidth: number;
103
+ cellHeight: number;
104
+ width: number;
105
+ themeJson: string;
106
+ }
107
+
108
+ async function renderChunk(args: RenderChunkArgs): Promise<void> {
109
+ const rows = args.lines.length;
110
+ const widthPx = Math.ceil(args.width * args.cellWidth) + 2 * PADDING;
111
+ const heightPx = Math.ceil((rows + 2) * args.cellHeight) + 2 * PADDING;
112
+
113
+ const dir = path.dirname(args.outPng);
114
+ const stem = path.basename(args.outPng, path.extname(args.outPng));
115
+ const ansiPath = path.join(dir, `.${stem}.ansi`);
116
+ const tapePath = path.join(dir, `.${stem}.tape`);
117
+ const gifPath = path.join(dir, `.${stem}.gif`);
118
+
119
+ // CRLF so each gallery line is its own terminal row regardless of how the
120
+ // captured shell handles bare LF.
121
+ await Bun.write(ansiPath, `${args.lines.join("\r\n")}\r\n`);
122
+ await Bun.write(
123
+ tapePath,
124
+ buildTape({
125
+ gifPath,
126
+ outPng: args.outPng,
127
+ ansiPath,
128
+ widthPx,
129
+ heightPx,
130
+ font: args.font,
131
+ fontSize: args.fontSize,
132
+ themeJson: args.themeJson,
133
+ }),
134
+ );
135
+
136
+ try {
137
+ const result = await Bun.$`${args.vhs} ${tapePath}`.quiet().nothrow();
138
+ if (result.exitCode !== 0 || !(await Bun.file(args.outPng).exists())) {
139
+ const detail = result.stderr.toString().trim() || result.stdout.toString().trim();
140
+ throw new Error(`VHS failed to render the gallery screenshot${detail ? `: ${detail.slice(-600)}` : ""}`);
141
+ }
142
+ } finally {
143
+ await Promise.all([
144
+ fs.promises.rm(ansiPath, { force: true }),
145
+ fs.promises.rm(tapePath, { force: true }),
146
+ fs.promises.rm(gifPath, { force: true }),
147
+ ]);
148
+ }
149
+ }
150
+
151
+ interface TapeArgs {
152
+ gifPath: string;
153
+ outPng: string;
154
+ ansiPath: string;
155
+ widthPx: number;
156
+ heightPx: number;
157
+ font: string;
158
+ fontSize: number;
159
+ themeJson: string;
160
+ }
161
+
162
+ function buildTape(args: TapeArgs): string {
163
+ // `Output` (a throwaway GIF) is mandatory for VHS to record; the screenshot
164
+ // is captured from the final visible frame. Setup is hidden so the typed
165
+ // `cat` command and shell prompt never appear in the capture, and a trailing
166
+ // `sleep` keeps the shell from drawing a fresh prompt under the output.
167
+ const shellCommand = `clear; cat ${shellSingleQuote(args.ansiPath)}; sleep 120`;
168
+ return `${[
169
+ `Output ${JSON.stringify(args.gifPath)}`,
170
+ `Set Width ${args.widthPx}`,
171
+ `Set Height ${args.heightPx}`,
172
+ `Set FontFamily ${JSON.stringify(args.font)}`,
173
+ `Set FontSize ${args.fontSize}`,
174
+ `Set Padding ${PADDING}`,
175
+ `Set LineHeight ${LINE_HEIGHT}`,
176
+ `Set Theme ${args.themeJson}`,
177
+ "Hide",
178
+ `Type ${JSON.stringify(shellCommand)}`,
179
+ "Enter",
180
+ "Sleep 1.2s",
181
+ "Show",
182
+ "Sleep 400ms",
183
+ `Screenshot ${JSON.stringify(args.outPng)}`,
184
+ ].join("\n")}\n`;
185
+ }
186
+
187
+ /**
188
+ * Build the VHS terminal theme. Only background/foreground/cursor matter: the
189
+ * gallery emits truecolor (`38;2`/`48;2`) escapes, so the 16-color palette is
190
+ * never consulted — it is filler to satisfy VHS's theme schema.
191
+ */
192
+ function buildVhsTheme(): string {
193
+ const background = parseAnsiRgb(theme.getBgAnsi("statusLineBg")) ?? (theme.isLight ? "#ffffff" : "#1a1a1a");
194
+ const foreground = theme.isLight ? "#1a1a1a" : "#d4d4d4";
195
+ const selection = theme.isLight ? "#c8d6ff" : "#404862";
196
+ return JSON.stringify({
197
+ name: "omp-gallery",
198
+ background,
199
+ foreground,
200
+ cursor: foreground,
201
+ selection,
202
+ black: "#000000",
203
+ red: "#ff5555",
204
+ green: "#50fa7b",
205
+ yellow: "#f1fa8c",
206
+ blue: "#6272ff",
207
+ magenta: "#ff79c6",
208
+ cyan: "#8be9fd",
209
+ white: "#bfbfbf",
210
+ brightBlack: "#4d4d4d",
211
+ brightRed: "#ff6e6e",
212
+ brightGreen: "#69ff94",
213
+ brightYellow: "#ffffa5",
214
+ brightBlue: "#8aa0ff",
215
+ brightMagenta: "#ff92df",
216
+ brightCyan: "#a4ffff",
217
+ brightWhite: "#ffffff",
218
+ });
219
+ }
220
+
221
+ /** Extract `#rrggbb` from a truecolor SGR escape (`…38;2;r;g;b…` / `…48;2;…`). */
222
+ function parseAnsiRgb(ansi: string): string | undefined {
223
+ const match = /[34]8;2;(\d+);(\d+);(\d+)/.exec(ansi);
224
+ if (!match) return undefined;
225
+ const hex = (value: string) => Number(value).toString(16).padStart(2, "0");
226
+ return `#${hex(match[1])}${hex(match[2])}${hex(match[3])}`;
227
+ }
228
+
229
+ /** POSIX single-quote a path for embedding in the VHS shell command. */
230
+ function shellSingleQuote(value: string): string {
231
+ return `'${value.replace(/'/g, `'\\''`)}'`;
232
+ }
233
+
234
+ /**
235
+ * Resolve a chunk's PNG path. A single image keeps the bare name (or the exact
236
+ * `out`); multiple images gain a zero-padded `-NN` suffix so they sort and never
237
+ * collide.
238
+ */
239
+ export function resolveScreenshotOutputPath(
240
+ out: string | undefined,
241
+ baseDir: string,
242
+ index: number,
243
+ total: number,
244
+ ): string {
245
+ if (total === 1) {
246
+ return out ? path.resolve(out) : path.join(baseDir, "gallery.png");
247
+ }
248
+ const suffix = String(index + 1).padStart(2, "0");
249
+ if (out) {
250
+ const resolved = path.resolve(out);
251
+ const ext = path.extname(resolved) || ".png";
252
+ const stem = path.basename(resolved, ext);
253
+ return path.join(path.dirname(resolved), `${stem}-${suffix}${ext}`);
254
+ }
255
+ return path.join(baseDir, `gallery-${suffix}.png`);
256
+ }
257
+
258
+ /**
259
+ * Group whole tool sections into chunks that stay under `rowBudget` rows. A
260
+ * single section larger than the budget gets its own (taller) image rather than
261
+ * being split mid-renderer.
262
+ */
263
+ export function chunkGallerySections(sections: GallerySection[], rowBudget: number): GallerySection[][] {
264
+ const chunks: GallerySection[][] = [];
265
+ let current: GallerySection[] = [];
266
+ let currentRows = 0;
267
+ for (const section of sections) {
268
+ const rows = section.lines.length;
269
+ if (current.length > 0 && currentRows + rows > rowBudget) {
270
+ chunks.push(current);
271
+ current = [];
272
+ currentRows = 0;
273
+ }
274
+ current.push(section);
275
+ currentRows += rows;
276
+ }
277
+ if (current.length > 0) chunks.push(current);
278
+ return chunks.length > 0 ? chunks : [[]];
279
+ }
@@ -22,6 +22,7 @@ export const commands: CommandEntry[] = [
22
22
  { name: "config", load: () => import("./commands/config").then(m => m.default) },
23
23
  { name: "dry-balance", load: () => import("./commands/dry-balance").then(m => m.default) },
24
24
  { name: "grep", load: () => import("./commands/grep").then(m => m.default) },
25
+ { name: "gallery", load: () => import("./commands/gallery").then(m => m.default) },
25
26
  { name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
26
27
  { name: "install", load: () => import("./commands/install").then(m => m.default) },
27
28
  { name: "plugin", load: () => import("./commands/plugin").then(m => m.default) },
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Render every built-in tool's renderer across its lifecycle states.
3
+ */
4
+ import { Command, Flags } from "@oh-my-pi/pi-utils/cli";
5
+ import { GALLERY_STATES, type GalleryState, runGalleryCommand } from "../cli/gallery-cli";
6
+
7
+ export default class Gallery extends Command {
8
+ static description = "Preview tool renderers across streaming, in-progress, success, and failure states";
9
+
10
+ static flags = {
11
+ tool: Flags.string({ char: "t", description: "Render a single tool by name" }),
12
+ state: Flags.string({
13
+ char: "s",
14
+ description: "Render only the given lifecycle state(s)",
15
+ options: [...GALLERY_STATES],
16
+ multiple: true,
17
+ }),
18
+ width: Flags.integer({ char: "w", description: "Render width in columns" }),
19
+ expanded: Flags.boolean({
20
+ char: "e",
21
+ description: "Render the expanded variant of each renderer",
22
+ default: false,
23
+ }),
24
+ plain: Flags.boolean({ description: "Strip ANSI styling from the output", default: false }),
25
+ screenshot: Flags.boolean({
26
+ description:
27
+ "Capture the rendered output as PNG screenshot(s) via VHS instead of printing ANSI (requires vhs)",
28
+ default: false,
29
+ }),
30
+ out: Flags.string({
31
+ char: "o",
32
+ description: "Screenshot output path (with --screenshot); suffixed per image when split across multiple",
33
+ }),
34
+ font: Flags.string({ description: "Screenshot font family (default: JetBrainsMono Nerd Font)" }),
35
+ "font-size": Flags.integer({ description: "Screenshot font size in points (default: 18)" }),
36
+ };
37
+
38
+ async run(): Promise<void> {
39
+ const { flags } = await this.parse(Gallery);
40
+ await runGalleryCommand({
41
+ tool: flags.tool,
42
+ states: flags.state as GalleryState[] | undefined,
43
+ width: flags.width,
44
+ expanded: flags.expanded,
45
+ plain: flags.plain,
46
+ screenshot: flags.screenshot,
47
+ out: flags.out,
48
+ font: flags.font,
49
+ fontSize: flags["font-size"],
50
+ });
51
+ }
52
+ }
@@ -2,7 +2,7 @@
2
2
  * Root command for the coding agent CLI.
3
3
  */
4
4
 
5
- import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
5
+ import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai/effort";
6
6
  import { APP_NAME } from "@oh-my-pi/pi-utils";
7
7
  import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
8
8
  import { parseArgs } from "../cli/args";
@@ -21,6 +21,7 @@ interface AppKeybindings {
21
21
  "app.clear": true;
22
22
  "app.exit": true;
23
23
  "app.suspend": true;
24
+ "app.display.reset": true;
24
25
  "app.thinking.cycle": true;
25
26
  "app.thinking.toggle": true;
26
27
  "app.model.cycleForward": true;
@@ -86,6 +87,10 @@ export const KEYBINDINGS = {
86
87
  defaultKeys: "ctrl+z",
87
88
  description: "Suspend application",
88
89
  },
90
+ "app.display.reset": {
91
+ defaultKeys: "ctrl+l",
92
+ description: "Reset terminal display",
93
+ },
89
94
  "app.thinking.cycle": {
90
95
  defaultKeys: "shift+tab",
91
96
  description: "Cycle thinking level",
@@ -103,7 +108,7 @@ export const KEYBINDINGS = {
103
108
  description: "Cycle to previous model",
104
109
  },
105
110
  "app.model.select": {
106
- defaultKeys: "ctrl+l",
111
+ defaultKeys: "alt+m",
107
112
  description: "Select model",
108
113
  },
109
114
  "app.model.selectTemporary": {
@@ -119,7 +124,10 @@ export const KEYBINDINGS = {
119
124
  description: "Open external editor",
120
125
  },
121
126
  "app.message.followUp": {
122
- defaultKeys: "ctrl+enter",
127
+ // Ctrl+Enter is preserved for terminals that deliver it (Kitty/iTerm2/WezTerm/Ghostty),
128
+ // but Windows Terminal does not emit a distinct event for Ctrl+Enter — Ctrl+Q is listed
129
+ // first so the default binding works there without remapping (#1903).
130
+ defaultKeys: ["ctrl+q", "ctrl+enter"],
123
131
  description: "Send follow-up message",
124
132
  },
125
133
  "app.message.dequeue": {
@@ -213,6 +221,7 @@ const KEYBINDING_NAME_MIGRATIONS = {
213
221
  clear: "app.clear",
214
222
  exit: "app.exit",
215
223
  suspend: "app.suspend",
224
+ displayReset: "app.display.reset",
216
225
  cycleThinkingLevel: "app.thinking.cycle",
217
226
  cycleModelForward: "app.model.cycleForward",
218
227
  cycleModelBackward: "app.model.cycleBackward",
@@ -439,16 +448,50 @@ function migrateKeybindingsConfigFile(agentDir: string): void {
439
448
  loadKeybindingsConfig(readPath, writeBackPath);
440
449
  }
441
450
 
451
+ const FOLLOW_UP_KEYBINDING: AppKeybinding = "app.message.followUp";
452
+ const WINDOWS_FOLLOW_UP_FALLBACK_KEY: KeyId = "ctrl+q";
453
+ function keyListIncludes(keys: KeyId | KeyId[] | undefined, target: KeyId): boolean {
454
+ if (keys === undefined) return false;
455
+ const keyList = Array.isArray(keys) ? keys : [keys];
456
+ for (const key of keyList) {
457
+ if (key.toLowerCase() === target) return true;
458
+ }
459
+ return false;
460
+ }
461
+
462
+ function userBindingClaimsKey(config: KeybindingsConfig, target: KeyId, except: Keybinding): boolean {
463
+ for (const [keybinding, keys] of Object.entries(config)) {
464
+ if (!(keybinding in KEYBINDINGS)) continue;
465
+ if (keybinding === except) continue;
466
+ if (keyListIncludes(keys, target)) return true;
467
+ }
468
+ return false;
469
+ }
470
+
471
+ function removeKey(keys: KeyId[], target: KeyId): KeyId[] {
472
+ return keys.filter(key => key !== target);
473
+ }
474
+
475
+ function keyConfigValue(keys: KeyId[]): KeyId | KeyId[] {
476
+ if (keys.length === 1) {
477
+ const key = keys[0];
478
+ if (key !== undefined) return key;
479
+ }
480
+ return [...keys];
481
+ }
482
+
442
483
  /**
443
484
  * Manages all keybindings (app + TUI).
444
485
  * Extends the TUI KeybindingsManager with app-specific functionality.
445
486
  */
446
487
  export class KeybindingsManager extends TuiKeybindingsManager {
447
488
  #configPath: string | undefined;
489
+ #userBindings: KeybindingsConfig;
448
490
 
449
491
  constructor(userBindings: KeybindingsConfig = {}, configPath?: string) {
450
492
  super(KEYBINDINGS, userBindings);
451
493
  this.#configPath = configPath;
494
+ this.#userBindings = userBindings;
452
495
  }
453
496
 
454
497
  /**
@@ -480,6 +523,29 @@ export class KeybindingsManager extends TuiKeybindingsManager {
480
523
  this.setUserBindings(config);
481
524
  }
482
525
 
526
+ setUserBindings(userBindings: KeybindingsConfig): void {
527
+ this.#userBindings = userBindings;
528
+ super.setUserBindings(userBindings);
529
+ }
530
+
531
+ getKeys(keybinding: Keybinding): KeyId[] {
532
+ const keys = super.getKeys(keybinding);
533
+ if (keybinding === FOLLOW_UP_KEYBINDING) {
534
+ if (this.#userBindings[FOLLOW_UP_KEYBINDING] !== undefined) return keys;
535
+ if (!userBindingClaimsKey(this.#userBindings, WINDOWS_FOLLOW_UP_FALLBACK_KEY, FOLLOW_UP_KEYBINDING)) {
536
+ return keys;
537
+ }
538
+ return removeKey(keys, WINDOWS_FOLLOW_UP_FALLBACK_KEY);
539
+ }
540
+ return keys;
541
+ }
542
+
543
+ getResolvedBindings(): KeybindingsConfig {
544
+ const resolved = super.getResolvedBindings();
545
+ resolved[FOLLOW_UP_KEYBINDING] = keyConfigValue(this.getKeys(FOLLOW_UP_KEYBINDING));
546
+ return resolved;
547
+ }
548
+
483
549
  /**
484
550
  * Get the effective resolved bindings (defaults + user overrides).
485
551
  */
@@ -58,7 +58,7 @@ const EMPTY_COMPILED_EQUIVALENCE: CompiledEquivalenceConfig = {
58
58
  };
59
59
  const kModelResolutionCache = Symbol("model-equivalence.resolutionCache");
60
60
  interface CompiledEquivalenceConfigWithCache extends CompiledEquivalenceConfig {
61
- [kModelResolutionCache]?: WeakMap<Model<Api>, ResolvedCanonicalModel>;
61
+ [kModelResolutionCache]?: Map<string, ResolvedCanonicalModel>;
62
62
  }
63
63
  const FAMILY_EXTRACTION_PATTERNS = [
64
64
  /(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+)(?::|$)/i,
@@ -128,10 +128,18 @@ function normalizeCanonicalIdKey(canonicalId: string): string {
128
128
  return canonicalId.trim().toLowerCase();
129
129
  }
130
130
 
131
+ function getCanonicalSuffixAliasKey(candidate: string): string {
132
+ return PENALTY_HAS_UPPERCASE.test(candidate) ? normalizeCanonicalIdKey(candidate) : candidate;
133
+ }
134
+
131
135
  export function formatCanonicalVariantSelector(model: Model<Api>): string {
132
136
  return `${model.provider}/${model.id}`;
133
137
  }
134
138
 
139
+ function getModelResolutionCacheKey(model: Model<Api>): string {
140
+ return `${model.provider}\0${model.id}`;
141
+ }
142
+
135
143
  function buildOverrideMap(overrides: Record<string, string> | undefined): Map<string, string> {
136
144
  const result = new Map<string, string>();
137
145
  if (!overrides) {
@@ -159,13 +167,24 @@ function buildExclusionSet(exclusions: readonly string[] | undefined): Set<strin
159
167
  return result;
160
168
  }
161
169
 
170
+ const compiledEquivalenceCache = new WeakMap<ModelEquivalenceConfig, CompiledEquivalenceConfig>();
162
171
  function compileEquivalenceConfig(config: ModelEquivalenceConfig | undefined): CompiledEquivalenceConfig {
172
+ if (config) {
173
+ const cached = compiledEquivalenceCache.get(config);
174
+ if (cached) {
175
+ return cached;
176
+ }
177
+ }
163
178
  const overrides = buildOverrideMap(config?.overrides);
164
179
  const exclude = buildExclusionSet(config?.exclude);
165
180
  if (overrides.size === 0 && exclude.size === 0) {
166
181
  return EMPTY_COMPILED_EQUIVALENCE;
167
182
  }
168
- return { overrides, exclude };
183
+ const compiled: CompiledEquivalenceConfig = { overrides, exclude };
184
+ if (config) {
185
+ compiledEquivalenceCache.set(config, compiled);
186
+ }
187
+ return compiled;
169
188
  }
170
189
 
171
190
  function addCanonicalCandidate(candidates: Set<string>, candidate: string): void {
@@ -277,7 +296,7 @@ function expandCompactSeriesMinorVersions(candidate: string): string[] {
277
296
  // safely return the same instance. Cap keeps memory bounded under adversarial
278
297
  // model-id churn.
279
298
  const QUALIFIED_NAMESPACE_SUFFIX_CACHE = new Map<string, string[]>();
280
- const QUALIFIED_NAMESPACE_SUFFIX_CACHE_CAP = 256;
299
+ const QUALIFIED_NAMESPACE_SUFFIX_CACHE_CAP = 4096;
281
300
  function getQualifiedNamespaceSuffixes(candidate: string): string[] {
282
301
  const cached = QUALIFIED_NAMESPACE_SUFFIX_CACHE.get(candidate);
283
302
  if (cached !== undefined) {
@@ -670,7 +689,7 @@ function expandHeavyCanonicalCandidates(normalized: string, queue: string[]): vo
670
689
  // is unused — kept for signature stability). The returned array is consumed via
671
690
  // `.filter` at every callsite, so sharing the cached instance is safe.
672
691
  const HEURISTIC_CANDIDATES_CACHE = new Map<string, string[]>();
673
- const HEURISTIC_CANDIDATES_CACHE_CAP = 256;
692
+ const HEURISTIC_CANDIDATES_CACHE_CAP = 4096;
674
693
  function getHeuristicCanonicalCandidates(modelId: string, _officialIds?: ReadonlySet<string>): string[] {
675
694
  const cached = HEURISTIC_CANDIDATES_CACHE.get(modelId);
676
695
  if (cached !== undefined) {
@@ -728,10 +747,10 @@ function getPreferredFallbackCanonicalCandidate(modelId: string, candidates: rea
728
747
 
729
748
  function resolveCanonicalIdForModel(
730
749
  model: Model<Api>,
750
+ selector: string,
731
751
  equivalence: CompiledEquivalenceConfig,
732
752
  referenceData: CanonicalReferenceData,
733
753
  ): ResolvedCanonicalModel {
734
- const selector = formatCanonicalVariantSelector(model);
735
754
  const normalizedSelector = normalizeSelectorKey(selector);
736
755
 
737
756
  if (equivalence.overrides.has(normalizedSelector)) {
@@ -753,9 +772,12 @@ function resolveCanonicalIdForModel(
753
772
  }
754
773
 
755
774
  const heuristicCandidates = getHeuristicCanonicalCandidates(model.id, referenceData.officialIds);
756
- const officialMatches = new Set(heuristicCandidates.filter(candidate => referenceData.officialIds.has(candidate)));
775
+ const officialMatches = new Set<string>();
757
776
  for (const candidate of heuristicCandidates) {
758
- const aliased = referenceData.suffixAliases.get(normalizeCanonicalIdKey(candidate));
777
+ if (referenceData.officialIds.has(candidate)) {
778
+ officialMatches.add(candidate);
779
+ }
780
+ const aliased = referenceData.suffixAliases.get(getCanonicalSuffixAliasKey(candidate));
759
781
  if (aliased) {
760
782
  officialMatches.add(aliased);
761
783
  }
@@ -814,17 +836,18 @@ export function buildCanonicalModelIndex(
814
836
  const compiledWithCache = compiledEquivalence as CompiledEquivalenceConfigWithCache;
815
837
  let modelCache = compiledWithCache[kModelResolutionCache];
816
838
  if (!modelCache) {
817
- modelCache = new WeakMap<Model<Api>, ResolvedCanonicalModel>();
839
+ modelCache = new Map<string, ResolvedCanonicalModel>();
818
840
  compiledWithCache[kModelResolutionCache] = modelCache;
819
841
  }
820
842
 
821
843
  for (const model of models) {
822
- let canonical = modelCache.get(model);
844
+ const selector = formatCanonicalVariantSelector(model);
845
+ const cacheKey = getModelResolutionCacheKey(model);
846
+ let canonical = modelCache.get(cacheKey);
823
847
  if (!canonical) {
824
- canonical = resolveCanonicalIdForModel(model, compiledEquivalence, referenceData);
825
- modelCache.set(model, canonical);
848
+ canonical = resolveCanonicalIdForModel(model, selector, compiledEquivalence, referenceData);
849
+ modelCache.set(cacheKey, canonical);
826
850
  }
827
- const selector = formatCanonicalVariantSelector(model);
828
851
  const variant: CanonicalModelVariant = {
829
852
  canonicalId: canonical.id,
830
853
  selector,