bermudis-pi-goodies 0.27.2 → 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 +3 -4
- package/goodies.ts +0 -2
- package/index.ts +0 -3
- package/package.json +1 -1
- package/prefer-tools.ts +10 -2
- package/review.ts +331 -223
- package/side.ts +141 -24
- package/zed.ts +0 -38
package/README.md
CHANGED
|
@@ -8,12 +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
|
-
| `
|
|
12
|
-
| `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. |
|
|
13
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. |
|
|
14
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. |
|
|
15
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". |
|
|
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.
|
|
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). |
|
|
17
16
|
| `kilo` | provider | Access Kilo Gateway models via `/login kilo` or `KILO_API_KEY`. |
|
|
18
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. |
|
|
19
18
|
| `tps` | hook (no command) | Notify tokens/sec and in/out/cache token usage at the end of each agent turn. |
|
|
@@ -26,7 +25,7 @@ extensions. One entry point, thirteen independent features.
|
|
|
26
25
|
First install, pinned to a published version:
|
|
27
26
|
|
|
28
27
|
```bash
|
|
29
|
-
pi install npm:bermudis-pi-goodies@0.27.
|
|
28
|
+
pi install npm:bermudis-pi-goodies@0.27.4
|
|
30
29
|
```
|
|
31
30
|
|
|
32
31
|
Picking up a new release on an existing install (`pi install` is not the
|
package/goodies.ts
CHANGED
|
@@ -34,7 +34,6 @@ type FeatureName =
|
|
|
34
34
|
| "copy-with-model"
|
|
35
35
|
| "copy-trajectory"
|
|
36
36
|
| "name-with-ai"
|
|
37
|
-
| "zed"
|
|
38
37
|
| "prefer-tools"
|
|
39
38
|
| "keep-model"
|
|
40
39
|
| "model-thinking"
|
|
@@ -50,7 +49,6 @@ const FEATURES: FeatureName[] = [
|
|
|
50
49
|
"copy-with-model",
|
|
51
50
|
"copy-trajectory",
|
|
52
51
|
"name-with-ai",
|
|
53
|
-
"zed",
|
|
54
52
|
"prefer-tools",
|
|
55
53
|
"keep-model",
|
|
56
54
|
"model-thinking",
|
package/index.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* - copy-with-model /copy-with-model copy last reply tagged with the model
|
|
7
7
|
* - copy-trajectory /copy-trajectory copy the whole conversation (text only) to the clipboard
|
|
8
8
|
* - name-with-ai /name-with-ai generate a session name via the model
|
|
9
|
-
* - zed /z open Zed on cwd
|
|
10
9
|
* - prefer-tools hook block legacy tools (trash/uv; blocks rg on glm-5.*)
|
|
11
10
|
* - keep-model hook preserve the active model across /new
|
|
12
11
|
* - model-thinking /model-thinking per-model default thinking levels, applied on switch
|
|
@@ -38,7 +37,6 @@ import tps from "./tps.ts";
|
|
|
38
37
|
import vision from "./vision.ts";
|
|
39
38
|
import side from "./side.ts";
|
|
40
39
|
import nameWithAi from "./name-with-ai.ts";
|
|
41
|
-
import zed from "./zed.ts";
|
|
42
40
|
import preferTools from "./prefer-tools.ts";
|
|
43
41
|
import goodies, { isEnabled } from "./goodies.ts";
|
|
44
42
|
import { setCleanTuiActive } from "./clean-tui.ts";
|
|
@@ -51,7 +49,6 @@ export default function bermudisPiGoodies(pi: ExtensionAPI): void {
|
|
|
51
49
|
if (isEnabled("copy-with-model")) copyWithModel(pi);
|
|
52
50
|
if (isEnabled("copy-trajectory")) copyTrajectory(pi);
|
|
53
51
|
if (isEnabled("name-with-ai")) nameWithAi(pi);
|
|
54
|
-
if (isEnabled("zed")) zed(pi);
|
|
55
52
|
if (isEnabled("prefer-tools")) preferTools(pi);
|
|
56
53
|
if (isEnabled("keep-model")) keepModelOnNew(pi);
|
|
57
54
|
if (isEnabled("model-thinking")) modelThinking(pi);
|
package/package.json
CHANGED
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:
|
|
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
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1237
|
+
// Preselect the smart default without reordering the list
|
|
1238
|
+
if (smartDefaultIndex >= 0) {
|
|
1239
|
+
selectList.setSelectedIndex(smartDefaultIndex);
|
|
1240
|
+
}
|
|
1187
1241
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1242
|
+
selectList.onSelect = (item) =>
|
|
1243
|
+
done(item.value as ReviewPresetValue);
|
|
1244
|
+
selectList.onCancel = () => done(null);
|
|
1190
1245
|
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
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
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1419
|
+
const searchInput = new Input();
|
|
1420
|
+
container.addChild(searchInput);
|
|
1421
|
+
container.addChild(new Spacer(1));
|
|
1349
1422
|
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
-
|
|
1360
|
-
|
|
1437
|
+
let filteredItems = items;
|
|
1438
|
+
let selectList: SelectList | null = null;
|
|
1361
1439
|
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1462
|
+
selectList.onSelect = (item) => done(item.value);
|
|
1463
|
+
selectList.onCancel = () => done(null);
|
|
1464
|
+
listContainer.addChild(selectList);
|
|
1465
|
+
};
|
|
1388
1466
|
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
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
|
-
|
|
1480
|
+
applyFilter();
|
|
1403
1481
|
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
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
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1556
|
+
const searchInput = new Input();
|
|
1557
|
+
container.addChild(searchInput);
|
|
1558
|
+
container.addChild(new Spacer(1));
|
|
1469
1559
|
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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
|
-
|
|
1480
|
-
|
|
1574
|
+
let filteredItems = items;
|
|
1575
|
+
let selectList: SelectList | null = null;
|
|
1481
1576
|
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
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
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
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
|
-
|
|
1624
|
+
applyFilter();
|
|
1530
1625
|
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
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
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
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
|
-
|
|
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(
|
|
@@ -2125,9 +2233,9 @@ Instructions:
|
|
|
2125
2233
|
endReviewInProgress = true;
|
|
2126
2234
|
try {
|
|
2127
2235
|
const choice = await ctx.ui.select("Finish review:", [
|
|
2128
|
-
"Return only",
|
|
2129
2236
|
"Return and fix findings",
|
|
2130
2237
|
"Return and summarize",
|
|
2238
|
+
"Return only",
|
|
2131
2239
|
]);
|
|
2132
2240
|
|
|
2133
2241
|
if (choice === undefined) {
|
package/side.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* - /side-exit navigates back to the recorded tip, restores the main model,
|
|
16
16
|
* and hands the main agent the side conversation — also as an attributed
|
|
17
17
|
* quote — with the mode chosen at exit: trajectory (full transcript),
|
|
18
|
-
* summary (the side model summarizes
|
|
18
|
+
* summary (the side model summarizes, shown as a cancellable progress
|
|
19
|
+
* widget above the editor), or nothing.
|
|
19
20
|
*
|
|
20
21
|
* Durability: handoffs are appended as custom-message entries via
|
|
21
22
|
* sendMessage({ triggerTurn: false }) while idle, which writes the session
|
|
@@ -636,10 +637,24 @@ function refreshSideLens(ctx: ExtensionContext): void {
|
|
|
636
637
|
setSideLensTokens(tokens);
|
|
637
638
|
}
|
|
638
639
|
|
|
640
|
+
/**
|
|
641
|
+
* The summarizer's completion resolved with stopReason "aborted" (partial
|
|
642
|
+
* text after an Esc) instead of rejecting — the caller's abort-signal check
|
|
643
|
+
* never fires on that path, so this sentinel carries the cancel out to the
|
|
644
|
+
* SummaryOutcome boundary below.
|
|
645
|
+
*/
|
|
646
|
+
class SummaryAbortedError extends Error {
|
|
647
|
+
constructor() {
|
|
648
|
+
super("side model summary was aborted");
|
|
649
|
+
this.name = "SummaryAbortedError";
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
639
653
|
async function summarizeDelta(
|
|
640
654
|
ctx: ExtensionCommandContext,
|
|
641
655
|
sideModel: ModelRef,
|
|
642
656
|
transcript: string,
|
|
657
|
+
signal?: AbortSignal,
|
|
643
658
|
): Promise<string> {
|
|
644
659
|
const model = ctx.modelRegistry.find(sideModel.provider, sideModel.id);
|
|
645
660
|
if (!model) {
|
|
@@ -647,22 +662,116 @@ async function summarizeDelta(
|
|
|
647
662
|
`${modelRef(sideModel)} is no longer available to summarize — use trajectory or nothing`,
|
|
648
663
|
);
|
|
649
664
|
}
|
|
650
|
-
const response = await ctx.modelRegistry.complete(
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
665
|
+
const response = await ctx.modelRegistry.complete(
|
|
666
|
+
model,
|
|
667
|
+
{
|
|
668
|
+
messages: [
|
|
669
|
+
{
|
|
670
|
+
role: "user",
|
|
671
|
+
timestamp: Date.now(),
|
|
672
|
+
content: [
|
|
673
|
+
{ type: "text", text: `${SUMMARY_INSTRUCTIONS}\n\n${transcript}` },
|
|
674
|
+
],
|
|
675
|
+
},
|
|
676
|
+
],
|
|
677
|
+
},
|
|
678
|
+
{ signal },
|
|
679
|
+
);
|
|
661
680
|
const text = extractTextParts(response.content).join("\n").trim();
|
|
681
|
+
// complete() RESOLVES failed and aborted calls (providers mark stopReason
|
|
682
|
+
// "error" or "aborted" and keep the partial text) rather than rejecting.
|
|
683
|
+
// Left alone, a truncated reply would reach the main agent as a finished
|
|
684
|
+
// summary. "aborted" becomes the cancel sentinel; "error" becomes a real
|
|
685
|
+
// failure so it rides the fail-closed exit path with the provider's message.
|
|
686
|
+
if (response.stopReason === "aborted") throw new SummaryAbortedError();
|
|
687
|
+
if (response.stopReason === "error") {
|
|
688
|
+
throw new Error(
|
|
689
|
+
`side model summary failed: ${response.errorMessage ?? "unknown error"}`,
|
|
690
|
+
);
|
|
691
|
+
}
|
|
662
692
|
if (!text) throw new Error("side model returned an empty summary");
|
|
663
693
|
return text;
|
|
664
694
|
}
|
|
665
695
|
|
|
696
|
+
type SummaryOutcome =
|
|
697
|
+
| { ok: true; text: string }
|
|
698
|
+
| { ok: false; cancelled: true }
|
|
699
|
+
| { ok: false; cancelled: false; error: unknown };
|
|
700
|
+
|
|
701
|
+
const SUMMARIZE_WIDGET_KEY = "bermudis-pi-goodies.side-summarize";
|
|
702
|
+
|
|
703
|
+
/** Fallback budget when there is no TTY to measure (tests, piped output). */
|
|
704
|
+
const WIDGET_LINE_FALLBACK_WIDTH = 80;
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Visible budget for the summarize progress line. Pi renders string-array
|
|
708
|
+
* widgets as Text(line, 1, 0) — one column of side padding — so the line
|
|
709
|
+
* wraps past (terminal width − 2). Without a TTY fall back to the
|
|
710
|
+
* historical 80 and never grow past it, so known-width terminals only ever
|
|
711
|
+
* truncate harder (same pattern as clean-tui's bashLineCap).
|
|
712
|
+
*/
|
|
713
|
+
function widgetLineCap(): number {
|
|
714
|
+
const cols = process.stdout.columns;
|
|
715
|
+
if (typeof cols !== "number" || cols <= 0)
|
|
716
|
+
return WIDGET_LINE_FALLBACK_WIDTH;
|
|
717
|
+
return Math.max(20, Math.min(WIDGET_LINE_FALLBACK_WIDTH, cols - 2));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Widget lines render above the editor; stay single-line on narrow terminals. */
|
|
721
|
+
function truncateWidgetLine(text: string, max = widgetLineCap()): string {
|
|
722
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Exit summarization with visible, cancellable UI: a progress widget above
|
|
727
|
+
* the editor for the whole call, Esc to abort. The picker closes the moment
|
|
728
|
+
* the mode is chosen; without these the UI then sits silent until the model
|
|
729
|
+
* answers, which reads as a freeze on long consultations.
|
|
730
|
+
*
|
|
731
|
+
* setWidget/onTerminalInput are optional-called because harness stubs and
|
|
732
|
+
* limited (non-interactive) contexts lack them; headless keeps the old
|
|
733
|
+
* no-feedback behavior, which is all it ever had.
|
|
734
|
+
*/
|
|
735
|
+
export async function summarizeWithProgress(
|
|
736
|
+
ctx: ExtensionCommandContext,
|
|
737
|
+
sideModel: ModelRef,
|
|
738
|
+
transcript: string,
|
|
739
|
+
turnCount: number,
|
|
740
|
+
): Promise<SummaryOutcome> {
|
|
741
|
+
const abort = new AbortController();
|
|
742
|
+
const turns = `${turnCount} ${turnCount === 1 ? "turn" : "turns"}`;
|
|
743
|
+
const line = truncateWidgetLine(
|
|
744
|
+
`⟳ summarizing side consultation (${turns}) with ${modelRef(sideModel)} — esc cancels`,
|
|
745
|
+
);
|
|
746
|
+
const unsubscribe = ctx.ui?.onTerminalInput?.((data) => {
|
|
747
|
+
// A bare Esc arrives as exactly "\x1b" (pi's stdin buffer flushes a lone
|
|
748
|
+
// ESC after its escape timeout); sequences like arrow keys arrive as
|
|
749
|
+
// longer chunks and pass through untouched.
|
|
750
|
+
if (data === "\x1b") {
|
|
751
|
+
abort.abort();
|
|
752
|
+
return { consume: true };
|
|
753
|
+
}
|
|
754
|
+
return undefined;
|
|
755
|
+
});
|
|
756
|
+
ctx.ui?.setWidget?.(SUMMARIZE_WIDGET_KEY, [line]);
|
|
757
|
+
try {
|
|
758
|
+
const text = await summarizeDelta(ctx, sideModel, transcript, abort.signal);
|
|
759
|
+
return { ok: true, text };
|
|
760
|
+
} catch (error) {
|
|
761
|
+
// Cancellation reaches here two ways: our own Esc (signal aborted, which
|
|
762
|
+
// may surface as a rejection or a resolved "aborted" message that
|
|
763
|
+
// summarizeDelta turns into the sentinel). Either way the user asked to
|
|
764
|
+
// stop, so it rides the stay-in-side-session path. Every other error,
|
|
765
|
+
// including a resolved "error" message, rides the fail-closed exit path.
|
|
766
|
+
if (abort.signal.aborted || error instanceof SummaryAbortedError)
|
|
767
|
+
return { ok: false, cancelled: true };
|
|
768
|
+
return { ok: false, cancelled: false, error };
|
|
769
|
+
} finally {
|
|
770
|
+
unsubscribe?.();
|
|
771
|
+
ctx.ui?.setWidget?.(SUMMARIZE_WIDGET_KEY, undefined);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
666
775
|
// ---------------------------------------------------------------------------
|
|
667
776
|
// Extension entry point
|
|
668
777
|
// ---------------------------------------------------------------------------
|
|
@@ -1024,20 +1133,28 @@ export default function side(pi: ExtensionAPI): void {
|
|
|
1024
1133
|
// mode (fail-closed) rather than exiting empty-handed.
|
|
1025
1134
|
let summaryText: string | undefined;
|
|
1026
1135
|
if (mode === "summary" && deltaTurns.length > 0) {
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1136
|
+
const result = await summarizeWithProgress(
|
|
1137
|
+
ctx,
|
|
1138
|
+
effectiveSide,
|
|
1139
|
+
renderTrajectory(deltaTurns),
|
|
1140
|
+
deltaTurns.length,
|
|
1141
|
+
);
|
|
1142
|
+
if (!result.ok) {
|
|
1143
|
+
if (result.cancelled) {
|
|
1144
|
+
ctx.ui.notify(
|
|
1145
|
+
"side-exit: summarization cancelled — still in the side session",
|
|
1146
|
+
"warning",
|
|
1147
|
+
);
|
|
1148
|
+
} else {
|
|
1149
|
+
logGoodiesEvent({
|
|
1150
|
+
type: "side_summary_failed",
|
|
1151
|
+
error: describeError(result.error),
|
|
1152
|
+
});
|
|
1153
|
+
ctx.ui.notify(`side-exit: ${describeError(result.error)}`, "error");
|
|
1154
|
+
}
|
|
1039
1155
|
return;
|
|
1040
1156
|
}
|
|
1157
|
+
summaryText = result.text;
|
|
1041
1158
|
}
|
|
1042
1159
|
|
|
1043
1160
|
const result = await ctx.navigateTree(marker.data.mainTipId, {
|
package/zed.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* /z — Open Zed editor on cwd.
|
|
3
|
-
*/
|
|
4
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { spawn } from "node:child_process";
|
|
6
|
-
|
|
7
|
-
export default function (pi: ExtensionAPI) {
|
|
8
|
-
// Arch packages the CLI as "zeditor"
|
|
9
|
-
const zedBin = process.platform === "linux" ? "zeditor" : "zed";
|
|
10
|
-
|
|
11
|
-
pi.registerCommand("z", {
|
|
12
|
-
description: "Open Zed editor on cwd (new window)",
|
|
13
|
-
handler: async (_args, ctx) => {
|
|
14
|
-
const child = spawn(zedBin, ["--new", ctx.cwd], {
|
|
15
|
-
detached: true,
|
|
16
|
-
stdio: "ignore",
|
|
17
|
-
});
|
|
18
|
-
child.on("error", (err) => {
|
|
19
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
20
|
-
if (code === "ENOENT") {
|
|
21
|
-
ctx.ui.notify(
|
|
22
|
-
`Zed not found: \`${zedBin}\` is not installed or not on PATH`,
|
|
23
|
-
"error",
|
|
24
|
-
);
|
|
25
|
-
} else {
|
|
26
|
-
ctx.ui.notify(`Failed to open Zed: ${err.message}`, "error");
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
// Only announce once the process has actually started — spawn()'s
|
|
30
|
-
// 'error' event fires asynchronously, so notifying synchronously would
|
|
31
|
-
// show "Opening Zed" before a missing-binary error lands.
|
|
32
|
-
child.on("spawn", () => {
|
|
33
|
-
ctx.ui.notify(`Opening Zed: ${ctx.cwd}`, "info");
|
|
34
|
-
});
|
|
35
|
-
child.unref();
|
|
36
|
-
},
|
|
37
|
-
});
|
|
38
|
-
}
|