bermudis-pi-goodies 0.22.0 → 0.23.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 +10 -4
- package/clean-tui.ts +108 -81
- package/goodies.ts +32 -0
- package/package.json +1 -1
- package/review.ts +182 -11
- package/vision-core.ts +91 -0
- package/vision.ts +162 -0
package/README.md
CHANGED
|
@@ -13,19 +13,19 @@ extensions. One entry point, twelve independent features.
|
|
|
13
13
|
| `keep-model-on-new` | hook (no command) | Keep the active model when `/new` starts a fresh session instead of reverting to pi's saved default model. |
|
|
14
14
|
| `model-thinking` | `/model-thinking` | Per-model default thinking levels: save the current level as this model's default and get it back on every switch to that model, instead of pi's global default. |
|
|
15
15
|
| `clean-tui` | tool overrides (no command) | Collapse built-in tool output for a cleaner TUI: back-to-back same-tool calls share one block (e.g. `read ×2`) until a boundary — visible text (assistant prose or a typed user message) or a thinking block (even an empty one, as OpenAI emits between tool calls) — so reasoning-per-call models render one block per call. Images stay visible without expanding, expand a row with ctrl+o to see the full command and results/diffs. Long bash commands get an AI-generated summary once you pick a model with `/goodies summary-model <provider/model>` (see "Smart summaries" below) — expanding a row swaps the summary back out for the raw command. While enabled, also flips `@bermudi/pi-codex`'s `apply_patch`/`web_search` into the same burst style. Long thinking runs can show a live plain-English line above the editor (`/goodies thinking-summaries on`) instead of a static `Thinking...` — see "Smart summaries". |
|
|
16
|
-
| `review` | `/review`, `/end-review` | Code review workflow: review uncommitted changes, a branch, a commit, a GitHub PR, or folders. Prioritized findings with actionable follow-ups. |
|
|
16
|
+
| `review` | `/review`, `/end-review` | Code review workflow: review uncommitted changes, a branch, a commit, a GitHub PR, or folders. Prioritized findings with actionable follow-ups. Targets tab-complete. |
|
|
17
17
|
| `kilo` | provider | Access Kilo Gateway models via `/login kilo` or `KILO_API_KEY`. |
|
|
18
18
|
| `provider-balance` | footer (no command) | Show remaining Kilo, OpenRouter, or CommandCode credits, z.ai token-plan quota, or OpenAI Codex quota on the right side of the working-directory footer line. |
|
|
19
19
|
| `tps` | hook (no command) | Notify tokens/sec and in/out/cache token usage at the end of each agent turn. |
|
|
20
20
|
| `vision` | `vision` tool, `/vision` | Ask a vision model targeted questions about an image file and get a text answer — for models that can't see images. Follow-ups via `followUp: true`. Self-hides when the active model has image input. |
|
|
21
|
-
| `goodies` | `/goodies` | Toggle individual features on/off without losing the rest. Also supports `/goodies summary-model [provider/model]` to pick the model used for AI bash-command summaries, and `/goodies thinking-summaries <on\|off>` for live thinking summaries. State persists to `~/.pi/agent/goodies.json`. |
|
|
21
|
+
| `goodies` | `/goodies` | Toggle individual features on/off without losing the rest. Also supports `/goodies summary-model [provider/model]` to pick the model used for AI bash-command summaries, and `/goodies thinking-summaries <on\|off>` for live thinking summaries. State persists to `~/.pi/agent/goodies.json`. Arguments tab-complete. |
|
|
22
22
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
25
25
|
After publishing the package to npm:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
pi install npm:bermudis-pi-goodies@0.
|
|
28
|
+
pi install npm:bermudis-pi-goodies@0.23.0
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
Remove any old `bermudis-pi-goodies.ts` symlink before reloading Pi. Each
|
|
@@ -311,7 +311,9 @@ this feature never stores credentials.
|
|
|
311
311
|
```
|
|
312
312
|
|
|
313
313
|
`/vision set` live-validates: typos get "did you mean" suggestions, text-only
|
|
314
|
-
models and missing auth are rejected immediately.
|
|
314
|
+
models and missing auth are rejected immediately. Arguments tab-complete:
|
|
315
|
+
subcommands, then `model=` / bare model ids (image-capable models from pi's
|
|
316
|
+
registry, ranked), then `maxTokens=`. Config lives in
|
|
315
317
|
`~/.pi/agent/vision.json` (0600, follows `PI_CODING_AGENT_DIR`); env fallback
|
|
316
318
|
`VISION_MODEL=provider/model`.
|
|
317
319
|
|
|
@@ -328,5 +330,9 @@ Behavior notes:
|
|
|
328
330
|
capped at 8 threads / 10 turns, in-memory only.
|
|
329
331
|
- Answers return as plain text (same trust model as any tool output); the
|
|
330
332
|
vision model's own system prompt refuses instructions embedded in the image.
|
|
333
|
+
- Rows render in the clean-tui burst style while that feature is enabled
|
|
334
|
+
(header = path + question, follow-ups annotated, answers on expand,
|
|
335
|
+
same-tool calls group as `vision ×N`); with clean-tui disabled the tool
|
|
336
|
+
falls back to pi's default rendering.
|
|
331
337
|
- Nested completion usage is reported back, so pi's session stats stay
|
|
332
338
|
accurate.
|
package/clean-tui.ts
CHANGED
|
@@ -103,7 +103,7 @@ function getBuiltInTools(cwd: string): BuiltInTools {
|
|
|
103
103
|
return tools;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
function shortenPath(path: string): string {
|
|
106
|
+
export function shortenPath(path: string): string {
|
|
107
107
|
const home = homedir();
|
|
108
108
|
// Only a real subpath of home shortens: a bare startsWith turns a sibling
|
|
109
109
|
// like `/home/me-other/x` into `~-other/x`.
|
|
@@ -1359,6 +1359,106 @@ function makeBox(
|
|
|
1359
1359
|
return box;
|
|
1360
1360
|
}
|
|
1361
1361
|
|
|
1362
|
+
/** While set, sibling extension tools in this package render in burst style
|
|
1363
|
+
* (same contract @bermudi/pi-codex mirrors via Symbol.for). Set at load,
|
|
1364
|
+
* cleared when the feature is disabled, so /reload converges. Consumers must
|
|
1365
|
+
* read it at registration time — rendering never happens before all
|
|
1366
|
+
* extensions load. */
|
|
1367
|
+
export function isCleanTuiActive(): boolean {
|
|
1368
|
+
return (globalThis as Record<symbol, unknown>)[CLEAN_TUI_ACTIVE] === true;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/** Format spec for a burst-style tool. The shared skeleton below carries the
|
|
1372
|
+
* grouped/solo/pending/error rules once for every tool that uses it. */
|
|
1373
|
+
export type BurstToolSpec = {
|
|
1374
|
+
name: string;
|
|
1375
|
+
/** Required when registering via registerBurstTool (built-in override);
|
|
1376
|
+
* createBurstRenderer ignores these — the tool keeps its own definition. */
|
|
1377
|
+
description?: any;
|
|
1378
|
+
parameters?: any;
|
|
1379
|
+
/** Bullet line for one entry inside a grouped header. */
|
|
1380
|
+
bullet: (entry: Entry, theme: any) => string;
|
|
1381
|
+
/** Extra lines appended to the grouped header when expanded (or ""). */
|
|
1382
|
+
groupedDetails: (entries: Entry[], theme: any) => string;
|
|
1383
|
+
/** Solo (ungrouped) header line, without expanded output. */
|
|
1384
|
+
soloHeader: (args: any, theme: any, ctx: any) => string;
|
|
1385
|
+
/** Extra lines appended to the solo header when expanded (or ""). */
|
|
1386
|
+
soloExpanded: (entry: Entry, args: any, theme: any) => string;
|
|
1387
|
+
/** Hook run right after upsertEntry (bash: request a summary). */
|
|
1388
|
+
onUpsert?: (entry: Entry, args: any, ctx: any) => void;
|
|
1389
|
+
};
|
|
1390
|
+
|
|
1391
|
+
/** The shared burst render hooks (renderShell + renderCall + renderResult),
|
|
1392
|
+
* split out of registerBurstTool so extension-owned tools in this package
|
|
1393
|
+
* (vision) can attach the identical skeleton to their own registration —
|
|
1394
|
+
* one implementation of the grouped/solo/pending/error rules, no copy. */
|
|
1395
|
+
export function createBurstRenderer(spec: BurstToolSpec): {
|
|
1396
|
+
renderShell: "self";
|
|
1397
|
+
renderCall: (args: any, theme: any, ctx: any) => any;
|
|
1398
|
+
renderResult: (result: any, _opts: any, _theme: any, ctx: any) => Container;
|
|
1399
|
+
} {
|
|
1400
|
+
return {
|
|
1401
|
+
renderShell: "self",
|
|
1402
|
+
renderCall(args: any, theme: any, ctx: any) {
|
|
1403
|
+
const entry = upsertEntry(
|
|
1404
|
+
ctx.toolCallId,
|
|
1405
|
+
spec.name,
|
|
1406
|
+
args,
|
|
1407
|
+
ctx.invalidate,
|
|
1408
|
+
);
|
|
1409
|
+
spec.onUpsert?.(entry, args, ctx);
|
|
1410
|
+
const burst = getBurstForId(ctx.toolCallId);
|
|
1411
|
+
const isGrouped = burst && burst.entries.length > 1;
|
|
1412
|
+
const isLeader =
|
|
1413
|
+
isGrouped && burst.entries[0].toolCallId === ctx.toolCallId;
|
|
1414
|
+
// The burst box is shared by every call in the group, so its background
|
|
1415
|
+
// never takes the error color — a failure (the leader included) is
|
|
1416
|
+
// marked on its own bullet instead. Pending does aggregate: the box
|
|
1417
|
+
// stays in its running state until every call in the burst has landed.
|
|
1418
|
+
// (b80a14d "follow the leader" painted the whole block red whenever the
|
|
1419
|
+
// first call itself failed; this rule — formerly only on bash — now
|
|
1420
|
+
// applies to every burst tool.)
|
|
1421
|
+
const pending = isGrouped
|
|
1422
|
+
? burst.entries.some((e) => !e.result)
|
|
1423
|
+
: !entry.result;
|
|
1424
|
+
const isError = isGrouped ? false : !!entry.isError;
|
|
1425
|
+
|
|
1426
|
+
if (isGrouped && !isLeader) {
|
|
1427
|
+
// Refresh the leader's header/count. Single hop: a leader's renderCall
|
|
1428
|
+
// never invalidates anything, so this cannot loop.
|
|
1429
|
+
const lead = invalidateById.get(burst.entries[0].toolCallId);
|
|
1430
|
+
if (lead) lead();
|
|
1431
|
+
return new Container();
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
if (isGrouped && isLeader) {
|
|
1435
|
+
let header = `${theme.fg("toolTitle", theme.bold(spec.name))} ${theme.fg("muted", `×${burst.entries.length}`)}`;
|
|
1436
|
+
header += `\n${burst.entries.map((e) => spec.bullet(e, theme)).join("\n")}`;
|
|
1437
|
+
if (ctx.expanded) {
|
|
1438
|
+
const details = spec.groupedDetails(burst.entries, theme);
|
|
1439
|
+
if (details) header += details;
|
|
1440
|
+
}
|
|
1441
|
+
return makeBox(theme, pending, isError, header);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// solo
|
|
1445
|
+
let line = spec.soloHeader(args, theme, ctx);
|
|
1446
|
+
if (ctx.expanded) {
|
|
1447
|
+
const extra = spec.soloExpanded(entry, args, theme);
|
|
1448
|
+
if (extra) line += `\n${extra}`;
|
|
1449
|
+
}
|
|
1450
|
+
return makeBox(theme, pending, isError, line);
|
|
1451
|
+
},
|
|
1452
|
+
renderResult(result: any, _opts: any, _theme: any, ctx: any) {
|
|
1453
|
+
recordResult(entryById.get(ctx.toolCallId), result, ctx);
|
|
1454
|
+
// All visual work is done in renderCall (unified box); keep the result
|
|
1455
|
+
// slot empty. Images are rendered by Pi's ToolExecutionComponent image
|
|
1456
|
+
// layer even when we return empty here.
|
|
1457
|
+
return new Container();
|
|
1458
|
+
},
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1362
1462
|
// ── Per-tool helpers ────────────────────────────────────────────
|
|
1363
1463
|
function formatReadHeader(args: any, theme: any): string {
|
|
1364
1464
|
const path = shortenPath(args.path || "");
|
|
@@ -1637,98 +1737,25 @@ export default function cleanTui(pi: ExtensionAPI): void {
|
|
|
1637
1737
|
});
|
|
1638
1738
|
|
|
1639
1739
|
// ── Shared burst-tool skeleton ─────────────────────────────────
|
|
1640
|
-
//
|
|
1641
|
-
//
|
|
1642
|
-
//
|
|
1643
|
-
//
|
|
1644
|
-
//
|
|
1645
|
-
//
|
|
1740
|
+
// registerBurstTool = createBurstRenderer (module-level, shared with
|
|
1741
|
+
// vision's own registration) + execute delegation to the cached built-in
|
|
1742
|
+
// tool. Every burst tool (read/bash/write/edit/find/grep/ls) shares that one
|
|
1743
|
+
// render skeleton, which makes the divergence class impossible: the "one
|
|
1744
|
+
// failed row paints the whole burst red" bug was fixed twice for bash
|
|
1745
|
+
// (b80a14d, then the isGrouped?false rule) but the other six tools still
|
|
1646
1746
|
// shipped `isGrouped ? entries.some(e => e.isError)` — the shared skeleton
|
|
1647
1747
|
// carries the single correct rule for all of them.
|
|
1648
|
-
type BurstToolSpec = {
|
|
1649
|
-
name: string;
|
|
1650
|
-
description: any;
|
|
1651
|
-
parameters: any;
|
|
1652
|
-
/** Bullet line for one entry inside a grouped header. */
|
|
1653
|
-
bullet: (entry: Entry, theme: any) => string;
|
|
1654
|
-
/** Extra lines appended to the grouped header when expanded (or ""). */
|
|
1655
|
-
groupedDetails: (entries: Entry[], theme: any) => string;
|
|
1656
|
-
/** Solo (ungrouped) header line, without expanded output. */
|
|
1657
|
-
soloHeader: (args: any, theme: any, ctx: any) => string;
|
|
1658
|
-
/** Extra lines appended to the solo header when expanded (or ""). */
|
|
1659
|
-
soloExpanded: (entry: Entry, args: any, theme: any) => string;
|
|
1660
|
-
/** Hook run right after upsertEntry (bash: request a summary). */
|
|
1661
|
-
onUpsert?: (entry: Entry, args: any, ctx: any) => void;
|
|
1662
|
-
};
|
|
1663
|
-
|
|
1664
1748
|
function registerBurstTool(spec: BurstToolSpec): void {
|
|
1665
1749
|
pi.registerTool({
|
|
1666
1750
|
name: spec.name,
|
|
1667
1751
|
label: spec.name,
|
|
1668
1752
|
description: spec.description,
|
|
1669
1753
|
parameters: spec.parameters,
|
|
1670
|
-
|
|
1754
|
+
...createBurstRenderer(spec),
|
|
1671
1755
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1672
1756
|
const tool = (getBuiltInTools(ctx.cwd) as any)[spec.name];
|
|
1673
1757
|
return tool.execute(toolCallId, params, signal, onUpdate);
|
|
1674
1758
|
},
|
|
1675
|
-
renderCall(args, theme, ctx: any) {
|
|
1676
|
-
const entry = upsertEntry(
|
|
1677
|
-
ctx.toolCallId,
|
|
1678
|
-
spec.name,
|
|
1679
|
-
args,
|
|
1680
|
-
ctx.invalidate,
|
|
1681
|
-
);
|
|
1682
|
-
spec.onUpsert?.(entry, args, ctx);
|
|
1683
|
-
const burst = getBurstForId(ctx.toolCallId);
|
|
1684
|
-
const isGrouped = burst && burst.entries.length > 1;
|
|
1685
|
-
const isLeader =
|
|
1686
|
-
isGrouped && burst.entries[0].toolCallId === ctx.toolCallId;
|
|
1687
|
-
// The burst box is shared by every call in the group, so its background
|
|
1688
|
-
// never takes the error color — a failure (the leader included) is
|
|
1689
|
-
// marked on its own bullet instead. Pending does aggregate: the box
|
|
1690
|
-
// stays in its running state until every call in the burst has landed.
|
|
1691
|
-
// (b80a14d "follow the leader" painted the whole block red whenever the
|
|
1692
|
-
// first call itself failed; this rule — formerly only on bash — now
|
|
1693
|
-
// applies to every burst tool.)
|
|
1694
|
-
const pending = isGrouped
|
|
1695
|
-
? burst.entries.some((e) => !e.result)
|
|
1696
|
-
: !entry.result;
|
|
1697
|
-
const isError = isGrouped ? false : !!entry.isError;
|
|
1698
|
-
|
|
1699
|
-
if (isGrouped && !isLeader) {
|
|
1700
|
-
// Refresh the leader's header/count. Single hop: a leader's renderCall
|
|
1701
|
-
// never invalidates anything, so this cannot loop.
|
|
1702
|
-
const lead = invalidateById.get(burst.entries[0].toolCallId);
|
|
1703
|
-
if (lead) lead();
|
|
1704
|
-
return new Container();
|
|
1705
|
-
}
|
|
1706
|
-
|
|
1707
|
-
if (isGrouped && isLeader) {
|
|
1708
|
-
let header = `${theme.fg("toolTitle", theme.bold(spec.name))} ${theme.fg("muted", `×${burst.entries.length}`)}`;
|
|
1709
|
-
header += `\n${burst.entries.map((e) => spec.bullet(e, theme)).join("\n")}`;
|
|
1710
|
-
if (ctx.expanded) {
|
|
1711
|
-
const details = spec.groupedDetails(burst.entries, theme);
|
|
1712
|
-
if (details) header += details;
|
|
1713
|
-
}
|
|
1714
|
-
return makeBox(theme, pending, isError, header);
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
// solo
|
|
1718
|
-
let line = spec.soloHeader(args, theme, ctx);
|
|
1719
|
-
if (ctx.expanded) {
|
|
1720
|
-
const extra = spec.soloExpanded(entry, args, theme);
|
|
1721
|
-
if (extra) line += `\n${extra}`;
|
|
1722
|
-
}
|
|
1723
|
-
return makeBox(theme, pending, isError, line);
|
|
1724
|
-
},
|
|
1725
|
-
renderResult(result: any, _opts: any, _theme: any, ctx: any) {
|
|
1726
|
-
recordResult(entryById.get(ctx.toolCallId), result, ctx);
|
|
1727
|
-
// All visual work is done in renderCall (unified box); keep the result
|
|
1728
|
-
// slot empty. Images are rendered by Pi's ToolExecutionComponent image
|
|
1729
|
-
// layer even when we return empty here.
|
|
1730
|
-
return new Container();
|
|
1731
|
-
},
|
|
1732
1759
|
});
|
|
1733
1760
|
}
|
|
1734
1761
|
|
package/goodies.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { homedir } from "node:os";
|
|
|
22
22
|
import { join } from "node:path";
|
|
23
23
|
import { reportFailure } from "./goodies-log.ts";
|
|
24
24
|
import { writeJsonFileAtomic, describeError } from "./json-file.ts";
|
|
25
|
+
import { rankCandidates } from "./vision-core.ts";
|
|
25
26
|
|
|
26
27
|
let CONFIG_PATH = join(homedir(), ".pi", "agent", "goodies.json");
|
|
27
28
|
|
|
@@ -337,6 +338,19 @@ export function completeGoodiesArguments(
|
|
|
337
338
|
? matches.map((v) => ({ value: `${verb} ${v}`, label: v }))
|
|
338
339
|
: null;
|
|
339
340
|
}
|
|
341
|
+
if (verb === "summary-model") {
|
|
342
|
+
// Single value token (model ids contain no spaces): "off"/"default"
|
|
343
|
+
// clear the setting; anything else matches the catalogue, ranked.
|
|
344
|
+
const q = valuePrefix.toLowerCase();
|
|
345
|
+
const items: AutocompleteItem[] = [];
|
|
346
|
+
for (const w of ["off", "default"]) {
|
|
347
|
+
if (w.startsWith(q)) items.push({ value: w, label: w });
|
|
348
|
+
}
|
|
349
|
+
for (const c of rankCandidates(completionModels, q).slice(0, 20)) {
|
|
350
|
+
items.push({ value: c, label: c });
|
|
351
|
+
}
|
|
352
|
+
return items.length ? items : null;
|
|
353
|
+
}
|
|
340
354
|
return null;
|
|
341
355
|
}
|
|
342
356
|
|
|
@@ -395,10 +409,28 @@ export function wrapGoodiesAutocomplete(
|
|
|
395
409
|
|
|
396
410
|
let autocompleteWrapped = false;
|
|
397
411
|
|
|
412
|
+
/**
|
|
413
|
+
* All catalogue models as "provider/id", stashed at session start for
|
|
414
|
+
* summary-model argument completion — completion callbacks are synchronous
|
|
415
|
+
* and context-free, so the registry must be captured beforehand. No auth
|
|
416
|
+
* filtering: getApiKeyAndHeaders can refresh OAuth tokens, far too heavy for
|
|
417
|
+
* keystrokes; a keyless pick hits the existing set-time warning instead.
|
|
418
|
+
*/
|
|
419
|
+
let completionModels: string[] = [];
|
|
420
|
+
|
|
421
|
+
export function __setCompletionModelsForTesting(models: string[]): void {
|
|
422
|
+
completionModels = models;
|
|
423
|
+
}
|
|
424
|
+
|
|
398
425
|
export default function goodies(pi: ExtensionAPI): void {
|
|
399
426
|
// Once per extension load: session_start also fires on /new, /resume, and
|
|
400
427
|
// /fork, where the first session's wrapper is still installed.
|
|
401
428
|
pi.on("session_start", (_event, ctx) => {
|
|
429
|
+
if (ctx.modelRegistry) {
|
|
430
|
+
completionModels = ctx.modelRegistry
|
|
431
|
+
.getAvailable()
|
|
432
|
+
.map((m) => `${m.provider}/${m.id}`);
|
|
433
|
+
}
|
|
402
434
|
if (autocompleteWrapped) return;
|
|
403
435
|
autocompleteWrapped = true;
|
|
404
436
|
ctx.ui.addAutocompleteProvider(wrapGoodiesAutocomplete);
|
package/package.json
CHANGED
package/review.ts
CHANGED
|
@@ -54,6 +54,8 @@ import {
|
|
|
54
54
|
SelectList,
|
|
55
55
|
Spacer,
|
|
56
56
|
Text,
|
|
57
|
+
type AutocompleteItem,
|
|
58
|
+
type AutocompleteProvider,
|
|
57
59
|
} from "@earendil-works/pi-tui";
|
|
58
60
|
import path from "node:path";
|
|
59
61
|
import { promises as fs } from "node:fs";
|
|
@@ -383,6 +385,27 @@ async function getLocalBranches(pi: ExtensionAPI): Promise<string[]> {
|
|
|
383
385
|
.filter((b) => b.trim());
|
|
384
386
|
}
|
|
385
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Get list of remote-tracking branches (e.g. origin/main, origin/master).
|
|
390
|
+
*
|
|
391
|
+
* Filters out the bare remote name (`origin`), the symbolic HEAD refs
|
|
392
|
+
* (origin/HEAD), and any non-origin remotes — origin is the conventional
|
|
393
|
+
* upstream and the only one the merge-base fallback path is meaningful for.
|
|
394
|
+
*/
|
|
395
|
+
async function getRemoteBranches(pi: ExtensionAPI): Promise<string[]> {
|
|
396
|
+
const { stdout, code } = await pi.exec("git", [
|
|
397
|
+
"branch",
|
|
398
|
+
"--remotes",
|
|
399
|
+
"--format=%(refname:short)",
|
|
400
|
+
]);
|
|
401
|
+
if (code !== 0) return [];
|
|
402
|
+
return stdout
|
|
403
|
+
.trim()
|
|
404
|
+
.split("\n")
|
|
405
|
+
.map((b) => b.trim())
|
|
406
|
+
.filter((b) => b.startsWith("origin/") && !b.endsWith("/HEAD"));
|
|
407
|
+
}
|
|
408
|
+
|
|
386
409
|
/**
|
|
387
410
|
* Get list of recent commits
|
|
388
411
|
*/
|
|
@@ -722,7 +745,7 @@ const REVIEW_PRESETS = [
|
|
|
722
745
|
{
|
|
723
746
|
value: "baseBranch",
|
|
724
747
|
label: "Review against a base branch",
|
|
725
|
-
description: "(local)",
|
|
748
|
+
description: "(local or origin/*)",
|
|
726
749
|
},
|
|
727
750
|
{ value: "commit", label: "Review a commit", description: "" },
|
|
728
751
|
{
|
|
@@ -742,6 +765,112 @@ type ReviewPresetValue =
|
|
|
742
765
|
| (typeof REVIEW_PRESETS)[number]["value"]
|
|
743
766
|
| typeof TOGGLE_CUSTOM_INSTRUCTIONS_VALUE;
|
|
744
767
|
|
|
768
|
+
// ── /review argument completion ─────────────────────────────────────────
|
|
769
|
+
|
|
770
|
+
const REVIEW_VERBS_WITH_ARGUMENT = new Set([
|
|
771
|
+
"branch",
|
|
772
|
+
"commit",
|
|
773
|
+
"folder",
|
|
774
|
+
"pr",
|
|
775
|
+
"--extra",
|
|
776
|
+
]);
|
|
777
|
+
const REVIEW_VERBS = [
|
|
778
|
+
"uncommitted",
|
|
779
|
+
"branch",
|
|
780
|
+
"commit",
|
|
781
|
+
"folder",
|
|
782
|
+
"pr",
|
|
783
|
+
"--extra",
|
|
784
|
+
];
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Complete /review arguments: the target verb (uncommitted/branch/commit/
|
|
788
|
+
* folder/pr) and the --extra flag. Values are refs, paths, or free text —
|
|
789
|
+
* no synchronous completion can do those justice, so returning null lets
|
|
790
|
+
* pi fall back to file completion (right for folder, harmless for refs).
|
|
791
|
+
*/
|
|
792
|
+
export function completeReviewArgument(
|
|
793
|
+
prefix: string,
|
|
794
|
+
): AutocompleteItem[] | null {
|
|
795
|
+
if (!/\s/.test(prefix)) {
|
|
796
|
+
const query = prefix.trim().toLowerCase();
|
|
797
|
+
const matches = REVIEW_VERBS.filter((w) => w.startsWith(query));
|
|
798
|
+
return matches.length
|
|
799
|
+
? matches.map((w) => ({
|
|
800
|
+
value: REVIEW_VERBS_WITH_ARGUMENT.has(w) ? `${w} ` : w,
|
|
801
|
+
label: w,
|
|
802
|
+
}))
|
|
803
|
+
: null;
|
|
804
|
+
}
|
|
805
|
+
const m = prefix.match(/^(\S+)\s+([\s\S]*)$/);
|
|
806
|
+
if (!m) return null;
|
|
807
|
+
const rest = m[2];
|
|
808
|
+
const tokens = rest.split(/\s+/).filter(Boolean);
|
|
809
|
+
const cur = /\s$/.test(rest) ? "" : (tokens[tokens.length - 1] ?? "");
|
|
810
|
+
// Only a partial flag completes past the verb; values stay free-form.
|
|
811
|
+
if (cur.startsWith("-")) {
|
|
812
|
+
return "--extra".startsWith(cur)
|
|
813
|
+
? [{ value: "--extra ", label: "--extra" }]
|
|
814
|
+
: null;
|
|
815
|
+
}
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** The argument text of a /review line, or null outside that context. */
|
|
820
|
+
function reviewArgumentText(
|
|
821
|
+
lines: string[],
|
|
822
|
+
cursorLine: number,
|
|
823
|
+
cursorCol: number,
|
|
824
|
+
): string | null {
|
|
825
|
+
if (cursorLine !== 0) return null;
|
|
826
|
+
const match = /^\/review\s+(.*)$/.exec((lines[0] ?? "").slice(0, cursorCol));
|
|
827
|
+
return match ? match[1] : null;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Pi's editor turns Tab in slash-command argument context into a forced file
|
|
832
|
+
* completion that never consults the command's getArgumentCompletions, so Tab
|
|
833
|
+
* after "/review " would list cwd paths. Claim that context and answer from
|
|
834
|
+
* the command's own completions — mirrors goodies' wrapGoodiesAutocomplete.
|
|
835
|
+
* Returning null (values, unknown flags) falls back to file completion.
|
|
836
|
+
*/
|
|
837
|
+
export function wrapReviewAutocomplete(
|
|
838
|
+
current: AutocompleteProvider,
|
|
839
|
+
): AutocompleteProvider {
|
|
840
|
+
return {
|
|
841
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
842
|
+
if (options.force) {
|
|
843
|
+
const argumentText = reviewArgumentText(lines, cursorLine, cursorCol);
|
|
844
|
+
if (argumentText !== null) {
|
|
845
|
+
const items = completeReviewArgument(argumentText);
|
|
846
|
+
return items ? { items, prefix: argumentText } : null;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
850
|
+
},
|
|
851
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
852
|
+
return current.applyCompletion(
|
|
853
|
+
lines,
|
|
854
|
+
cursorLine,
|
|
855
|
+
cursorCol,
|
|
856
|
+
item,
|
|
857
|
+
prefix,
|
|
858
|
+
);
|
|
859
|
+
},
|
|
860
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
861
|
+
if (reviewArgumentText(lines, cursorLine, cursorCol) !== null) {
|
|
862
|
+
return true;
|
|
863
|
+
}
|
|
864
|
+
return (
|
|
865
|
+
current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
|
|
866
|
+
true
|
|
867
|
+
);
|
|
868
|
+
},
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
let reviewAutocompleteWrapped = false;
|
|
873
|
+
|
|
745
874
|
export default function reviewExtension(pi: ExtensionAPI) {
|
|
746
875
|
function persistReviewSettings() {
|
|
747
876
|
pi.appendEntry(REVIEW_SETTINGS_TYPE, {
|
|
@@ -845,6 +974,22 @@ export default function reviewExtension(pi: ExtensionAPI) {
|
|
|
845
974
|
|
|
846
975
|
pi.on("session_start", (_event, ctx) => {
|
|
847
976
|
applyAllReviewState(ctx);
|
|
977
|
+
// Claim the /review forced-Tab context once per extension load (guarded:
|
|
978
|
+
// harness stubs and limited contexts lack addAutocompleteProvider).
|
|
979
|
+
const ui = (
|
|
980
|
+
ctx as {
|
|
981
|
+
ui?: {
|
|
982
|
+
addAutocompleteProvider?: (
|
|
983
|
+
factory: (current: AutocompleteProvider) => AutocompleteProvider,
|
|
984
|
+
) => void;
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
).ui;
|
|
988
|
+
const add = ui?.addAutocompleteProvider;
|
|
989
|
+
if (!reviewAutocompleteWrapped && typeof add === "function") {
|
|
990
|
+
reviewAutocompleteWrapped = true;
|
|
991
|
+
add(wrapReviewAutocomplete);
|
|
992
|
+
}
|
|
848
993
|
});
|
|
849
994
|
|
|
850
995
|
pi.on("session_tree", (_event, ctx) => {
|
|
@@ -1022,14 +1167,29 @@ export default function reviewExtension(pi: ExtensionAPI) {
|
|
|
1022
1167
|
async function showBranchSelector(
|
|
1023
1168
|
ctx: ExtensionContext,
|
|
1024
1169
|
): Promise<ReviewTarget | null> {
|
|
1025
|
-
const
|
|
1170
|
+
const [localBranches, remoteBranches] = await Promise.all([
|
|
1171
|
+
getLocalBranches(pi),
|
|
1172
|
+
getRemoteBranches(pi),
|
|
1173
|
+
]);
|
|
1026
1174
|
const currentBranch = await getCurrentBranch(pi);
|
|
1027
1175
|
const defaultBranch = await getDefaultBranch(pi);
|
|
1028
1176
|
|
|
1029
|
-
//
|
|
1177
|
+
// Combine local and remote-tracking branches. Dedupe by name (a local
|
|
1178
|
+
// branch and its origin/<name> counterpart are distinct refs, so both
|
|
1179
|
+
// stay — the user explicitly asked for origin/main | origin/master).
|
|
1180
|
+
const seen = new Set<string>();
|
|
1181
|
+
const combined = [...localBranches, ...remoteBranches].filter((b) => {
|
|
1182
|
+
if (seen.has(b)) return false;
|
|
1183
|
+
seen.add(b);
|
|
1184
|
+
return true;
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
// Never offer the current branch as a base branch (reviewing against
|
|
1188
|
+
// itself is meaningless). Remote-tracking branches are never equal to a
|
|
1189
|
+
// local current-branch name, so they pass through untouched.
|
|
1030
1190
|
const candidateBranches = currentBranch
|
|
1031
|
-
?
|
|
1032
|
-
:
|
|
1191
|
+
? combined.filter((b) => b !== currentBranch)
|
|
1192
|
+
: combined;
|
|
1033
1193
|
|
|
1034
1194
|
if (candidateBranches.length === 0) {
|
|
1035
1195
|
ctx.ui.notify(
|
|
@@ -1041,18 +1201,28 @@ export default function reviewExtension(pi: ExtensionAPI) {
|
|
|
1041
1201
|
return null;
|
|
1042
1202
|
}
|
|
1043
1203
|
|
|
1044
|
-
|
|
1204
|
+
const defaultRemote = `origin/${defaultBranch}`;
|
|
1205
|
+
|
|
1206
|
+
// Sort: default local branch first, then its origin/<default> counterpart,
|
|
1207
|
+
// then remaining locals, then remaining remotes — each group alphabetical.
|
|
1045
1208
|
const sortedBranches = candidateBranches.sort((a, b) => {
|
|
1046
1209
|
if (a === defaultBranch) return -1;
|
|
1047
1210
|
if (b === defaultBranch) return 1;
|
|
1211
|
+
if (a === defaultRemote) return -1;
|
|
1212
|
+
if (b === defaultRemote) return 1;
|
|
1213
|
+
const aRemote = a.startsWith("origin/");
|
|
1214
|
+
const bRemote = b.startsWith("origin/");
|
|
1215
|
+
if (aRemote !== bRemote) return aRemote ? 1 : -1;
|
|
1048
1216
|
return a.localeCompare(b);
|
|
1049
1217
|
});
|
|
1050
1218
|
|
|
1051
|
-
const items: SelectItem[] = sortedBranches.map((branch) =>
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1219
|
+
const items: SelectItem[] = sortedBranches.map((branch) => {
|
|
1220
|
+
let description = "";
|
|
1221
|
+
if (branch === defaultBranch) description = "(default)";
|
|
1222
|
+
else if (branch === defaultRemote) description = "(remote default)";
|
|
1223
|
+
else if (branch.startsWith("origin/")) description = "(remote)";
|
|
1224
|
+
return { value: branch, label: branch, description };
|
|
1225
|
+
});
|
|
1056
1226
|
|
|
1057
1227
|
const result = await ctx.ui.custom<string | null>(
|
|
1058
1228
|
(tui, theme, keybindings, done) => {
|
|
@@ -1534,6 +1704,7 @@ export default function reviewExtension(pi: ExtensionAPI) {
|
|
|
1534
1704
|
pi.registerCommand("review", {
|
|
1535
1705
|
description:
|
|
1536
1706
|
"Review code changes (PR, uncommitted, branch, commit, or folder)",
|
|
1707
|
+
getArgumentCompletions: completeReviewArgument,
|
|
1537
1708
|
handler: async (args, ctx) => {
|
|
1538
1709
|
if (!ctx.hasUI) {
|
|
1539
1710
|
ctx.ui.notify("Review requires interactive mode", "error");
|
package/vision-core.ts
CHANGED
|
@@ -214,6 +214,97 @@ export function parseVisionArgs(args: string): {
|
|
|
214
214
|
return { action: "set", values };
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
// --- argument completion -------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
export interface CompletionItem {
|
|
220
|
+
value: string;
|
|
221
|
+
label: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Sort candidates by match quality against q: startsWith, then contains,
|
|
225
|
+
* then alphabetical. Stable and predictable — completion must not jump.
|
|
226
|
+
* Shared by /vision and /goodies summary-model completion. */
|
|
227
|
+
export function rankCandidates(candidates: string[], q: string): string[] {
|
|
228
|
+
const lower = candidates.map((c) => ({ c, l: c.toLowerCase() }));
|
|
229
|
+
return lower
|
|
230
|
+
.map((e) => {
|
|
231
|
+
const rank = e.l.startsWith(q) ? 0 : e.l.includes(q) ? 1 : 2;
|
|
232
|
+
return { c: e.c, rank };
|
|
233
|
+
})
|
|
234
|
+
.filter((e) => e.rank < 2)
|
|
235
|
+
.sort((a, b) => a.rank - b.rank || a.c.localeCompare(b.c))
|
|
236
|
+
.map((e) => e.c);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const VISION_SUBCOMMANDS = ["set", "show", "status", "reset"];
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Complete /vision arguments from `candidates` (vision models as
|
|
243
|
+
* "provider/id"). Pure: the caller supplies candidates, so this is testable
|
|
244
|
+
* without a registry. Returns null when nothing matches (pi then falls back).
|
|
245
|
+
*
|
|
246
|
+
* Shapes completed:
|
|
247
|
+
* "" | "s" → subcommands ("set " keeps its trailing space)
|
|
248
|
+
* "set " → keys (model=, maxTokens=2000) + model candidates
|
|
249
|
+
* "set zai" → bare model candidates matching "zai"
|
|
250
|
+
* "set model=goog" → model= candidates
|
|
251
|
+
* "set model=x maxTo" → maxTokens=2000
|
|
252
|
+
* A model already given (model= or bare) suppresses further model items;
|
|
253
|
+
* maxTokens= given → null (nothing left to complete).
|
|
254
|
+
*/
|
|
255
|
+
export function completeVisionArgument(
|
|
256
|
+
prefix: string,
|
|
257
|
+
candidates: string[],
|
|
258
|
+
limit = 20,
|
|
259
|
+
): CompletionItem[] | null {
|
|
260
|
+
// No whitespace yet → completing the subcommand itself (empty and partial
|
|
261
|
+
// words alike; the verb regex below only makes sense past the first space).
|
|
262
|
+
if (!/\s/.test(prefix)) {
|
|
263
|
+
const query = prefix.trim();
|
|
264
|
+
const subs = VISION_SUBCOMMANDS.filter((s) => s.startsWith(query));
|
|
265
|
+
return subs.length
|
|
266
|
+
? subs.map((s) => ({ value: s === "set" ? "set " : s, label: s }))
|
|
267
|
+
: null;
|
|
268
|
+
}
|
|
269
|
+
const verbMatch = prefix.match(/^(\S+)\s+([\s\S]*)$/);
|
|
270
|
+
if (!verbMatch || verbMatch[1] !== "set") return null;
|
|
271
|
+
const rest = verbMatch[2] ?? "";
|
|
272
|
+
|
|
273
|
+
// Completed tokens vs the trailing partial ("" right after a space), so
|
|
274
|
+
// "set a/b " completes the next key instead of a second model.
|
|
275
|
+
const tokens = rest.split(/\s+/).filter(Boolean);
|
|
276
|
+
const trailingSpace = /\s$/.test(rest);
|
|
277
|
+
const cur = trailingSpace ? "" : (tokens[tokens.length - 1] ?? "");
|
|
278
|
+
const earlier = trailingSpace ? tokens : tokens.slice(0, -1);
|
|
279
|
+
|
|
280
|
+
const hasModel = earlier.some(
|
|
281
|
+
(t) => t.startsWith("model=") || !/^[a-zA-Z]+=/.test(t),
|
|
282
|
+
);
|
|
283
|
+
if (earlier.some((t) => t.startsWith("maxTokens="))) return null;
|
|
284
|
+
|
|
285
|
+
const q = cur.toLowerCase();
|
|
286
|
+
const items: CompletionItem[] = [];
|
|
287
|
+
if (cur.startsWith("model=")) {
|
|
288
|
+
if (!hasModel) {
|
|
289
|
+
for (const c of rankCandidates(candidates, cur.slice(6).toLowerCase()))
|
|
290
|
+
items.push({ value: `model=${c}`, label: `model=${c}` });
|
|
291
|
+
}
|
|
292
|
+
} else if (/^maxtokens/i.test(cur)) {
|
|
293
|
+
if ("maxtokens=2000".startsWith(q))
|
|
294
|
+
items.push({ value: "maxTokens=2000", label: "maxTokens=2000" });
|
|
295
|
+
} else {
|
|
296
|
+
if (!hasModel && "model=".startsWith(q))
|
|
297
|
+
items.push({ value: "model=", label: "model=" });
|
|
298
|
+
if ("maxtokens=2000".startsWith(q))
|
|
299
|
+
items.push({ value: "maxTokens=2000", label: "maxTokens=2000" });
|
|
300
|
+
if (!hasModel) {
|
|
301
|
+
for (const c of rankCandidates(candidates, q))
|
|
302
|
+
items.push({ value: c, label: c });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return items.length ? items.slice(0, limit) : null;
|
|
306
|
+
}
|
|
307
|
+
|
|
217
308
|
// --- model resolution ----------------------------------------------------------
|
|
218
309
|
|
|
219
310
|
/**
|
package/vision.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
|
24
24
|
import { Type } from "typebox";
|
|
25
25
|
import {
|
|
26
26
|
applyVisionToolVisibility,
|
|
27
|
+
completeVisionArgument,
|
|
27
28
|
configPath,
|
|
28
29
|
createConversationStore,
|
|
29
30
|
loadConfig,
|
|
@@ -37,14 +38,147 @@ import {
|
|
|
37
38
|
type ContentBlockLike,
|
|
38
39
|
type ModelLike,
|
|
39
40
|
} from "./vision-core.ts";
|
|
41
|
+
import type {
|
|
42
|
+
AutocompleteItem,
|
|
43
|
+
AutocompleteProvider,
|
|
44
|
+
} from "@earendil-works/pi-tui";
|
|
45
|
+
import {
|
|
46
|
+
createBurstRenderer,
|
|
47
|
+
isCleanTuiActive,
|
|
48
|
+
shortenPath,
|
|
49
|
+
} from "./clean-tui.ts";
|
|
40
50
|
|
|
41
51
|
/** Follow-up threads live per pi process: capped, in-memory, never persisted. */
|
|
42
52
|
const conversations = createConversationStore();
|
|
43
53
|
|
|
54
|
+
// ── /vision argument completion ─────────────────────────────────
|
|
55
|
+
// Image-capable models as "provider/id", stashed at session start — the
|
|
56
|
+
// completion callbacks are synchronous and receive no context, so the
|
|
57
|
+
// registry must be captured beforehand (same shape as clean-tui's stash).
|
|
58
|
+
let completionModels: string[] = [];
|
|
59
|
+
let autocompleteWrapped = false;
|
|
60
|
+
|
|
61
|
+
/** The argument text of a /vision line, or null outside that context. */
|
|
62
|
+
function visionArgumentText(
|
|
63
|
+
lines: string[],
|
|
64
|
+
cursorLine: number,
|
|
65
|
+
cursorCol: number,
|
|
66
|
+
): string | null {
|
|
67
|
+
if (cursorLine !== 0) return null;
|
|
68
|
+
const match = /^\/vision\s+(.*)$/.exec((lines[0] ?? "").slice(0, cursorCol));
|
|
69
|
+
return match ? match[1] : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pi's editor turns Tab in slash-command argument context into a forced file
|
|
74
|
+
* completion that never consults the command's getArgumentCompletions, so Tab
|
|
75
|
+
* after "/vision set " would list cwd paths. Claim that context and answer
|
|
76
|
+
* from the command's own completions — mirrors goodies' wrapGoodiesAutocomplete.
|
|
77
|
+
*/
|
|
78
|
+
export function wrapVisionAutocomplete(
|
|
79
|
+
current: AutocompleteProvider,
|
|
80
|
+
): AutocompleteProvider {
|
|
81
|
+
return {
|
|
82
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
83
|
+
if (options.force) {
|
|
84
|
+
const argumentText = visionArgumentText(lines, cursorLine, cursorCol);
|
|
85
|
+
if (argumentText !== null) {
|
|
86
|
+
const items = completeVisionArgument(argumentText, completionModels);
|
|
87
|
+
return items ? { items, prefix: argumentText } : null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
91
|
+
},
|
|
92
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
93
|
+
return current.applyCompletion(
|
|
94
|
+
lines,
|
|
95
|
+
cursorLine,
|
|
96
|
+
cursorCol,
|
|
97
|
+
item,
|
|
98
|
+
prefix,
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
102
|
+
if (visionArgumentText(lines, cursorLine, cursorCol) !== null) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return (
|
|
106
|
+
current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
|
|
107
|
+
true
|
|
108
|
+
);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Live completion for /vision arguments (non-forced path). */
|
|
114
|
+
function completeVisionArguments(prefix: string): AutocompleteItem[] | null {
|
|
115
|
+
return completeVisionArgument(prefix, completionModels);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function __setCompletionModelsForTesting(models: string[]): void {
|
|
119
|
+
completionModels = models;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** First line of a question, hard-capped — prompts can be long/multi-line. */
|
|
123
|
+
function questionPreview(prompt: unknown, cap: number): string {
|
|
124
|
+
const s = typeof prompt === "string" ? prompt : "";
|
|
125
|
+
if (!s) return "";
|
|
126
|
+
const nl = s.indexOf("\n");
|
|
127
|
+
let head = nl === -1 ? s : s.slice(0, nl);
|
|
128
|
+
if (head.length > cap) head = head.slice(0, cap - 1) + "…";
|
|
129
|
+
return head;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Answer text of a recorded vision result (its content is one text block). */
|
|
133
|
+
function answerText(result: unknown): string {
|
|
134
|
+
const content = (result as { content?: Array<{ text?: string }> } | undefined)
|
|
135
|
+
?.content;
|
|
136
|
+
return String(content?.[0]?.text ?? "").trim();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Burst-rendering spec (clean-tui style): header = path + question, the
|
|
140
|
+
* answer stays hidden until the row is expanded. Same skeleton the built-in
|
|
141
|
+
* tools use — attached only while clean-tui is active, else pi's default
|
|
142
|
+
* rendering. */
|
|
143
|
+
const visionBurstSpec = {
|
|
144
|
+
name: "vision",
|
|
145
|
+
bullet(entry: any, theme: any) {
|
|
146
|
+
const accent = (s: string) =>
|
|
147
|
+
theme.fg(entry.isError ? "error" : "accent", s);
|
|
148
|
+
const q = questionPreview(entry.args.prompt, 80);
|
|
149
|
+
const follow = entry.args.followUp ? theme.fg("muted", " (follow-up)") : "";
|
|
150
|
+
const label = `${accent(shortenPath(entry.args.path || "..."))}${follow}`;
|
|
151
|
+
return ` ${theme.fg("muted", "•")} ${label}${q ? theme.fg("toolOutput", ` — "${q}"`) : ""}`;
|
|
152
|
+
},
|
|
153
|
+
groupedDetails(entries: any[], theme: any) {
|
|
154
|
+
return entries
|
|
155
|
+
.map((e) => {
|
|
156
|
+
const label = `— ${shortenPath(e.args.path || "...")}${e.args.followUp ? " (follow-up)" : ""}: "${questionPreview(e.args.prompt, 90)}"`;
|
|
157
|
+
if (!e.result) return `\n${theme.fg("warning", `${label} (pending)`)}`;
|
|
158
|
+
const txt = answerText(e.result);
|
|
159
|
+
if (!txt) return `\n${theme.fg("muted", label)}`;
|
|
160
|
+
return `\n${theme.fg("muted", label)}\n${e.isError ? theme.fg("error", txt) : theme.fg("toolOutput", txt)}`;
|
|
161
|
+
})
|
|
162
|
+
.join("");
|
|
163
|
+
},
|
|
164
|
+
soloHeader(args: any, theme: any) {
|
|
165
|
+
const follow = args.followUp ? theme.fg("muted", " (follow-up)") : "";
|
|
166
|
+
const q = questionPreview(args.prompt, 90);
|
|
167
|
+
return `${theme.fg("toolTitle", theme.bold("vision"))} ${theme.fg("accent", shortenPath(args.path || "..."))}${follow}${q ? ` ${theme.fg("toolOutput", `"${q}"`)}` : ""}`;
|
|
168
|
+
},
|
|
169
|
+
soloExpanded(entry: any, _args: any, theme: any) {
|
|
170
|
+
if (!entry.result) return "";
|
|
171
|
+
const txt = answerText(entry.result);
|
|
172
|
+
if (!txt) return "";
|
|
173
|
+
return entry.isError ? theme.fg("error", txt) : theme.fg("toolOutput", txt);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
44
177
|
export default function (pi: ExtensionAPI): void {
|
|
45
178
|
pi.registerCommand("vision", {
|
|
46
179
|
description:
|
|
47
180
|
"Configure the vision tool (set/show/reset the vision model used for image Q&A)",
|
|
181
|
+
getArgumentCompletions: completeVisionArguments,
|
|
48
182
|
handler: async (args, ctx) => {
|
|
49
183
|
let parsed: ReturnType<typeof parseVisionArgs>;
|
|
50
184
|
try {
|
|
@@ -116,6 +250,30 @@ export default function (pi: ExtensionAPI): void {
|
|
|
116
250
|
}
|
|
117
251
|
pi.on("session_start", (_event, ctx) => {
|
|
118
252
|
syncVisionToolVisibility(ctx.model);
|
|
253
|
+
// Stash image-capable models for argument completion (only when a
|
|
254
|
+
// registry exists — stub contexts without one keep the previous stash).
|
|
255
|
+
if (ctx.modelRegistry) {
|
|
256
|
+
completionModels = ctx.modelRegistry
|
|
257
|
+
.getAvailable()
|
|
258
|
+
.filter(modelSupportsImages)
|
|
259
|
+
.map((m) => `${m.provider}/${m.id}`);
|
|
260
|
+
}
|
|
261
|
+
// Claim the /vision forced-Tab context once per extension load (guarded:
|
|
262
|
+
// harness stubs and limited contexts lack addAutocompleteProvider).
|
|
263
|
+
const ui = (
|
|
264
|
+
ctx as {
|
|
265
|
+
ui?: {
|
|
266
|
+
addAutocompleteProvider?: (
|
|
267
|
+
factory: (current: AutocompleteProvider) => AutocompleteProvider,
|
|
268
|
+
) => void;
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
).ui;
|
|
272
|
+
const add = ui?.addAutocompleteProvider;
|
|
273
|
+
if (!autocompleteWrapped && typeof add === "function") {
|
|
274
|
+
autocompleteWrapped = true;
|
|
275
|
+
add(wrapVisionAutocomplete);
|
|
276
|
+
}
|
|
119
277
|
});
|
|
120
278
|
pi.on("model_select", (event) => {
|
|
121
279
|
syncVisionToolVisibility(event.model);
|
|
@@ -148,6 +306,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
148
306
|
}),
|
|
149
307
|
),
|
|
150
308
|
}),
|
|
309
|
+
// Burst-style rows while clean-tui is active (flag is settled before this
|
|
310
|
+
// loads — index.ts registers clean-tui first); pi's default rendering
|
|
311
|
+
// otherwise. Read at registration time, like the pi-codex contract.
|
|
312
|
+
...(isCleanTuiActive() ? createBurstRenderer(visionBurstSpec) : {}),
|
|
151
313
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
152
314
|
// Delegate image loading to pi's own read tool: photon resize,
|
|
153
315
|
// magic-byte mime detection, size caps — battle-tested behavior.
|