@shanepadgett/tau-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +30 -0
  2. package/docs/extending-tau-agent.md +119 -0
  3. package/docs/subagents.md +57 -0
  4. package/docs/tui.md +107 -0
  5. package/extensions/appshot/README.md +11 -0
  6. package/extensions/appshot/capture.swift +191 -0
  7. package/extensions/appshot/index.ts +245 -0
  8. package/extensions/appshot/native-helper.ts +102 -0
  9. package/extensions/attention/README.md +23 -0
  10. package/extensions/attention/index.ts +71 -0
  11. package/extensions/auto-name/README.md +26 -0
  12. package/extensions/auto-name/index.ts +163 -0
  13. package/extensions/branch/README.md +22 -0
  14. package/extensions/branch/index.ts +145 -0
  15. package/extensions/branch/panel.ts +148 -0
  16. package/extensions/clear-screen/README.md +29 -0
  17. package/extensions/clear-screen/index.ts +75 -0
  18. package/extensions/commit/README.md +15 -0
  19. package/extensions/commit/commit-plan.ts +328 -0
  20. package/extensions/commit/git-change-set.ts +268 -0
  21. package/extensions/commit/index.ts +138 -0
  22. package/extensions/commit/review-ui.ts +745 -0
  23. package/extensions/explore/README.md +7 -0
  24. package/extensions/explore/autoread.ts +140 -0
  25. package/extensions/explore/find.ts +147 -0
  26. package/extensions/explore/grep.ts +749 -0
  27. package/extensions/explore/index.ts +17 -0
  28. package/extensions/explore/limits.ts +22 -0
  29. package/extensions/explore/ls.ts +113 -0
  30. package/extensions/explore/path-display.ts +41 -0
  31. package/extensions/explore/path-tree.ts +150 -0
  32. package/extensions/explore/read.ts +160 -0
  33. package/extensions/explore/result.ts +29 -0
  34. package/extensions/explore/traverse.ts +226 -0
  35. package/extensions/footer/README.md +23 -0
  36. package/extensions/footer/index.ts +534 -0
  37. package/extensions/footer/settings.ts +13 -0
  38. package/extensions/ideas/README.md +10 -0
  39. package/extensions/ideas/browser.ts +66 -0
  40. package/extensions/ideas/index.ts +31 -0
  41. package/extensions/ideas/store.ts +32 -0
  42. package/extensions/image-gen/README.md +11 -0
  43. package/extensions/image-gen/client.ts +186 -0
  44. package/extensions/image-gen/index.ts +152 -0
  45. package/extensions/manage-sessions/README.md +12 -0
  46. package/extensions/manage-sessions/index.ts +58 -0
  47. package/extensions/manage-sessions/manager-ui.ts +323 -0
  48. package/extensions/manage-sessions/sessions.ts +126 -0
  49. package/extensions/patch/README.md +48 -0
  50. package/extensions/patch/executor.ts +355 -0
  51. package/extensions/patch/index.ts +170 -0
  52. package/extensions/patch/matcher.ts +236 -0
  53. package/extensions/patch/parser.ts +347 -0
  54. package/extensions/patch/render.ts +247 -0
  55. package/extensions/patch/summary.ts +35 -0
  56. package/extensions/publish/README.md +11 -0
  57. package/extensions/publish/index.ts +225 -0
  58. package/extensions/qna/README.md +45 -0
  59. package/extensions/qna/additional-context-body.ts +51 -0
  60. package/extensions/qna/body-render.ts +6 -0
  61. package/extensions/qna/choice-question-body.ts +322 -0
  62. package/extensions/qna/index.ts +245 -0
  63. package/extensions/qna/inline-editor-row.ts +56 -0
  64. package/extensions/qna/input-question-body.ts +89 -0
  65. package/extensions/qna/model.ts +317 -0
  66. package/extensions/qna/panel.ts +240 -0
  67. package/extensions/qna/ui.ts +16 -0
  68. package/extensions/reference/README.md +47 -0
  69. package/extensions/reference/index.ts +76 -0
  70. package/extensions/reference/panel.ts +874 -0
  71. package/extensions/reference/settings.ts +29 -0
  72. package/extensions/run-summary/README.md +5 -0
  73. package/extensions/run-summary/index.ts +98 -0
  74. package/extensions/silent-command-runner/README.md +25 -0
  75. package/extensions/silent-command-runner/index.ts +468 -0
  76. package/extensions/silent-command-runner/settings.ts +65 -0
  77. package/extensions/soul/README.md +19 -0
  78. package/extensions/soul/index.ts +20 -0
  79. package/extensions/soul/prompt.ts +254 -0
  80. package/extensions/soul/settings.ts +13 -0
  81. package/extensions/stash/README.md +48 -0
  82. package/extensions/stash/browser.ts +51 -0
  83. package/extensions/stash/index.ts +55 -0
  84. package/extensions/stash/store.ts +36 -0
  85. package/extensions/subagent/README.md +34 -0
  86. package/extensions/subagent/agents/scout.md +85 -0
  87. package/extensions/subagent/agents/web-research.md +95 -0
  88. package/extensions/subagent/agents.ts +196 -0
  89. package/extensions/subagent/index.ts +214 -0
  90. package/extensions/subagent/render.ts +89 -0
  91. package/extensions/subagent/run.ts +325 -0
  92. package/extensions/tau/README.md +17 -0
  93. package/extensions/tau/index.ts +122 -0
  94. package/extensions/tau-help/README.md +3 -0
  95. package/extensions/tau-help/help.md +125 -0
  96. package/extensions/tau-help/index.ts +68 -0
  97. package/extensions/turn-budget/README.md +14 -0
  98. package/extensions/turn-budget/index.ts +150 -0
  99. package/extensions/turn-budget/settings.ts +35 -0
  100. package/extensions/web/README.md +13 -0
  101. package/extensions/web/codesearch.ts +79 -0
  102. package/extensions/web/exa.ts +101 -0
  103. package/extensions/web/html.ts +67 -0
  104. package/extensions/web/index.ts +13 -0
  105. package/extensions/web/limits.ts +11 -0
  106. package/extensions/web/tool-output.ts +54 -0
  107. package/extensions/web/webfetch.ts +177 -0
  108. package/extensions/web/websearch.ts +100 -0
  109. package/package.json +57 -0
  110. package/prompts/.gitkeep +1 -0
  111. package/prompts/cavemanify.md +25 -0
  112. package/prompts/implement.md +25 -0
  113. package/prompts/interview.md +48 -0
  114. package/prompts/plan-feature.md +30 -0
  115. package/prompts/plan-implementation.md +50 -0
  116. package/schemas/tau.schema.json +205 -0
  117. package/shared/agent-blocked.ts +12 -0
  118. package/shared/description.ts +28 -0
  119. package/shared/events.ts +135 -0
  120. package/shared/git.ts +41 -0
  121. package/shared/injected-context.ts +69 -0
  122. package/shared/jsonl-store.ts +108 -0
  123. package/shared/model-fallback/index.ts +278 -0
  124. package/shared/model-fallback/settings.ts +18 -0
  125. package/shared/model-fallback/types.ts +8 -0
  126. package/shared/ranges.ts +21 -0
  127. package/shared/settings/define.ts +15 -0
  128. package/shared/settings/files.ts +20 -0
  129. package/shared/settings/json.ts +41 -0
  130. package/shared/settings/load.ts +59 -0
  131. package/shared/settings/merge.ts +21 -0
  132. package/shared/settings/paths.ts +41 -0
  133. package/shared/settings/schema.ts +25 -0
  134. package/shared/settings/specs.ts +23 -0
  135. package/shared/text.ts +11 -0
  136. package/shared/tool-row-state.ts +43 -0
  137. package/skills/writing-preferences/SKILL.md +136 -0
  138. package/themes/.gitkeep +1 -0
@@ -0,0 +1,874 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, mkdir, readdir, rename, rm } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { basename, join } from "node:path";
5
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
6
+ import { type Component, getKeybindings, Input, Key, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
7
+ import type { GitRunner } from "../../shared/git.ts";
8
+ import { formatAge } from "../../shared/text.ts";
9
+ import { bindingHint, rawHint } from "@shanepadgett/tau-tui";
10
+ import {
11
+ type SelectableListActionResult,
12
+ SelectableList,
13
+ type SelectableListItem,
14
+ type SelectableListResult,
15
+ } from "@shanepadgett/tau-tui";
16
+ import { ToolPanel, type ToolPanelConfig } from "@shanepadgett/tau-tui";
17
+ import { visibleWindow } from "@shanepadgett/tau-tui";
18
+ import type { ReferenceEditor } from "./settings.ts";
19
+
20
+ const REFERENCES_DIR = join(homedir(), ".local", "share", "tau-agent", "references");
21
+ const UPDATE_TIMEOUT_MS = 120_000;
22
+ const BRANCH_LOOKUP_TIMEOUT_MS = 15_000;
23
+ const BRANCH_SWITCH_TIMEOUT_MS = 120_000;
24
+ const CLONE_STALL_MS = 180_000;
25
+ const REFERENCE_VISIBLE_ROWS = 10;
26
+ const BRANCH_VISIBLE_ROWS = 5;
27
+
28
+ export interface ReferenceItem {
29
+ name: string;
30
+ path: string;
31
+ dirty: boolean;
32
+ branch: string;
33
+ }
34
+
35
+ interface ReferenceListItem extends ReferenceItem, SelectableListItem {
36
+ state?: "updating" | "updated" | "failed" | "switching";
37
+ }
38
+
39
+ interface CloneProgress {
40
+ phase: "receiving" | "resolving" | "updating";
41
+ percent: number;
42
+ }
43
+
44
+ interface CloneStatus {
45
+ name: string;
46
+ path: string;
47
+ progress?: CloneProgress;
48
+ }
49
+
50
+ interface ReferenceBranch {
51
+ name: string;
52
+ updatedAt?: number;
53
+ isCurrent: boolean;
54
+ }
55
+
56
+ interface BranchPicker {
57
+ item: ReferenceListItem;
58
+ branches: ReferenceBranch[];
59
+ cursor: number;
60
+ }
61
+
62
+ interface DeleteConfirm {
63
+ items: readonly ReferenceListItem[];
64
+ label: string;
65
+ }
66
+
67
+ export async function showReferencePanel(
68
+ git: GitRunner,
69
+ ctx: ExtensionCommandContext,
70
+ editor: ReferenceEditor,
71
+ branchChoices: number,
72
+ ): Promise<ReferenceItem[] | undefined> {
73
+ let initial: ReferenceItem[];
74
+ try {
75
+ initial = await loadReferences(git);
76
+ } catch (error) {
77
+ ctx.ui.notify(`Reference load failed: ${errorText(error)}`, "error");
78
+ initial = [];
79
+ }
80
+
81
+ return ctx.ui.custom<ReferenceItem[] | undefined>(
82
+ (tui, theme, _keybindings, done) =>
83
+ new ReferencePanel(tui, theme, git, ctx, editor, branchChoices, initial, done),
84
+ );
85
+ }
86
+
87
+ export async function cloneFromCommand(ctx: ExtensionCommandContext, url: string): Promise<void> {
88
+ let name: string;
89
+ try {
90
+ name = referenceNameFromUrl(url);
91
+ } catch (error) {
92
+ ctx.ui.notify(errorText(error), "error");
93
+ return;
94
+ }
95
+
96
+ const path = join(REFERENCES_DIR, name);
97
+ ctx.ui.setStatus("reference", `cloning ${name}`);
98
+ try {
99
+ await cloneReference(url, name, path, ctx.signal, () => {});
100
+ ctx.ui.notify(`Added ${name} to ${path}`, "info");
101
+ } catch (error) {
102
+ ctx.ui.notify(`New reference failed: ${errorText(error)}`, "error");
103
+ } finally {
104
+ ctx.ui.setStatus("reference", undefined);
105
+ }
106
+ }
107
+
108
+ class ReferencePanel implements Component {
109
+ private readonly tui: TUI;
110
+ private readonly theme: Theme;
111
+ private readonly git: GitRunner;
112
+ private readonly ctx: ExtensionCommandContext;
113
+ private readonly editor: ReferenceEditor;
114
+ private readonly branchChoices: number;
115
+ private readonly done: (result: ReferenceItem[] | undefined) => void;
116
+ private readonly panelConfig: ToolPanelConfig;
117
+ private readonly panel: ToolPanel;
118
+ private readonly body: Component;
119
+ private readonly branchCache = new Map<string, ReferenceBranch[]>();
120
+ private refs: ReferenceListItem[];
121
+ private selected: readonly ReferenceListItem[] = [];
122
+ private list: SelectableList<ReferenceListItem>;
123
+ private deleteConfirm: DeleteConfirm | undefined;
124
+ private cloneInput: Input | undefined;
125
+ private cloneStatus: CloneStatus | undefined;
126
+ private branchPicker: BranchPicker | undefined;
127
+ private updating = false;
128
+
129
+ constructor(
130
+ tui: TUI,
131
+ theme: Theme,
132
+ git: GitRunner,
133
+ ctx: ExtensionCommandContext,
134
+ editor: ReferenceEditor,
135
+ branchChoices: number,
136
+ initial: readonly ReferenceItem[],
137
+ done: (result: ReferenceItem[] | undefined) => void,
138
+ ) {
139
+ this.tui = tui;
140
+ this.theme = theme;
141
+ this.git = git;
142
+ this.ctx = ctx;
143
+ this.editor = editor;
144
+ this.branchChoices = branchChoices;
145
+ this.done = done;
146
+ this.refs = initial.map((item) => toListItem(item));
147
+ this.list = this.createList(this.refs);
148
+ this.body = {
149
+ render: (width) => this.renderBody(width),
150
+ invalidate: () => this.list.invalidate(),
151
+ };
152
+ this.panelConfig = {
153
+ title: "References",
154
+ secondary: this.secondaryText(),
155
+ header: this.headerLines(),
156
+ body: this.body,
157
+ footer: { kind: "hints", hints: this.footerHints() },
158
+ };
159
+ this.panel = new ToolPanel(theme, this.panelConfig);
160
+ }
161
+
162
+ render(width: number): string[] {
163
+ return this.panel.render(width);
164
+ }
165
+
166
+ invalidate(): void {
167
+ this.panel.invalidate();
168
+ }
169
+
170
+ handleInput(data: string): void {
171
+ if (this.branchPicker) {
172
+ this.handleBranchInput(data);
173
+ return;
174
+ }
175
+ if (this.cloneInput) {
176
+ this.cloneInput.handleInput(data);
177
+ this.syncPanel();
178
+ return;
179
+ }
180
+ if (this.deleteConfirm) {
181
+ this.handleDeleteConfirmInput(data);
182
+ return;
183
+ }
184
+ this.list.handleInput(data);
185
+ this.syncPanel();
186
+ }
187
+
188
+ private createList(items: readonly ReferenceListItem[]): SelectableList<ReferenceListItem> {
189
+ return new SelectableList(this.theme, {
190
+ items,
191
+ emptyMessage: "No references. Press ctrl+n for new.",
192
+ selection: { kind: "multi", primaryLabel: "attach" },
193
+ filter: { searchText: (item) => `${item.name} ${item.branch} ${item.path}` },
194
+ actions: [
195
+ { id: "new", key: Key.ctrl("n"), hint: rawHint("ctrl+n", "new") },
196
+ { id: "open", key: Key.ctrl("o"), hint: rawHint("ctrl+o", "open"), target: "current" },
197
+ { id: "branch", key: Key.ctrl("b"), hint: rawHint("ctrl+b", "branch"), target: "current" },
198
+ { id: "delete", key: Key.ctrl("d"), hint: rawHint("ctrl+d", "delete"), target: "currentOrSelection" },
199
+ { id: "updateVisible", key: Key.ctrl("u"), hint: rawHint("ctrl+u", "update"), target: "visible" },
200
+ ],
201
+ cancelLabel: "cancel",
202
+ maxVisible: REFERENCE_VISIBLE_ROWS,
203
+ renderItem: (item, state, width) => this.renderReferenceRow(item, state.active, width),
204
+ onResult: (result) => this.handleListResult(result),
205
+ onSelectionChange: (selected) => {
206
+ this.selected = selected;
207
+ this.syncPanel();
208
+ },
209
+ });
210
+ }
211
+
212
+ private handleListResult(result: SelectableListResult<ReferenceListItem>): void {
213
+ if (result.kind === "cancel") {
214
+ this.done(undefined);
215
+ return;
216
+ }
217
+ if (result.kind === "primary") {
218
+ if (result.items.length > 0) this.done(result.items.map(toReferenceItem));
219
+ return;
220
+ }
221
+ this.handleListAction(result);
222
+ }
223
+
224
+ private handleListAction(result: SelectableListActionResult<ReferenceListItem>): void {
225
+ if (result.actionId === "new") {
226
+ this.startCloneInput();
227
+ return;
228
+ }
229
+ if (result.actionId === "updateVisible") {
230
+ void this.updateVisibleReferences(result.items);
231
+ return;
232
+ }
233
+ if (result.items.length === 0) {
234
+ this.ctx.ui.notify("No reference highlighted or selected.", "info");
235
+ return;
236
+ }
237
+
238
+ const [item] = result.items;
239
+ if (!item) return;
240
+ if (result.actionId === "open") this.openReference(item);
241
+ else if (result.actionId === "branch") void this.startBranchPicker(item);
242
+ else if (result.actionId === "delete") this.startDeleteReferences(result.items);
243
+ }
244
+
245
+ private renderBody(width: number): string[] {
246
+ if (this.branchPicker) return this.renderBranchPicker(width);
247
+ if (this.cloneInput) return this.renderCloneInput(width);
248
+ return this.list.render(width);
249
+ }
250
+
251
+ private renderCloneInput(width: number): string[] {
252
+ const renderWidth = Math.max(1, width);
253
+ return [
254
+ truncateToWidth(this.theme.fg("accent", this.theme.bold("Git URL")), renderWidth, ""),
255
+ ...(this.cloneInput?.render(renderWidth) ?? []),
256
+ ];
257
+ }
258
+
259
+ private renderBranchPicker(width: number): string[] {
260
+ const picker = this.branchPicker;
261
+ if (!picker) return [];
262
+ const renderWidth = Math.max(1, width);
263
+ const window = visibleWindow(picker.cursor, picker.branches.length, BRANCH_VISIBLE_ROWS);
264
+ const lines = [
265
+ truncateToWidth(
266
+ this.theme.fg("accent", this.theme.bold(`Choose branch for ${picker.item.name}`)),
267
+ renderWidth,
268
+ "",
269
+ ),
270
+ truncateToWidth(
271
+ this.theme.fg("dim", `${picker.branches.length}/${this.branchChoices} branch choice(s) shown`),
272
+ renderWidth,
273
+ "",
274
+ ),
275
+ "",
276
+ ];
277
+
278
+ for (let index = window.start; index < window.end; index++) {
279
+ const branch = picker.branches[index];
280
+ if (!branch) continue;
281
+ const active = index === picker.cursor;
282
+ const pointer = active ? this.theme.fg("accent", "› ") : " ";
283
+ const suffix = branch.isCurrent
284
+ ? "current"
285
+ : branch.updatedAt === undefined
286
+ ? "remote"
287
+ : formatAge(branch.updatedAt);
288
+ const labelText = `${branch.name} ${suffix}`;
289
+ const label = active
290
+ ? this.theme.fg("accent", labelText)
291
+ : this.theme.fg(branch.isCurrent ? "accent" : "text", labelText);
292
+ lines.push(truncateToWidth(`${pointer}${label}`, renderWidth, ""));
293
+ }
294
+ if (window.start > 0 || window.end < picker.branches.length) {
295
+ lines.push(
296
+ this.theme.fg(
297
+ "dim",
298
+ truncateToWidth(` (${picker.cursor + 1}/${picker.branches.length})`, renderWidth, ""),
299
+ ),
300
+ );
301
+ }
302
+ return lines;
303
+ }
304
+
305
+ private renderReferenceRow(item: ReferenceListItem, active: boolean, width: number): string[] {
306
+ const name = item.dirty ? `${item.name} *` : item.name;
307
+ const label = active ? this.theme.fg("accent", name) : this.theme.fg(item.dirty ? "warning" : "text", name);
308
+ const branch = item.branch ? this.theme.fg("muted", ` (${item.branch})`) : "";
309
+ const status = item.state
310
+ ? ` ${this.theme.fg(item.state === "failed" ? "error" : item.state === "updated" ? "success" : "muted", item.state === "failed" ? "!" : item.state === "updated" ? "✓" : "…")}`
311
+ : "";
312
+ return [truncateToWidth(`${label}${branch}${status}`, width, "")];
313
+ }
314
+
315
+ private handleBranchInput(data: string): void {
316
+ const picker = this.branchPicker;
317
+ if (!picker) return;
318
+ const keybindings = getKeybindings();
319
+ if (keybindings.matches(data, "tui.select.up")) picker.cursor = Math.max(0, picker.cursor - 1);
320
+ else if (keybindings.matches(data, "tui.select.down"))
321
+ picker.cursor = Math.min(picker.branches.length - 1, picker.cursor + 1);
322
+ else if (keybindings.matches(data, "tui.select.confirm")) {
323
+ const branch = picker.branches[picker.cursor];
324
+ if (branch) void this.switchPickedBranch(branch.name);
325
+ return;
326
+ } else if (keybindings.matches(data, "tui.select.cancel")) this.branchPicker = undefined;
327
+ this.syncPanel();
328
+ }
329
+
330
+ private handleDeleteConfirmInput(data: string): void {
331
+ const keybindings = getKeybindings();
332
+ if (keybindings.matches(data, "tui.select.confirm")) {
333
+ const picked = this.deleteConfirm?.items ?? [];
334
+ this.deleteConfirm = undefined;
335
+ void this.deleteReferences(picked);
336
+ return;
337
+ }
338
+ if (keybindings.matches(data, "tui.select.cancel")) {
339
+ this.deleteConfirm = undefined;
340
+ this.syncPanel();
341
+ }
342
+ }
343
+
344
+ private startCloneInput(): void {
345
+ if (this.cloneStatus) {
346
+ this.ctx.ui.notify("Reference clone already running.", "info");
347
+ return;
348
+ }
349
+ this.cloneInput = new Input();
350
+ this.cloneInput.focused = true;
351
+ this.cloneInput.onSubmit = (value) => {
352
+ this.cloneInput = undefined;
353
+ void this.startClone(value);
354
+ };
355
+ this.cloneInput.onEscape = () => {
356
+ this.cloneInput = undefined;
357
+ this.ctx.ui.notify("New reference cancelled.", "info");
358
+ this.syncPanel();
359
+ };
360
+ this.branchPicker = undefined;
361
+ this.deleteConfirm = undefined;
362
+ this.syncPanel();
363
+ }
364
+
365
+ private async startClone(input: string): Promise<void> {
366
+ const url = input.trim();
367
+ if (!url) {
368
+ this.ctx.ui.notify("New reference cancelled.", "info");
369
+ this.syncPanel();
370
+ return;
371
+ }
372
+
373
+ let name: string;
374
+ try {
375
+ name = referenceNameFromUrl(url);
376
+ } catch (error) {
377
+ this.ctx.ui.notify(errorText(error), "error");
378
+ this.syncPanel();
379
+ return;
380
+ }
381
+
382
+ const path = join(REFERENCES_DIR, name);
383
+ if (this.refs.some((item) => item.path === path) || this.cloneStatus?.path === path) {
384
+ this.ctx.ui.notify(`Reference already exists: ${name}`, "error");
385
+ this.syncPanel();
386
+ return;
387
+ }
388
+
389
+ this.cloneStatus = { name, path };
390
+ this.syncPanel();
391
+ try {
392
+ await cloneReference(url, name, path, this.ctx.signal, (progress) => {
393
+ if (this.cloneStatus?.path === path) this.cloneStatus.progress = progress;
394
+ this.syncPanel();
395
+ });
396
+ const item = toListItem(await loadReference(this.git, name, path));
397
+ this.refs = [...this.refs, item].sort(compareReferenceItems);
398
+ this.list.setItems(this.refs);
399
+ this.ctx.ui.notify(`Added ${name} to ${path}`, "info");
400
+ } catch (error) {
401
+ this.ctx.ui.notify(`New reference failed: ${errorText(error)}`, "error");
402
+ } finally {
403
+ this.cloneStatus = undefined;
404
+ this.syncPanel();
405
+ }
406
+ }
407
+
408
+ private async updateVisibleReferences(items: readonly ReferenceListItem[]): Promise<void> {
409
+ if (this.updating) return;
410
+ if (items.length === 0) {
411
+ this.ctx.ui.notify("No references.", "info");
412
+ return;
413
+ }
414
+
415
+ this.updating = true;
416
+ let updated = 0;
417
+ const failures: string[] = [];
418
+ try {
419
+ const [authenticationReference] = items;
420
+ if (!authenticationReference) return;
421
+ try {
422
+ await this.git.run(["ls-remote", "origin"], {
423
+ cwd: authenticationReference.path,
424
+ timeout: UPDATE_TIMEOUT_MS,
425
+ });
426
+ } catch (error) {
427
+ this.ctx.ui.notify(`Reference authentication failed: ${errorText(error)}`, "error");
428
+ return;
429
+ }
430
+
431
+ for (const item of items) item.state = "updating";
432
+ this.syncPanel();
433
+
434
+ await Promise.all(
435
+ items.map(async (item) => {
436
+ try {
437
+ await this.git.run(["pull", "--ff-only", "--quiet"], {
438
+ cwd: item.path,
439
+ timeout: UPDATE_TIMEOUT_MS,
440
+ });
441
+ updated += 1;
442
+ item.state = "updated";
443
+ } catch (error) {
444
+ item.state = "failed";
445
+ failures.push(`${item.name}: ${errorText(error)}`);
446
+ }
447
+ this.syncPanel();
448
+ }),
449
+ );
450
+ await this.reloadReferences(true);
451
+ } finally {
452
+ this.updating = false;
453
+ this.syncPanel();
454
+ }
455
+
456
+ if (updated > 0) this.ctx.ui.notify(`Updated ${updated} reference(s).`, "info");
457
+ if (failures.length > 0) this.ctx.ui.notify(`Reference update failed:\n${failures.join("\n")}`, "error");
458
+ }
459
+
460
+ private async startBranchPicker(item: ReferenceListItem): Promise<void> {
461
+ item.state = "switching";
462
+ this.syncPanel();
463
+ let branches = markCurrent(this.branchCache.get(item.path) ?? [], item.branch);
464
+ try {
465
+ if (branches.length === 0)
466
+ branches = await loadLocalBranches(this.git, item.path, item.branch, this.branchChoices);
467
+ } catch (error) {
468
+ item.state = undefined;
469
+ this.syncPanel();
470
+ this.ctx.ui.notify(`Branch lookup failed: ${errorText(error)}`, "error");
471
+ return;
472
+ }
473
+
474
+ item.state = undefined;
475
+ if (branches.length === 0) {
476
+ this.ctx.ui.notify("No remote branches found.", "error");
477
+ this.syncPanel();
478
+ return;
479
+ }
480
+
481
+ this.branchPicker = { item, branches, cursor: 0 };
482
+ this.branchCache.set(item.path, branches);
483
+ this.deleteConfirm = undefined;
484
+ this.syncPanel();
485
+
486
+ void (async () => {
487
+ try {
488
+ const remoteBranches = await loadRemoteBranches(this.git, item.path, item.branch);
489
+ const merged = mergeBranches(branches, remoteBranches, this.branchChoices);
490
+ this.branchCache.set(item.path, merged);
491
+ if (this.branchPicker?.item.path === item.path) {
492
+ this.branchPicker.branches = merged;
493
+ this.branchPicker.cursor = Math.min(this.branchPicker.cursor, Math.max(0, merged.length - 1));
494
+ this.syncPanel();
495
+ }
496
+ } catch (error) {
497
+ if (this.branchPicker?.item.path === item.path)
498
+ this.ctx.ui.notify(`Remote branch lookup failed: ${errorText(error)}`, "error");
499
+ }
500
+ })();
501
+ }
502
+
503
+ private async switchPickedBranch(branch: string): Promise<void> {
504
+ const item = this.branchPicker?.item;
505
+ if (!item) return;
506
+ this.branchPicker = undefined;
507
+ const current = this.refs.find((ref) => ref.path === item.path);
508
+ if (!current) {
509
+ this.ctx.ui.notify("Reference no longer exists.", "error");
510
+ this.syncPanel();
511
+ return;
512
+ }
513
+ current.state = "switching";
514
+ this.syncPanel();
515
+ try {
516
+ await switchBranch(this.git, current.path, branch);
517
+ this.branchCache.delete(current.path);
518
+ await this.reloadReferences();
519
+ this.ctx.ui.notify(`Switched ${current.name} to ${branch}.`, "info");
520
+ } catch (error) {
521
+ current.state = "failed";
522
+ this.syncPanel();
523
+ this.ctx.ui.notify(`Branch switch failed: ${errorText(error)}`, "error");
524
+ }
525
+ }
526
+
527
+ private startDeleteReferences(items: readonly ReferenceListItem[]): void {
528
+ if (items.length === 0) {
529
+ this.ctx.ui.notify("No reference highlighted or selected.", "info");
530
+ return;
531
+ }
532
+ this.deleteConfirm = {
533
+ items,
534
+ label: `Delete ${items.length === 1 ? items[0]?.name : `${items.length} repos`}?`,
535
+ };
536
+ this.branchPicker = undefined;
537
+ this.syncPanel();
538
+ }
539
+
540
+ private async deleteReferences(items: readonly ReferenceListItem[]): Promise<void> {
541
+ const deleted: string[] = [];
542
+ const failures: string[] = [];
543
+ for (const item of items) {
544
+ try {
545
+ if (!/^[A-Za-z0-9._-]+$/.test(item.name) || item.name === "." || item.name === "..") {
546
+ throw new Error(`Invalid reference name: ${item.name}`);
547
+ }
548
+ const target = join(REFERENCES_DIR, item.name);
549
+ if (!target.startsWith(`${REFERENCES_DIR}/`)) throw new Error(`Invalid reference path: ${item.name}`);
550
+ await rm(target, { recursive: true, force: true });
551
+ deleted.push(item.name);
552
+ } catch (error) {
553
+ failures.push(`${item.name}: ${errorText(error)}`);
554
+ }
555
+ }
556
+ await this.reloadReferences();
557
+ if (deleted.length > 0)
558
+ this.ctx.ui.notify(`Deleted ${deleted.length} reference${deleted.length === 1 ? "" : "s"}.`, "info");
559
+ if (failures.length > 0) this.ctx.ui.notify(`Reference delete failed:\n${failures.join("\n")}`, "error");
560
+ }
561
+
562
+ private openReference(item: ReferenceListItem): void {
563
+ const configuredEditor = this.editor === "default" ? undefined : this.editor;
564
+ const defaultEditor = process.env.VISUAL?.trim() || process.env.EDITOR?.trim();
565
+ const command = configuredEditor ?? defaultEditor;
566
+ const child = command
567
+ ? spawn(`${command} ${shellQuote(item.path)}`, { detached: true, shell: true, stdio: "ignore" })
568
+ : spawn("code", [item.path], { detached: true, stdio: "ignore" });
569
+ child.once("error", (error) => this.ctx.ui.notify(`Open reference failed: ${errorText(error)}`, "error"));
570
+ child.once("spawn", () => {
571
+ child.unref();
572
+ this.ctx.ui.notify(`Opening ${item.name}.`, "info");
573
+ });
574
+ }
575
+
576
+ private async reloadReferences(preserveStates = false): Promise<void> {
577
+ const states = new Map(preserveStates ? this.refs.map((item) => [item.path, item.state]) : []);
578
+ this.refs = (await loadReferences(this.git)).map((item) => toListItem(item, states.get(item.path)));
579
+ this.list.setItems(this.refs);
580
+ this.syncPanel();
581
+ }
582
+
583
+ private syncPanel(): void {
584
+ this.panelConfig.secondary = this.secondaryText();
585
+ this.panelConfig.header = this.headerLines();
586
+ this.panelConfig.footer = this.footer();
587
+ this.tui.requestRender();
588
+ }
589
+
590
+ private secondaryText(): string {
591
+ return [
592
+ `${this.refs.length} total`,
593
+ REFERENCES_DIR,
594
+ ...(this.selected.length > 0 ? [`${this.selected.length} selected`] : []),
595
+ ].join(" · ");
596
+ }
597
+
598
+ private headerLines(): readonly string[] {
599
+ const lines: string[] = [];
600
+ if (this.refs.some((item) => item.dirty)) {
601
+ lines.push(this.theme.fg("warning", "Warning: dirty reference repos are read-only."));
602
+ }
603
+ if (this.cloneStatus) {
604
+ const progress = this.cloneStatus.progress
605
+ ? ` ${this.cloneStatus.progress.phase} ${this.cloneStatus.progress.percent}%`
606
+ : " starting";
607
+ lines.push(this.theme.fg("muted", `Cloning ${this.cloneStatus.name}${progress}`));
608
+ }
609
+ return lines;
610
+ }
611
+
612
+ private footer() {
613
+ if (this.deleteConfirm) {
614
+ return {
615
+ kind: "destructiveAck" as const,
616
+ message: this.deleteConfirm.label,
617
+ hints: [bindingHint("tui.select.confirm", "confirm"), bindingHint("tui.select.cancel", "cancel")],
618
+ };
619
+ }
620
+ return { kind: "hints" as const, hints: this.footerHints() };
621
+ }
622
+
623
+ private footerHints() {
624
+ if (this.branchPicker) {
625
+ return [bindingHint("tui.select.confirm", "switch branch"), bindingHint("tui.select.cancel", "cancel")];
626
+ }
627
+ if (this.cloneInput) {
628
+ return [bindingHint("tui.select.confirm", "clone default branch"), bindingHint("tui.select.cancel", "cancel")];
629
+ }
630
+ return this.list.getKeyHints();
631
+ }
632
+ }
633
+
634
+ function toListItem(item: ReferenceItem, state?: ReferenceListItem["state"]): ReferenceListItem {
635
+ return { ...item, id: item.path, state };
636
+ }
637
+
638
+ function toReferenceItem(item: ReferenceListItem): ReferenceItem {
639
+ return { name: item.name, path: item.path, dirty: item.dirty, branch: item.branch };
640
+ }
641
+
642
+ async function cloneReference(
643
+ url: string,
644
+ name: string,
645
+ target: string,
646
+ signal: AbortSignal | undefined,
647
+ onProgress: (progress: CloneProgress) => void,
648
+ ): Promise<void> {
649
+ await mkdir(REFERENCES_DIR, { recursive: true });
650
+ if (
651
+ await access(target).then(
652
+ () => true,
653
+ () => false,
654
+ )
655
+ )
656
+ throw new Error(`Reference already exists: ${name}`);
657
+ const temp = join(REFERENCES_DIR, `.clone-${name}-${process.pid}-${Date.now()}`);
658
+ let stderr = "";
659
+ let lastProgress: CloneProgress | undefined;
660
+ let lastOutputAt = Date.now();
661
+ let timedOut = false;
662
+
663
+ try {
664
+ const child = spawn("git", ["clone", "--progress", "--single-branch", url, temp], {
665
+ cwd: REFERENCES_DIR,
666
+ stdio: ["ignore", "ignore", "pipe"],
667
+ });
668
+ const stderrStream = child.stderr;
669
+ if (!stderrStream) throw new Error("git clone stderr unavailable.");
670
+ const abort = () => child.kill("SIGTERM");
671
+ if (signal?.aborted) abort();
672
+ signal?.addEventListener("abort", abort, { once: true });
673
+
674
+ await new Promise<void>((resolve, reject) => {
675
+ const timer = setInterval(() => {
676
+ if (Date.now() - lastOutputAt < CLONE_STALL_MS) return;
677
+ timedOut = true;
678
+ child.kill("SIGTERM");
679
+ }, 1000);
680
+
681
+ stderrStream.setEncoding("utf8");
682
+ stderrStream.on("data", (chunk: string) => {
683
+ lastOutputAt = Date.now();
684
+ stderr = `${stderr}${chunk}`.slice(-4000);
685
+ for (const part of chunk.split(/[\r\n]+/)) {
686
+ const match = /(?<phase>Receiving objects|Resolving deltas|Updating files):\s+(?<percent>\d+)%/.exec(
687
+ part,
688
+ );
689
+ if (!match) continue;
690
+ const phase = cloneProgressPhase(match.groups?.phase);
691
+ if (!phase) continue;
692
+ lastProgress = { phase, percent: Number(match.groups?.percent ?? 0) };
693
+ onProgress(lastProgress);
694
+ }
695
+ });
696
+
697
+ child.once("error", (error) => {
698
+ clearInterval(timer);
699
+ signal?.removeEventListener("abort", abort);
700
+ reject(error);
701
+ });
702
+ child.once("close", (code, termSignal) => {
703
+ clearInterval(timer);
704
+ signal?.removeEventListener("abort", abort);
705
+ if (code === 0) resolve();
706
+ else if (timedOut) {
707
+ reject(
708
+ new Error(
709
+ `Clone stalled after ${Math.round(CLONE_STALL_MS / 1000)}s with no git output${lastProgress === undefined ? "" : ` at ${lastProgress.phase} ${lastProgress.percent}%`}.${stderr.trim() ? `\n${stderr.trim()}` : ""}`,
710
+ ),
711
+ );
712
+ } else if (signal?.aborted) reject(new Error("Clone cancelled."));
713
+ else reject(new Error(stderr.trim() || `git clone failed with exit code ${code ?? termSignal}`));
714
+ });
715
+ });
716
+
717
+ await rename(temp, target);
718
+ } catch (error) {
719
+ await rm(temp, { recursive: true, force: true });
720
+ throw error;
721
+ }
722
+ }
723
+
724
+ function cloneProgressPhase(phase: string | undefined): CloneProgress["phase"] | undefined {
725
+ if (phase === "Receiving objects") return "receiving";
726
+ if (phase === "Resolving deltas") return "resolving";
727
+ if (phase === "Updating files") return "updating";
728
+ return undefined;
729
+ }
730
+
731
+ function compareReferenceItems(left: ReferenceListItem, right: ReferenceListItem): number {
732
+ return left.name.localeCompare(right.name);
733
+ }
734
+
735
+ async function loadReferences(git: GitRunner): Promise<ReferenceItem[]> {
736
+ await mkdir(REFERENCES_DIR, { recursive: true });
737
+ const refs = (await readdir(REFERENCES_DIR, { withFileTypes: true }))
738
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".clone-"))
739
+ .map((entry) => ({ name: entry.name, path: join(REFERENCES_DIR, entry.name) }))
740
+ .sort((left, right) => left.name.localeCompare(right.name));
741
+
742
+ const references: ReferenceItem[] = [];
743
+ for (const ref of refs) references.push(await loadReference(git, ref.name, ref.path));
744
+ return references;
745
+ }
746
+
747
+ async function loadReference(git: GitRunner, name: string, path: string): Promise<ReferenceItem> {
748
+ const branch = await git.run(["branch", "--show-current"], { cwd: path, optional: true });
749
+ const commit = branch ? "" : await git.run(["rev-parse", "--short", "HEAD"], { cwd: path, optional: true });
750
+ return {
751
+ name,
752
+ path,
753
+ dirty: (await git.run(["status", "--porcelain=v1"], { cwd: path, optional: true })).length > 0,
754
+ branch: branch || (commit ? `detached ${commit}` : ""),
755
+ };
756
+ }
757
+
758
+ async function loadLocalBranches(
759
+ git: GitRunner,
760
+ path: string,
761
+ current: string,
762
+ limit: number,
763
+ ): Promise<ReferenceBranch[]> {
764
+ const localRefs = await git.run(
765
+ [
766
+ "for-each-ref",
767
+ `--count=${limit}`,
768
+ "--sort=-committerdate",
769
+ "--format=%(refname:strip=3)%00%(committerdate:unix)",
770
+ "refs/remotes/origin",
771
+ ],
772
+ { cwd: path, timeout: BRANCH_LOOKUP_TIMEOUT_MS },
773
+ );
774
+ return parseLocalBranches(localRefs, current).slice(0, limit);
775
+ }
776
+
777
+ async function loadRemoteBranches(git: GitRunner, path: string, current: string): Promise<ReferenceBranch[]> {
778
+ const remoteRefs = await git.run(["ls-remote", "--heads", "origin"], {
779
+ cwd: path,
780
+ timeout: BRANCH_LOOKUP_TIMEOUT_MS,
781
+ });
782
+ return parseRemoteBranchNames(remoteRefs).map((name) => ({ name, isCurrent: name === current }));
783
+ }
784
+
785
+ function mergeBranches(
786
+ localBranches: readonly ReferenceBranch[],
787
+ remoteBranches: readonly ReferenceBranch[],
788
+ limit: number,
789
+ ): ReferenceBranch[] {
790
+ const byName = new Map(localBranches.map((branch) => [branch.name, branch]));
791
+ for (const branch of remoteBranches) if (!byName.has(branch.name)) byName.set(branch.name, branch);
792
+ return [...byName.values()]
793
+ .sort((left, right) => {
794
+ if (left.isCurrent !== right.isCurrent) return left.isCurrent ? -1 : 1;
795
+ if (left.updatedAt !== undefined && right.updatedAt !== undefined) return right.updatedAt - left.updatedAt;
796
+ if (left.updatedAt !== undefined) return -1;
797
+ if (right.updatedAt !== undefined) return 1;
798
+ return left.name.localeCompare(right.name);
799
+ })
800
+ .slice(0, limit);
801
+ }
802
+
803
+ function markCurrent(branches: readonly ReferenceBranch[], current: string): ReferenceBranch[] {
804
+ return branches.map((branch) => ({ ...branch, isCurrent: branch.name === current }));
805
+ }
806
+
807
+ function parseLocalBranches(output: string, current: string): ReferenceBranch[] {
808
+ return output
809
+ .split("\n")
810
+ .filter(Boolean)
811
+ .map((line): ReferenceBranch => {
812
+ const [branchName = "", seconds = "0"] = line.split("\0");
813
+ return { name: branchName, updatedAt: Number(seconds) * 1000, isCurrent: branchName === current };
814
+ })
815
+ .filter((branch) => branch.name && branch.name !== "HEAD")
816
+ .sort((left, right) => {
817
+ if (left.isCurrent !== right.isCurrent) return left.isCurrent ? -1 : 1;
818
+ return (right.updatedAt ?? 0) - (left.updatedAt ?? 0) || left.name.localeCompare(right.name);
819
+ });
820
+ }
821
+
822
+ function parseRemoteBranchNames(output: string): string[] {
823
+ return output
824
+ .split("\n")
825
+ .map((line) => line.split("\t")[1]?.replace(/^refs\/heads\//, "") ?? "")
826
+ .filter((name) => name && name !== "HEAD");
827
+ }
828
+
829
+ async function switchBranch(git: GitRunner, path: string, branch: string): Promise<void> {
830
+ const local = await git.run(["branch", "--list", branch], { cwd: path, optional: true });
831
+ if (local.trim()) {
832
+ await git.run(["switch", branch], { cwd: path, timeout: BRANCH_SWITCH_TIMEOUT_MS });
833
+ return;
834
+ }
835
+
836
+ const remote = await git.run(["show-ref", "--verify", `refs/remotes/origin/${branch}`], {
837
+ cwd: path,
838
+ optional: true,
839
+ timeout: BRANCH_LOOKUP_TIMEOUT_MS,
840
+ });
841
+ if (!remote.trim()) {
842
+ await git.run(["fetch", "--quiet", "--depth=1", "origin", `refs/heads/${branch}:refs/remotes/origin/${branch}`], {
843
+ cwd: path,
844
+ timeout: BRANCH_SWITCH_TIMEOUT_MS,
845
+ });
846
+ }
847
+ await git.run(["switch", "--track", "-c", branch, `origin/${branch}`], {
848
+ cwd: path,
849
+ timeout: BRANCH_SWITCH_TIMEOUT_MS,
850
+ });
851
+ }
852
+
853
+ function referenceNameFromUrl(url: string): string {
854
+ const name = basename(
855
+ url
856
+ .trim()
857
+ .replace(/[?#].*$/, "")
858
+ .replace(/\/+$/, "")
859
+ .replace(/\.git$/i, ""),
860
+ )
861
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
862
+ .replace(/^-+|-+$/g, "");
863
+
864
+ if (!name || name === "." || name === "..") throw new Error("Could not derive reference folder name from Git URL.");
865
+ return name;
866
+ }
867
+
868
+ function shellQuote(value: string): string {
869
+ return `'${value.replaceAll("'", "'\\''")}'`;
870
+ }
871
+
872
+ function errorText(error: unknown): string {
873
+ return error instanceof Error ? error.message : String(error);
874
+ }