bermudis-pi-goodies 0.27.3 → 0.27.4

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
@@ -8,11 +8,11 @@ extensions. One entry point, thirteen independent features.
8
8
  | `copy-with-model` | `/copy-with-model` | Copy last assistant message to the clipboard in a code fence tagged with the model name. |
9
9
  | `copy-trajectory` | `/copy-trajectory [thinking]` | Copy the whole conversation (user + assistant text, tool calls stripped) to the clipboard; `thinking` also includes assistant thinking blocks. |
10
10
  | `name-with-ai` | `/name-with-ai [name]` | Generate a short session name from the first user message (or set one manually). |
11
- | `prefer-tools` | hook (no command) | Blocks legacy tools: `rm` → `trash`, bare `python`/`pip`/`pytest`/`mypy` → `uv`. On `glm-5.*` models it also blocks bash `rg` (they invent ripgrep flags) — the built-in grep tool still works. |
11
+ | `prefer-tools` | hook (no command) | Blocks legacy tools: `rm` → `trash <path>` (and `trash-rm`/`trash-empty`, which delete permanently), bare `python`/`pip`/`pytest`/`mypy` → `uv`. On `glm-5.*` models it also blocks bash `rg` (they invent ripgrep flags) — the built-in grep tool still works. |
12
12
  | `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. |
13
13
  | `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. |
14
14
  | `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". |
15
- | `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. |
15
+ | `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. Works in the terminal and in RPC hosts like T3 Code (plain selectors replace the search-as-you-type ones outside the TUI). |
16
16
  | `kilo` | provider | Access Kilo Gateway models via `/login kilo` or `KILO_API_KEY`. |
17
17
  | `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. |
18
18
  | `tps` | hook (no command) | Notify tokens/sec and in/out/cache token usage at the end of each agent turn. |
@@ -25,7 +25,7 @@ extensions. One entry point, thirteen independent features.
25
25
  First install, pinned to a published version:
26
26
 
27
27
  ```bash
28
- pi install npm:bermudis-pi-goodies@0.27.3
28
+ pi install npm:bermudis-pi-goodies@0.27.4
29
29
  ```
30
30
 
31
31
  Picking up a new release on an existing install (`pi install` is not the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.27.3",
3
+ "version": "0.27.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/bermudi/agent-extensions.git",
package/prefer-tools.ts CHANGED
@@ -10,7 +10,9 @@
10
10
  * Env-var assignment prefixes (`FOO=bar rm x`) are recognized and skipped so
11
11
  * the real command that follows them is still checked.
12
12
  *
13
- * rm -> trash
13
+ * rm -> trash <path> (moves to the trash can; recoverable)
14
+ * trash-rm -> blocked (permanently deletes from the trash can)
15
+ * trash-empty -> blocked (permanently empties the trash can)
14
16
  * python/pip/pytest/ -> uv
15
17
  * mypy
16
18
  *
@@ -31,7 +33,13 @@ interface Rule {
31
33
  const RULES: Rule[] = [
32
34
  {
33
35
  names: ["rm"],
34
- reason: "rm is blocked — use `trash` instead (recoverable beats gone)",
36
+ reason:
37
+ "rm is blocked — use `trash <path>` instead (moves to the trash can; recoverable beats gone). Do NOT use `trash-rm`: it deletes permanently from the trash can.",
38
+ },
39
+ {
40
+ names: ["trash-rm", "trash-empty"],
41
+ reason:
42
+ "trash-rm and trash-empty are blocked — they delete permanently from the trash can. Use `trash <path>` to move files to the trash (recoverable).",
35
43
  },
36
44
  {
37
45
  names: ["python", "python3", "pip", "pip3", "pytest", "mypy"],
package/review.ts CHANGED
@@ -48,6 +48,12 @@
48
48
  * after the abort is discarded and the review state is cleared, so a stale
49
49
  * {active: true} entry can't resurrect the review widget (pi exposes no
50
50
  * cancellation for an in-flight navigateTree summarization)
51
+ * - RPC-host compatibility (T3 Code drives Pi with --mode rpc, where
52
+ * ctx.ui.custom resolves to undefined and /review silently cancelled):
53
+ * the preset, base-branch, and commit selectors fall back to plain
54
+ * ctx.ui.select dialogs when ctx.mode !== "tui", the end-review loader
55
+ * only renders in TUI mode, and the review widget renders as string
56
+ * lines outside the TUI
51
57
  */
52
58
 
53
59
  import type {
@@ -102,6 +108,15 @@ function setReviewWidget(ctx: ExtensionContext, active: boolean) {
102
108
  return;
103
109
  }
104
110
 
111
+ // Component factories only render in the TUI; RPC hosts like T3 Code show
112
+ // the plain string lines instead.
113
+ if (ctx.mode !== "tui") {
114
+ ctx.ui.setWidget("review", [
115
+ "Review session active, return with /end-review",
116
+ ]);
117
+ return;
118
+ }
119
+
105
120
  ctx.ui.setWidget("review", (_tui, theme) => {
106
121
  const message = "Review session active, return with /end-review";
107
122
  const text = new Text(theme.fg("warning", message), 0, 0);
@@ -846,6 +861,37 @@ function getUserFacingHint(target: ReviewTarget): string {
846
861
  }
847
862
  }
848
863
 
864
+ /**
865
+ * One ctx.ui.select option: the plain dialog has no description column, so
866
+ * the description rides inline in the label.
867
+ */
868
+ export function selectOptionLabel(label: string, description?: string): string {
869
+ const trimmed = description?.trim();
870
+ return trimmed ? `${label} ${trimmed}` : label;
871
+ }
872
+
873
+ /**
874
+ * Build ctx.ui.select options from value/label/description items and a
875
+ * lookup that maps a chosen option back to its value. Used by the selectors'
876
+ * non-TUI fallbacks, where ctx.ui.custom components can't render (RPC hosts
877
+ * like T3 Code) and only plain dialogs are available. The recommended index
878
+ * gets a " (recommended)" suffix since plain dialogs can't preselect.
879
+ */
880
+ export function buildSelectOptions<T extends string>(
881
+ items: ReadonlyArray<{ value: T; label: string; description?: string }>,
882
+ recommendedIndex = -1,
883
+ ): { options: string[]; valueFor: (option: string) => T | undefined } {
884
+ const valueByOption = new Map<string, T>();
885
+ const options = items.map((item, index) => {
886
+ const option = selectOptionLabel(item.label, item.description);
887
+ const marked =
888
+ index === recommendedIndex ? `${option} (recommended)` : option;
889
+ valueByOption.set(marked, item.value);
890
+ return marked;
891
+ });
892
+ return { options, valueFor: (option: string) => valueByOption.get(option) };
893
+ }
894
+
849
895
  // Review preset options for the selector (keep this order stable)
850
896
  const REVIEW_PRESETS = [
851
897
  {
@@ -1162,56 +1208,79 @@ export default function reviewExtension(pi: ExtensionAPI) {
1162
1208
  },
1163
1209
  ];
1164
1210
 
1165
- const result = await ctx.ui.custom<ReviewPresetValue | null>(
1166
- (tui, theme, _kb, done) => {
1167
- const container = new Container();
1168
- container.addChild(
1169
- new DynamicBorder((str) => theme.fg("accent", str)),
1170
- );
1171
- container.addChild(
1172
- new Text(theme.fg("accent", theme.bold("Select a review preset"))),
1173
- );
1211
+ let result: ReviewPresetValue | null;
1212
+ if (ctx.mode === "tui") {
1213
+ result = await ctx.ui.custom<ReviewPresetValue | null>(
1214
+ (tui, theme, _kb, done) => {
1215
+ const container = new Container();
1216
+ container.addChild(
1217
+ new DynamicBorder((str) => theme.fg("accent", str)),
1218
+ );
1219
+ container.addChild(
1220
+ new Text(
1221
+ theme.fg("accent", theme.bold("Select a review preset")),
1222
+ ),
1223
+ );
1174
1224
 
1175
- const selectList = new SelectList(items, Math.min(items.length, 10), {
1176
- selectedPrefix: (text) => theme.fg("accent", text),
1177
- selectedText: (text) => theme.fg("accent", text),
1178
- description: (text) => theme.fg("muted", text),
1179
- scrollInfo: (text) => theme.fg("dim", text),
1180
- noMatch: (text) => theme.fg("warning", text),
1181
- });
1225
+ const selectList = new SelectList(
1226
+ items,
1227
+ Math.min(items.length, 10),
1228
+ {
1229
+ selectedPrefix: (text) => theme.fg("accent", text),
1230
+ selectedText: (text) => theme.fg("accent", text),
1231
+ description: (text) => theme.fg("muted", text),
1232
+ scrollInfo: (text) => theme.fg("dim", text),
1233
+ noMatch: (text) => theme.fg("warning", text),
1234
+ },
1235
+ );
1182
1236
 
1183
- // Preselect the smart default without reordering the list
1184
- if (smartDefaultIndex >= 0) {
1185
- selectList.setSelectedIndex(smartDefaultIndex);
1186
- }
1237
+ // Preselect the smart default without reordering the list
1238
+ if (smartDefaultIndex >= 0) {
1239
+ selectList.setSelectedIndex(smartDefaultIndex);
1240
+ }
1187
1241
 
1188
- selectList.onSelect = (item) => done(item.value as ReviewPresetValue);
1189
- selectList.onCancel = () => done(null);
1242
+ selectList.onSelect = (item) =>
1243
+ done(item.value as ReviewPresetValue);
1244
+ selectList.onCancel = () => done(null);
1190
1245
 
1191
- container.addChild(selectList);
1192
- container.addChild(
1193
- new Text(
1194
- theme.fg("dim", "Press enter to confirm or esc to go back"),
1195
- ),
1196
- );
1197
- container.addChild(
1198
- new DynamicBorder((str) => theme.fg("accent", str)),
1199
- );
1246
+ container.addChild(selectList);
1247
+ container.addChild(
1248
+ new Text(
1249
+ theme.fg("dim", "Press enter to confirm or esc to go back"),
1250
+ ),
1251
+ );
1252
+ container.addChild(
1253
+ new DynamicBorder((str) => theme.fg("accent", str)),
1254
+ );
1200
1255
 
1201
- return {
1202
- render(width: number) {
1203
- return container.render(width);
1204
- },
1205
- invalidate() {
1206
- container.invalidate();
1207
- },
1208
- handleInput(data: string) {
1209
- selectList.handleInput(data);
1210
- tui.requestRender();
1211
- },
1212
- };
1213
- },
1214
- );
1256
+ return {
1257
+ render(width: number) {
1258
+ return container.render(width);
1259
+ },
1260
+ invalidate() {
1261
+ container.invalidate();
1262
+ },
1263
+ handleInput(data: string) {
1264
+ selectList.handleInput(data);
1265
+ tui.requestRender();
1266
+ },
1267
+ };
1268
+ },
1269
+ );
1270
+ } else {
1271
+ // Outside the TUI (RPC hosts like T3 Code) ctx.ui.custom resolves to
1272
+ // undefined, so use the plain select dialog, which works everywhere.
1273
+ const { options, valueFor } = buildSelectOptions<ReviewPresetValue>(
1274
+ items.map((item) => ({
1275
+ value: item.value as ReviewPresetValue,
1276
+ label: item.label,
1277
+ description: item.description,
1278
+ })),
1279
+ smartDefaultIndex,
1280
+ );
1281
+ const selected = await ctx.ui.select("Select a review preset", options);
1282
+ result = selected === undefined ? null : (valueFor(selected) ?? null);
1283
+ }
1215
1284
 
1216
1285
  if (!result) return null;
1217
1286
 
@@ -1335,102 +1404,119 @@ export default function reviewExtension(pi: ExtensionAPI) {
1335
1404
  return { value: branch, label: branch, description };
1336
1405
  });
1337
1406
 
1338
- const result = await ctx.ui.custom<string | null>(
1339
- (tui, theme, keybindings, done) => {
1340
- const container = new Container();
1341
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1342
- container.addChild(
1343
- new Text(theme.fg("accent", theme.bold("Select base branch"))),
1344
- );
1407
+ let result: string | null;
1408
+ if (ctx.mode === "tui") {
1409
+ result = await ctx.ui.custom<string | null>(
1410
+ (tui, theme, keybindings, done) => {
1411
+ const container = new Container();
1412
+ container.addChild(
1413
+ new DynamicBorder((str) => theme.fg("accent", str)),
1414
+ );
1415
+ container.addChild(
1416
+ new Text(theme.fg("accent", theme.bold("Select base branch"))),
1417
+ );
1345
1418
 
1346
- const searchInput = new Input();
1347
- container.addChild(searchInput);
1348
- container.addChild(new Spacer(1));
1419
+ const searchInput = new Input();
1420
+ container.addChild(searchInput);
1421
+ container.addChild(new Spacer(1));
1349
1422
 
1350
- const listContainer = new Container();
1351
- container.addChild(listContainer);
1352
- container.addChild(
1353
- new Text(
1354
- theme.fg("dim", "Type to filter • enter to select • esc to cancel"),
1355
- ),
1356
- );
1357
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1423
+ const listContainer = new Container();
1424
+ container.addChild(listContainer);
1425
+ container.addChild(
1426
+ new Text(
1427
+ theme.fg(
1428
+ "dim",
1429
+ "Type to filter • enter to select • esc to cancel",
1430
+ ),
1431
+ ),
1432
+ );
1433
+ container.addChild(
1434
+ new DynamicBorder((str) => theme.fg("accent", str)),
1435
+ );
1358
1436
 
1359
- let filteredItems = items;
1360
- let selectList: SelectList | null = null;
1437
+ let filteredItems = items;
1438
+ let selectList: SelectList | null = null;
1361
1439
 
1362
- const updateList = () => {
1363
- listContainer.clear();
1364
- if (filteredItems.length === 0) {
1365
- listContainer.addChild(
1366
- new Text(theme.fg("warning", " No matching branches")),
1367
- );
1368
- selectList = null;
1369
- return;
1370
- }
1440
+ const updateList = () => {
1441
+ listContainer.clear();
1442
+ if (filteredItems.length === 0) {
1443
+ listContainer.addChild(
1444
+ new Text(theme.fg("warning", " No matching branches")),
1445
+ );
1446
+ selectList = null;
1447
+ return;
1448
+ }
1371
1449
 
1372
- selectList = new SelectList(
1373
- filteredItems,
1374
- Math.min(filteredItems.length, 10),
1375
- {
1376
- selectedPrefix: (text) => theme.fg("accent", text),
1377
- selectedText: (text) => theme.fg("accent", text),
1378
- description: (text) => theme.fg("muted", text),
1379
- scrollInfo: (text) => theme.fg("dim", text),
1380
- noMatch: (text) => theme.fg("warning", text),
1381
- },
1382
- );
1450
+ selectList = new SelectList(
1451
+ filteredItems,
1452
+ Math.min(filteredItems.length, 10),
1453
+ {
1454
+ selectedPrefix: (text) => theme.fg("accent", text),
1455
+ selectedText: (text) => theme.fg("accent", text),
1456
+ description: (text) => theme.fg("muted", text),
1457
+ scrollInfo: (text) => theme.fg("dim", text),
1458
+ noMatch: (text) => theme.fg("warning", text),
1459
+ },
1460
+ );
1383
1461
 
1384
- selectList.onSelect = (item) => done(item.value);
1385
- selectList.onCancel = () => done(null);
1386
- listContainer.addChild(selectList);
1387
- };
1462
+ selectList.onSelect = (item) => done(item.value);
1463
+ selectList.onCancel = () => done(null);
1464
+ listContainer.addChild(selectList);
1465
+ };
1388
1466
 
1389
- const applyFilter = () => {
1390
- const query = searchInput.getValue();
1391
- filteredItems = query
1392
- ? fuzzyFilter(
1393
- items,
1394
- query,
1395
- (item) =>
1396
- `${item.label} ${item.value} ${item.description ?? ""}`,
1397
- )
1398
- : items;
1399
- updateList();
1400
- };
1467
+ const applyFilter = () => {
1468
+ const query = searchInput.getValue();
1469
+ filteredItems = query
1470
+ ? fuzzyFilter(
1471
+ items,
1472
+ query,
1473
+ (item) =>
1474
+ `${item.label} ${item.value} ${item.description ?? ""}`,
1475
+ )
1476
+ : items;
1477
+ updateList();
1478
+ };
1401
1479
 
1402
- applyFilter();
1480
+ applyFilter();
1403
1481
 
1404
- return {
1405
- render(width: number) {
1406
- return container.render(width);
1407
- },
1408
- invalidate() {
1409
- container.invalidate();
1410
- },
1411
- handleInput(data: string) {
1412
- if (
1413
- keybindings.matches(data, "tui.select.up") ||
1414
- keybindings.matches(data, "tui.select.down") ||
1415
- keybindings.matches(data, "tui.select.confirm") ||
1416
- keybindings.matches(data, "tui.select.cancel")
1417
- ) {
1418
- if (selectList) {
1419
- selectList.handleInput(data);
1420
- } else if (keybindings.matches(data, "tui.select.cancel")) {
1421
- done(null);
1482
+ return {
1483
+ render(width: number) {
1484
+ return container.render(width);
1485
+ },
1486
+ invalidate() {
1487
+ container.invalidate();
1488
+ },
1489
+ handleInput(data: string) {
1490
+ if (
1491
+ keybindings.matches(data, "tui.select.up") ||
1492
+ keybindings.matches(data, "tui.select.down") ||
1493
+ keybindings.matches(data, "tui.select.confirm") ||
1494
+ keybindings.matches(data, "tui.select.cancel")
1495
+ ) {
1496
+ if (selectList) {
1497
+ selectList.handleInput(data);
1498
+ } else if (keybindings.matches(data, "tui.select.cancel")) {
1499
+ done(null);
1500
+ }
1501
+ tui.requestRender();
1502
+ return;
1422
1503
  }
1423
- tui.requestRender();
1424
- return;
1425
- }
1426
1504
 
1427
- searchInput.handleInput(data);
1428
- applyFilter();
1429
- tui.requestRender();
1430
- },
1431
- };
1432
- },
1433
- );
1505
+ searchInput.handleInput(data);
1506
+ applyFilter();
1507
+ tui.requestRender();
1508
+ },
1509
+ };
1510
+ },
1511
+ );
1512
+ } else {
1513
+ // Outside the TUI (RPC hosts like T3 Code) ctx.ui.custom resolves to
1514
+ // undefined, so offer the branches as a plain select dialog. The
1515
+ // fuzzy filter is lost — descriptions ride inline in the labels.
1516
+ const { options, valueFor } = buildSelectOptions(items);
1517
+ const selected = await ctx.ui.select("Select base branch", options);
1518
+ result = selected === undefined ? null : (valueFor(selected) ?? null);
1519
+ }
1434
1520
 
1435
1521
  if (!result) return null;
1436
1522
  return { type: "baseBranch", branch: result };
@@ -1455,109 +1541,129 @@ export default function reviewExtension(pi: ExtensionAPI) {
1455
1541
  description: "",
1456
1542
  }));
1457
1543
 
1458
- const result = await ctx.ui.custom<{ sha: string; title: string } | null>(
1459
- (tui, theme, keybindings, done) => {
1460
- const container = new Container();
1461
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1462
- container.addChild(
1463
- new Text(theme.fg("accent", theme.bold("Select commit to review"))),
1464
- );
1544
+ let result: { sha: string; title: string } | null;
1545
+ if (ctx.mode === "tui") {
1546
+ result = await ctx.ui.custom<{ sha: string; title: string } | null>(
1547
+ (tui, theme, keybindings, done) => {
1548
+ const container = new Container();
1549
+ container.addChild(
1550
+ new DynamicBorder((str) => theme.fg("accent", str)),
1551
+ );
1552
+ container.addChild(
1553
+ new Text(theme.fg("accent", theme.bold("Select commit to review"))),
1554
+ );
1465
1555
 
1466
- const searchInput = new Input();
1467
- container.addChild(searchInput);
1468
- container.addChild(new Spacer(1));
1556
+ const searchInput = new Input();
1557
+ container.addChild(searchInput);
1558
+ container.addChild(new Spacer(1));
1469
1559
 
1470
- const listContainer = new Container();
1471
- container.addChild(listContainer);
1472
- container.addChild(
1473
- new Text(
1474
- theme.fg("dim", "Type to filter • enter to select • esc to cancel"),
1475
- ),
1476
- );
1477
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1560
+ const listContainer = new Container();
1561
+ container.addChild(listContainer);
1562
+ container.addChild(
1563
+ new Text(
1564
+ theme.fg(
1565
+ "dim",
1566
+ "Type to filter • enter to select • esc to cancel",
1567
+ ),
1568
+ ),
1569
+ );
1570
+ container.addChild(
1571
+ new DynamicBorder((str) => theme.fg("accent", str)),
1572
+ );
1478
1573
 
1479
- let filteredItems = items;
1480
- let selectList: SelectList | null = null;
1574
+ let filteredItems = items;
1575
+ let selectList: SelectList | null = null;
1481
1576
 
1482
- const updateList = () => {
1483
- listContainer.clear();
1484
- if (filteredItems.length === 0) {
1485
- listContainer.addChild(
1486
- new Text(theme.fg("warning", " No matching commits")),
1487
- );
1488
- selectList = null;
1489
- return;
1490
- }
1577
+ const updateList = () => {
1578
+ listContainer.clear();
1579
+ if (filteredItems.length === 0) {
1580
+ listContainer.addChild(
1581
+ new Text(theme.fg("warning", " No matching commits")),
1582
+ );
1583
+ selectList = null;
1584
+ return;
1585
+ }
1491
1586
 
1492
- selectList = new SelectList(
1493
- filteredItems,
1494
- Math.min(filteredItems.length, 10),
1495
- {
1496
- selectedPrefix: (text) => theme.fg("accent", text),
1497
- selectedText: (text) => theme.fg("accent", text),
1498
- description: (text) => theme.fg("muted", text),
1499
- scrollInfo: (text) => theme.fg("dim", text),
1500
- noMatch: (text) => theme.fg("warning", text),
1501
- },
1502
- );
1587
+ selectList = new SelectList(
1588
+ filteredItems,
1589
+ Math.min(filteredItems.length, 10),
1590
+ {
1591
+ selectedPrefix: (text) => theme.fg("accent", text),
1592
+ selectedText: (text) => theme.fg("accent", text),
1593
+ description: (text) => theme.fg("muted", text),
1594
+ scrollInfo: (text) => theme.fg("dim", text),
1595
+ noMatch: (text) => theme.fg("warning", text),
1596
+ },
1597
+ );
1503
1598
 
1504
- selectList.onSelect = (item) => {
1505
- const commit = commits.find((c) => c.sha === item.value);
1506
- if (commit) {
1507
- done(commit);
1508
- } else {
1509
- done(null);
1510
- }
1599
+ selectList.onSelect = (item) => {
1600
+ const commit = commits.find((c) => c.sha === item.value);
1601
+ if (commit) {
1602
+ done(commit);
1603
+ } else {
1604
+ done(null);
1605
+ }
1606
+ };
1607
+ selectList.onCancel = () => done(null);
1608
+ listContainer.addChild(selectList);
1511
1609
  };
1512
- selectList.onCancel = () => done(null);
1513
- listContainer.addChild(selectList);
1514
- };
1515
1610
 
1516
- const applyFilter = () => {
1517
- const query = searchInput.getValue();
1518
- filteredItems = query
1519
- ? fuzzyFilter(
1520
- items,
1521
- query,
1522
- (item) =>
1523
- `${item.label} ${item.value} ${item.description ?? ""}`,
1524
- )
1525
- : items;
1526
- updateList();
1527
- };
1611
+ const applyFilter = () => {
1612
+ const query = searchInput.getValue();
1613
+ filteredItems = query
1614
+ ? fuzzyFilter(
1615
+ items,
1616
+ query,
1617
+ (item) =>
1618
+ `${item.label} ${item.value} ${item.description ?? ""}`,
1619
+ )
1620
+ : items;
1621
+ updateList();
1622
+ };
1528
1623
 
1529
- applyFilter();
1624
+ applyFilter();
1530
1625
 
1531
- return {
1532
- render(width: number) {
1533
- return container.render(width);
1534
- },
1535
- invalidate() {
1536
- container.invalidate();
1537
- },
1538
- handleInput(data: string) {
1539
- if (
1540
- keybindings.matches(data, "tui.select.up") ||
1541
- keybindings.matches(data, "tui.select.down") ||
1542
- keybindings.matches(data, "tui.select.confirm") ||
1543
- keybindings.matches(data, "tui.select.cancel")
1544
- ) {
1545
- if (selectList) {
1546
- selectList.handleInput(data);
1547
- } else if (keybindings.matches(data, "tui.select.cancel")) {
1548
- done(null);
1626
+ return {
1627
+ render(width: number) {
1628
+ return container.render(width);
1629
+ },
1630
+ invalidate() {
1631
+ container.invalidate();
1632
+ },
1633
+ handleInput(data: string) {
1634
+ if (
1635
+ keybindings.matches(data, "tui.select.up") ||
1636
+ keybindings.matches(data, "tui.select.down") ||
1637
+ keybindings.matches(data, "tui.select.confirm") ||
1638
+ keybindings.matches(data, "tui.select.cancel")
1639
+ ) {
1640
+ if (selectList) {
1641
+ selectList.handleInput(data);
1642
+ } else if (keybindings.matches(data, "tui.select.cancel")) {
1643
+ done(null);
1644
+ }
1645
+ tui.requestRender();
1646
+ return;
1549
1647
  }
1550
- tui.requestRender();
1551
- return;
1552
- }
1553
1648
 
1554
- searchInput.handleInput(data);
1555
- applyFilter();
1556
- tui.requestRender();
1557
- },
1558
- };
1559
- },
1560
- );
1649
+ searchInput.handleInput(data);
1650
+ applyFilter();
1651
+ tui.requestRender();
1652
+ },
1653
+ };
1654
+ },
1655
+ );
1656
+ } else {
1657
+ // Outside the TUI (RPC hosts like T3 Code) ctx.ui.custom resolves to
1658
+ // undefined, so offer the recent commits as a plain select dialog.
1659
+ const { options, valueFor } = buildSelectOptions(
1660
+ items.map((item) => ({ value: item.value, label: item.label })),
1661
+ );
1662
+ const selected = await ctx.ui.select("Select commit to review", options);
1663
+ const sha = selected === undefined ? undefined : valueFor(selected);
1664
+ const commit = commits.find((c) => c.sha === sha);
1665
+ result = commit ? { sha: commit.sha, title: commit.title } : null;
1666
+ }
1561
1667
 
1562
1668
  if (!result) return null;
1563
1669
  return { type: "commit", sha: result.sha, title: result.title };
@@ -1932,7 +2038,9 @@ Instructions:
1932
2038
  originId: string,
1933
2039
  showLoader: boolean,
1934
2040
  ): Promise<{ cancelled: boolean; error?: string } | null> {
1935
- if (showLoader && ctx.hasUI) {
2041
+ // The loader is a ctx.ui.custom component, which only renders in the
2042
+ // TUI; RPC hosts (T3 Code) get undefined back, so there navigate directly.
2043
+ if (showLoader && ctx.mode === "tui") {
1936
2044
  return ctx.ui.custom<{ cancelled: boolean; error?: string } | null>(
1937
2045
  (tui, theme, _kb, done) => {
1938
2046
  const loader = new BorderedLoader(