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 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.22.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. Config lives in
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
- // Every burst tool (read/bash/write/edit/find/grep/ls) shares one render
1641
- // skeleton: upsert → burst → leader/follower split → grouped box or solo
1642
- // box. Per-tool behavior is a spec of formatters. This deletes ~450 lines of
1643
- // copy-paste and, more importantly, makes the divergence class impossible:
1644
- // the "one failed row paints the whole burst red" bug was fixed twice for
1645
- // bash (b80a14d, then the isGrouped?false rule) but the other six tools still
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
- renderShell: "self",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/bermudi/agent-extensions.git",
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 branches = await getLocalBranches(pi);
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
- // Never offer the current branch as a base branch (reviewing against itself is meaningless).
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
- ? branches.filter((b) => b !== currentBranch)
1032
- : branches;
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
- // Sort branches with default branch first
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
- value: branch,
1053
- label: branch,
1054
- description: branch === defaultBranch ? "(default)" : "",
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.