@oh-my-pi/pi-coding-agent 15.9.67 → 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 (128) hide show
  1. package/CHANGELOG.md +63 -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 +6 -1
  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 +32 -6
  22. package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
  23. package/dist/types/lsp/types.d.ts +10 -0
  24. package/dist/types/main.d.ts +3 -2
  25. package/dist/types/memory-backend/index.d.ts +2 -1
  26. package/dist/types/memory-backend/resolve.d.ts +1 -1
  27. package/dist/types/memory-backend/types.d.ts +1 -1
  28. package/dist/types/modes/components/custom-editor.d.ts +2 -1
  29. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  30. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  31. package/dist/types/modes/index.d.ts +5 -4
  32. package/dist/types/modes/interactive-mode.d.ts +1 -1
  33. package/dist/types/modes/setup-version.d.ts +11 -0
  34. package/dist/types/modes/setup-wizard/index.d.ts +2 -1
  35. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
  36. package/dist/types/modes/types.d.ts +1 -1
  37. package/dist/types/sdk.d.ts +1 -1
  38. package/dist/types/task/executor.d.ts +7 -0
  39. package/dist/types/telemetry-export.d.ts +1 -1
  40. package/dist/types/tools/eval-render.d.ts +1 -8
  41. package/dist/types/tools/fetch.d.ts +15 -7
  42. package/dist/types/tools/render-utils.d.ts +8 -0
  43. package/dist/types/tools/renderers.d.ts +16 -2
  44. package/dist/types/tools/search.d.ts +1 -1
  45. package/dist/types/tools/write.d.ts +2 -0
  46. package/dist/types/web/scrapers/github.d.ts +22 -0
  47. package/dist/types/web/search/providers/perplexity.d.ts +8 -1
  48. package/dist/types/web/search/types.d.ts +1 -1
  49. package/package.json +9 -9
  50. package/scripts/dev-launch +42 -0
  51. package/scripts/dev-launch-preload.ts +19 -0
  52. package/src/cli/args.ts +2 -2
  53. package/src/cli/gallery-cli.ts +223 -0
  54. package/src/cli/gallery-fixtures/agentic.ts +292 -0
  55. package/src/cli/gallery-fixtures/codeintel.ts +188 -0
  56. package/src/cli/gallery-fixtures/edit.ts +194 -0
  57. package/src/cli/gallery-fixtures/fs.ts +153 -0
  58. package/src/cli/gallery-fixtures/index.ts +40 -0
  59. package/src/cli/gallery-fixtures/interaction.ts +49 -0
  60. package/src/cli/gallery-fixtures/memory.ts +81 -0
  61. package/src/cli/gallery-fixtures/misc.ts +221 -0
  62. package/src/cli/gallery-fixtures/search.ts +213 -0
  63. package/src/cli/gallery-fixtures/shell.ts +167 -0
  64. package/src/cli/gallery-fixtures/types.ts +41 -0
  65. package/src/cli/gallery-fixtures/web.ts +158 -0
  66. package/src/cli/gallery-screenshot.ts +279 -0
  67. package/src/cli-commands.ts +1 -0
  68. package/src/commands/gallery.ts +52 -0
  69. package/src/commands/launch.ts +1 -1
  70. package/src/config/keybindings.ts +15 -6
  71. package/src/config/model-equivalence.ts +35 -12
  72. package/src/config/model-id-affixes.ts +39 -22
  73. package/src/config/model-registry.ts +16 -16
  74. package/src/config/settings-schema.ts +18 -5
  75. package/src/config/settings.ts +11 -0
  76. package/src/dap/client.ts +14 -16
  77. package/src/edit/renderer.ts +36 -48
  78. package/src/eval/__tests__/agent-bridge.test.ts +75 -32
  79. package/src/eval/agent-bridge.ts +34 -7
  80. package/src/extensibility/extensions/runner.ts +1 -0
  81. package/src/extensibility/plugins/doctor.ts +0 -1
  82. package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
  83. package/src/goals/tools/goal-tool.ts +2 -2
  84. package/src/internal-urls/docs-index.generated.ts +5 -5
  85. package/src/lsp/client.ts +104 -55
  86. package/src/lsp/types.ts +10 -0
  87. package/src/main.ts +44 -49
  88. package/src/memory-backend/index.ts +13 -1
  89. package/src/memory-backend/resolve.ts +3 -5
  90. package/src/memory-backend/types.ts +1 -1
  91. package/src/modes/components/custom-editor.ts +10 -1
  92. package/src/modes/components/status-line.ts +3 -5
  93. package/src/modes/components/tool-execution.ts +61 -16
  94. package/src/modes/controllers/command-controller.ts +13 -2
  95. package/src/modes/controllers/input-controller.ts +11 -3
  96. package/src/modes/controllers/selector-controller.ts +2 -2
  97. package/src/modes/index.ts +5 -4
  98. package/src/modes/interactive-mode.ts +17 -3
  99. package/src/modes/setup-version.ts +11 -0
  100. package/src/modes/setup-wizard/index.ts +3 -2
  101. package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
  102. package/src/modes/types.ts +1 -1
  103. package/src/modes/utils/context-usage.ts +10 -6
  104. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  105. package/src/sdk.ts +21 -23
  106. package/src/session/agent-session.ts +7 -7
  107. package/src/slash-commands/builtin-registry.ts +1 -1
  108. package/src/slash-commands/helpers/usage-report.ts +2 -0
  109. package/src/task/executor.ts +20 -2
  110. package/src/task/render.ts +1 -2
  111. package/src/telemetry-export.ts +25 -7
  112. package/src/tools/eval-backends.ts +6 -17
  113. package/src/tools/eval-render.ts +21 -18
  114. package/src/tools/eval.ts +5 -4
  115. package/src/tools/fetch.ts +94 -84
  116. package/src/tools/render-utils.ts +17 -3
  117. package/src/tools/renderers.ts +16 -1
  118. package/src/tools/report-tool-issue.ts +1 -1
  119. package/src/tools/search.ts +173 -81
  120. package/src/tools/todo.ts +20 -7
  121. package/src/tools/write.ts +22 -1
  122. package/src/web/scrapers/github.ts +255 -3
  123. package/src/web/scrapers/youtube.ts +3 -2
  124. package/src/web/search/providers/perplexity.ts +199 -51
  125. package/src/web/search/render.ts +39 -54
  126. package/src/web/search/types.ts +5 -1
  127. package/dist/types/eval/__tests__/shared-executors.test.d.ts +0 -1
  128. package/src/eval/__tests__/shared-executors.test.ts +0 -609
@@ -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": {
@@ -216,6 +221,7 @@ const KEYBINDING_NAME_MIGRATIONS = {
216
221
  clear: "app.clear",
217
222
  exit: "app.exit",
218
223
  suspend: "app.suspend",
224
+ displayReset: "app.display.reset",
219
225
  cycleThinkingLevel: "app.thinking.cycle",
220
226
  cycleModelForward: "app.model.cycleForward",
221
227
  cycleModelBackward: "app.model.cycleBackward",
@@ -444,7 +450,6 @@ function migrateKeybindingsConfigFile(agentDir: string): void {
444
450
 
445
451
  const FOLLOW_UP_KEYBINDING: AppKeybinding = "app.message.followUp";
446
452
  const WINDOWS_FOLLOW_UP_FALLBACK_KEY: KeyId = "ctrl+q";
447
-
448
453
  function keyListIncludes(keys: KeyId | KeyId[] | undefined, target: KeyId): boolean {
449
454
  if (keys === undefined) return false;
450
455
  const keyList = Array.isArray(keys) ? keys : [keys];
@@ -525,10 +530,14 @@ export class KeybindingsManager extends TuiKeybindingsManager {
525
530
 
526
531
  getKeys(keybinding: Keybinding): KeyId[] {
527
532
  const keys = super.getKeys(keybinding);
528
- if (keybinding !== FOLLOW_UP_KEYBINDING) return keys;
529
- if (this.#userBindings[FOLLOW_UP_KEYBINDING] !== undefined) return keys;
530
- if (!userBindingClaimsKey(this.#userBindings, WINDOWS_FOLLOW_UP_FALLBACK_KEY, FOLLOW_UP_KEYBINDING)) return keys;
531
- return removeKey(keys, WINDOWS_FOLLOW_UP_FALLBACK_KEY);
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;
532
541
  }
533
542
 
534
543
  getResolvedBindings(): KeybindingsConfig {
@@ -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,
@@ -4,34 +4,49 @@ const MODEL_ID_SEGMENT_PATTERN = /[a-z0-9.:-]+/g;
4
4
  const MODEL_FAMILY_PREFIX_PATTERN =
5
5
  /^(claude|gemini|gpt|grok|glm|qwen|deepseek|kimi|mimo|doubao|ernie|gpt-oss|gemma|minimax|step|command|jamba|llama|o[1345])/i;
6
6
 
7
- function hasDigit(value: string): boolean {
8
- return /\d/.test(value);
7
+ function normalizeModelIdWhitespace(value: string): string {
8
+ return value.trim().replace(/\s+/g, " ");
9
9
  }
10
10
 
11
+ /** Ordering for model-like segments: longest first, ties broken lexicographically. */
11
12
  function compareSegmentPreference(left: string, right: string): number {
12
- if (left.length !== right.length) {
13
- return right.length - left.length;
14
- }
15
- return left.localeCompare(right);
13
+ return left.length !== right.length ? right.length - left.length : left.localeCompare(right);
16
14
  }
17
15
 
18
16
  export function getModelLikeIdSegments(modelId: string): string[] {
19
- const normalized = normalizeModelIdWhitespace(modelId).toLowerCase();
20
- if (!normalized) return [];
21
- const segments = (normalized.match(MODEL_ID_SEGMENT_PATTERN) ?? []).filter(
22
- segment => MODEL_FAMILY_PREFIX_PATTERN.test(segment) && hasDigit(segment),
23
- );
24
- const unique = [...new Set(segments)];
25
- unique.sort(compareSegmentPreference);
26
- return unique;
17
+ const matches = normalizeModelIdWhitespace(modelId).toLowerCase().match(MODEL_ID_SEGMENT_PATTERN);
18
+ if (!matches) return [];
19
+ const segments = new Set<string>();
20
+ for (const segment of matches) {
21
+ if (MODEL_FAMILY_PREFIX_PATTERN.test(segment) && /\d/.test(segment)) segments.add(segment);
22
+ }
23
+ return [...segments].sort(compareSegmentPreference);
27
24
  }
28
25
 
29
26
  export function getLongestModelLikeIdSegment(modelId: string): string | undefined {
30
- return getModelLikeIdSegments(modelId)[0];
27
+ const matches = normalizeModelIdWhitespace(modelId).toLowerCase().match(MODEL_ID_SEGMENT_PATTERN);
28
+ if (!matches) return undefined;
29
+ let best: string | undefined;
30
+ for (const segment of matches) {
31
+ if (
32
+ MODEL_FAMILY_PREFIX_PATTERN.test(segment) &&
33
+ /\d/.test(segment) &&
34
+ (best === undefined || compareSegmentPreference(segment, best) < 0)
35
+ ) {
36
+ best = segment;
37
+ }
38
+ }
39
+ return best;
31
40
  }
32
41
 
33
- function normalizeModelIdWhitespace(value: string): string {
34
- return value.trim().replace(/\s+/g, " ");
42
+ function hasBracketAffixMarker(value: string): boolean {
43
+ for (let index = 0; index < value.length; index++) {
44
+ const code = value.charCodeAt(index);
45
+ if (code === 91 || code === 93 || code === 0x3010 || code === 0x3011) {
46
+ return true;
47
+ }
48
+ }
49
+ return false;
35
50
  }
36
51
 
37
52
  /**
@@ -39,18 +54,20 @@ function normalizeModelIdWhitespace(value: string): string {
39
54
  * upstream model id, e.g.
40
55
  * "[Kiro] claude-opus-4-8" -> "claude-opus-4-8"
41
56
  * "[gcli转] gemini-3.1-pro-preview [假流]" -> "gemini-3.1-pro-preview"
57
+ *
58
+ * Candidates are returned most-stripped first: both ends, then leading-only, then trailing-only.
42
59
  */
43
60
  export function getBracketStrippedModelIdCandidates(modelId: string): string[] {
61
+ if (!hasBracketAffixMarker(modelId)) return [];
44
62
  const normalized = normalizeModelIdWhitespace(modelId);
45
63
  if (!normalized) return [];
46
64
 
47
- const candidates = new Set<string>();
48
- const withoutLeading = normalizeModelIdWhitespace(normalized.replace(LEADING_BRACKETED_AFFIX_PATTERN, ""));
65
+ const strippedLeading = normalized.replace(LEADING_BRACKETED_AFFIX_PATTERN, "");
66
+ const withoutLeading = normalizeModelIdWhitespace(strippedLeading);
49
67
  const withoutTrailing = normalizeModelIdWhitespace(normalized.replace(TRAILING_BRACKETED_AFFIX_PATTERN, ""));
50
- const withoutBoth = normalizeModelIdWhitespace(
51
- normalized.replace(LEADING_BRACKETED_AFFIX_PATTERN, "").replace(TRAILING_BRACKETED_AFFIX_PATTERN, ""),
52
- );
68
+ const withoutBoth = normalizeModelIdWhitespace(strippedLeading.replace(TRAILING_BRACKETED_AFFIX_PATTERN, ""));
53
69
 
70
+ const candidates = new Set<string>();
54
71
  for (const candidate of [withoutBoth, withoutLeading, withoutTrailing]) {
55
72
  if (candidate && candidate !== normalized) {
56
73
  candidates.add(candidate);
@@ -1,27 +1,19 @@
1
1
  import * as path from "node:path";
2
+ import { registerCustomApi, unregisterCustomApis } from "@oh-my-pi/pi-ai/api-registry";
3
+ import { readModelCache } from "@oh-my-pi/pi-ai/model-cache";
4
+ import { createModelManager, type ModelManagerOptions, type ModelRefreshStrategy } from "@oh-my-pi/pi-ai/model-manager";
5
+ import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking";
6
+ import { getBundledModels, getBundledProviders } from "@oh-my-pi/pi-ai/models";
2
7
  import {
3
- type Api,
4
- type AssistantMessageEventStream,
5
- type Context,
6
- createModelManager,
7
- enrichModelThinking,
8
- getBundledModels,
9
- getBundledProviders,
10
8
  googleAntigravityModelManagerOptions,
11
9
  googleGeminiCliModelManagerOptions,
12
- type Model,
13
- type ModelManagerOptions,
14
- type ModelRefreshStrategy,
15
10
  openaiCodexModelManagerOptions,
16
11
  PROVIDER_DESCRIPTORS,
17
- readModelCache,
18
- registerCustomApi,
19
- type SimpleStreamOptions,
20
- type ThinkingConfig,
21
12
  UNK_CONTEXT_WINDOW,
22
13
  UNK_MAX_TOKENS,
23
- unregisterCustomApis,
24
- } from "@oh-my-pi/pi-ai";
14
+ } from "@oh-my-pi/pi-ai/provider-models";
15
+ import type { Api, Context, Model, SimpleStreamOptions, ThinkingConfig } from "@oh-my-pi/pi-ai/types";
16
+ import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
25
17
 
26
18
  // Sentinel for local-only OAuth token (LM Studio, vLLM) — declared inline to avoid loading
27
19
  // any provider module at startup. Must match `DEFAULT_LOCAL_TOKEN` in oauth/lm-studio.ts.
@@ -2609,6 +2601,14 @@ export class ModelRegistry {
2609
2601
  }
2610
2602
  return true;
2611
2603
  }
2604
+
2605
+ /**
2606
+ * Clear all cooldown suppressions recorded via {@link suppressSelector}.
2607
+ * Used to reset retry-fallback cooldown state without a full {@link refresh}.
2608
+ */
2609
+ clearSuppressedSelectors(): void {
2610
+ this.#suppressedSelectors.clear();
2611
+ }
2612
2612
  }
2613
2613
 
2614
2614
  /**