@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,745 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ type Component,
5
+ Editor,
6
+ type Focusable,
7
+ getKeybindings,
8
+ Input,
9
+ Key,
10
+ type TUI,
11
+ truncateToWidth,
12
+ visibleWidth,
13
+ } from "@earendil-works/pi-tui";
14
+ import type { GitRunner } from "../../shared/git.ts";
15
+ import { errorText } from "../../shared/text.ts";
16
+ import { editorTheme } from "@shanepadgett/tau-tui";
17
+ import { bindingHint, rawHint, type ToolKeyHint } from "@shanepadgett/tau-tui";
18
+ import { SelectableList, type SelectableListItem, type SelectableListResult } from "@shanepadgett/tau-tui";
19
+ import { ToolPanel, type ToolPanelConfig } from "@shanepadgett/tau-tui";
20
+ import {
21
+ type CommitGroup,
22
+ type CommitPlanState,
23
+ generatePlan,
24
+ regenerateMessage,
25
+ requireCommitMessage,
26
+ } from "./commit-plan.ts";
27
+ import {
28
+ assertCommittableState,
29
+ type CommitEvidence,
30
+ computeWorktreeSignature,
31
+ type DirtyFile,
32
+ loadChangeSet,
33
+ } from "./git-change-set.ts";
34
+
35
+ const MAX_GROUPS = 10;
36
+ const MAX_PICKER_FILES = 14;
37
+
38
+ type ReviewMode = { kind: "groups" } | FileMode | MessageMode | NoteMode;
39
+
40
+ type FileMode =
41
+ | {
42
+ kind: "files";
43
+ purpose: "assign";
44
+ groupId: string;
45
+ list: SelectableList<CommitFileItem>;
46
+ selectedPaths: string[];
47
+ }
48
+ | {
49
+ kind: "files";
50
+ purpose: "new";
51
+ list: SelectableList<CommitFileItem>;
52
+ selectedPaths: string[];
53
+ };
54
+
55
+ type MessageMode =
56
+ | { kind: "message"; purpose: "edit"; groupId: string; editor: Editor }
57
+ | { kind: "message"; purpose: "new"; editor: Editor };
58
+
59
+ type NoteMode =
60
+ | { kind: "note"; target: "message"; groupId: string; input: Input }
61
+ | { kind: "note"; target: "plan"; input: Input };
62
+
63
+ interface CommitFileItem extends SelectableListItem {
64
+ path: string;
65
+ status: string;
66
+ ownerId?: string;
67
+ ownerSubject?: string;
68
+ }
69
+
70
+ export async function reviewPlan(
71
+ ctx: ExtensionCommandContext,
72
+ git: GitRunner,
73
+ root: string,
74
+ evidence: CommitEvidence,
75
+ initialPlan: CommitPlanState,
76
+ initialSelectedGroupId: string | undefined,
77
+ markerType: string,
78
+ ): Promise<CommitPlanState | undefined> {
79
+ return ctx.ui.custom<CommitPlanState | undefined>(
80
+ (tui, theme, _keybindings, done) =>
81
+ new CommitReviewPanel(
82
+ ctx,
83
+ tui,
84
+ theme,
85
+ git,
86
+ root,
87
+ evidence,
88
+ initialPlan,
89
+ initialSelectedGroupId,
90
+ markerType,
91
+ done,
92
+ ),
93
+ );
94
+ }
95
+
96
+ class CommitReviewPanel implements Component, Focusable {
97
+ private readonly ctx: ExtensionCommandContext;
98
+ private readonly tui: TUI;
99
+ private readonly theme: Theme;
100
+ private readonly git: GitRunner;
101
+ private readonly root: string;
102
+ private readonly markerType: string;
103
+ private readonly done: (state: CommitPlanState | undefined) => void;
104
+ private readonly panelConfig: ToolPanelConfig;
105
+ private readonly panel: ToolPanel;
106
+ private readonly body: Component;
107
+ private evidence: CommitEvidence;
108
+ private plan: CommitPlanState;
109
+ private selectedGroupId: string | undefined;
110
+ private mode: ReviewMode = { kind: "groups" };
111
+ private groupList: SelectableList<CommitGroup>;
112
+ private pendingDelete: CommitGroup | undefined;
113
+ private busyMessage: string | undefined;
114
+ private _focused = false;
115
+
116
+ constructor(
117
+ ctx: ExtensionCommandContext,
118
+ tui: TUI,
119
+ theme: Theme,
120
+ git: GitRunner,
121
+ root: string,
122
+ evidence: CommitEvidence,
123
+ initialPlan: CommitPlanState,
124
+ initialSelectedGroupId: string | undefined,
125
+ markerType: string,
126
+ done: (state: CommitPlanState | undefined) => void,
127
+ ) {
128
+ this.ctx = ctx;
129
+ this.tui = tui;
130
+ this.theme = theme;
131
+ this.git = git;
132
+ this.root = root;
133
+ this.evidence = evidence;
134
+ this.plan = initialPlan;
135
+ this.selectedGroupId = initialSelectedGroupId;
136
+ this.markerType = markerType;
137
+ this.done = done;
138
+ this.groupList = this.createGroupList(initialPlan.groups);
139
+ this.groupList.setItems(initialPlan.groups, initialSelectedGroupId);
140
+ this.body = {
141
+ render: (width) => this.renderBody(width),
142
+ invalidate: () => this.activeBody().invalidate(),
143
+ };
144
+ this.panelConfig = {
145
+ title: this.titleText(),
146
+ secondary: this.secondaryText(),
147
+ header: this.headerLines(),
148
+ body: this.body,
149
+ footer: this.footer(),
150
+ };
151
+ this.panel = new ToolPanel(theme, this.panelConfig);
152
+ }
153
+
154
+ get focused(): boolean {
155
+ return this._focused;
156
+ }
157
+
158
+ set focused(value: boolean) {
159
+ this._focused = value;
160
+ this.syncFocus();
161
+ }
162
+
163
+ render = (width: number): string[] => {
164
+ return this.panel.render(width);
165
+ };
166
+
167
+ invalidate = (): void => {
168
+ this.panel.invalidate();
169
+ };
170
+
171
+ handleInput(data: string): void {
172
+ if (this.busyMessage) return;
173
+ if (this.pendingDelete) {
174
+ this.handleDeleteAck(data);
175
+ return;
176
+ }
177
+
178
+ if (this.mode.kind === "groups") this.handleGroupsInput(data);
179
+ else if (this.mode.kind === "files") this.handleFilesInput(data, this.mode);
180
+ else if (this.mode.kind === "message") this.handleMessageInput(data, this.mode);
181
+ else this.handleNoteInput(data, this.mode);
182
+ }
183
+
184
+ private createGroupList(groups: readonly CommitGroup[]): SelectableList<CommitGroup> {
185
+ return new SelectableList(this.theme, {
186
+ items: groups,
187
+ emptyMessage: "No commit groups. Press n to create one.",
188
+ selection: { kind: "single", primaryLabel: "commit" },
189
+ actions: [
190
+ { id: "edit", key: "e", hint: rawHint("e", "edit") },
191
+ { id: "files", key: "f", hint: rawHint("f", "files") },
192
+ { id: "regenMessage", key: "r", hint: rawHint("r", "regen") },
193
+ { id: "delete", key: Key.delete, hint: rawHint("delete", "delete") },
194
+ { id: "new", key: "n", hint: rawHint("n", "new") },
195
+ { id: "regenPlan", key: Key.shift("r"), hint: rawHint("R", "regen plan") },
196
+ ],
197
+ cancelLabel: "cancel",
198
+ maxVisible: MAX_GROUPS,
199
+ renderItem: (group, state, width) => this.renderGroup(group, state.active, width),
200
+ onResult: (result) => this.handleGroupResult(result),
201
+ });
202
+ }
203
+
204
+ private handleGroupsInput(data: string): void {
205
+ this.groupList.handleInput(data);
206
+ this.syncSelectedGroupFromCursor();
207
+ this.syncPanel();
208
+ }
209
+
210
+ private handleGroupResult(result: SelectableListResult<CommitGroup>): void {
211
+ if (result.kind === "cancel") {
212
+ this.ctx.ui.notify("Commit cancelled.", "info");
213
+ this.done(undefined);
214
+ return;
215
+ }
216
+ if (result.kind === "primary") {
217
+ this.done(this.plan);
218
+ return;
219
+ }
220
+
221
+ switch (result.actionId) {
222
+ case "new":
223
+ this.openNewGroupFiles();
224
+ return;
225
+ case "regenPlan":
226
+ this.openPlanRegenerationNote();
227
+ return;
228
+ }
229
+
230
+ const item = result.items[0];
231
+ if (!item) return;
232
+ this.selectedGroupId = item.id;
233
+ switch (result.actionId) {
234
+ case "edit":
235
+ this.openEditMessage(item);
236
+ return;
237
+ case "files":
238
+ this.openAssignFiles(item);
239
+ return;
240
+ case "regenMessage":
241
+ this.openMessageRegenerationNote(item);
242
+ return;
243
+ case "delete":
244
+ this.pendingDelete = item;
245
+ this.syncPanel();
246
+ return;
247
+ }
248
+ }
249
+
250
+ private handleDeleteAck(data: string): void {
251
+ const keybindings = getKeybindings();
252
+ if (keybindings.matches(data, "tui.select.confirm")) {
253
+ this.confirmDeleteGroup();
254
+ return;
255
+ }
256
+ if (keybindings.matches(data, "tui.select.cancel")) {
257
+ this.pendingDelete = undefined;
258
+ this.syncPanel();
259
+ }
260
+ }
261
+
262
+ private handleFilesInput(data: string, mode: FileMode): void {
263
+ if (mode.list.isFilterFocused()) {
264
+ mode.list.handleInput(data);
265
+ this.syncPanel();
266
+ return;
267
+ }
268
+
269
+ const keybindings = getKeybindings();
270
+ if (keybindings.matches(data, "tui.select.confirm")) {
271
+ this.saveFileSelection(mode);
272
+ return;
273
+ }
274
+ if (keybindings.matches(data, "tui.select.cancel")) {
275
+ this.showGroups(mode.purpose === "assign" ? mode.groupId : this.selectedGroupId);
276
+ return;
277
+ }
278
+
279
+ mode.list.handleInput(data);
280
+ this.syncPanel();
281
+ }
282
+
283
+ private handleMessageInput(data: string, mode: MessageMode): void {
284
+ if (getKeybindings().matches(data, "tui.select.cancel")) {
285
+ this.showGroups(mode.purpose === "edit" ? mode.groupId : this.selectedGroupId);
286
+ return;
287
+ }
288
+ mode.editor.handleInput(data);
289
+ this.syncPanel();
290
+ }
291
+
292
+ private handleNoteInput(data: string, mode: NoteMode): void {
293
+ if (getKeybindings().matches(data, "tui.select.cancel")) {
294
+ this.showGroups(mode.target === "message" ? mode.groupId : this.selectedGroupId);
295
+ return;
296
+ }
297
+ mode.input.handleInput(data);
298
+ this.syncPanel();
299
+ }
300
+
301
+ private openAssignFiles(group: CommitGroup): void {
302
+ const { list, selectedPaths } = this.createFileList(group.id, group.files);
303
+ this.mode = { kind: "files", purpose: "assign", groupId: group.id, list, selectedPaths };
304
+ this.pendingDelete = undefined;
305
+ this.syncFocus();
306
+ this.syncPanel();
307
+ }
308
+
309
+ private openNewGroupFiles(): void {
310
+ const { list, selectedPaths } = this.createFileList(undefined, unassignedFiles(this.plan));
311
+ this.mode = { kind: "files", purpose: "new", list, selectedPaths };
312
+ this.pendingDelete = undefined;
313
+ this.syncFocus();
314
+ this.syncPanel();
315
+ }
316
+
317
+ private saveFileSelection(mode: FileMode): void {
318
+ if (mode.purpose === "assign") {
319
+ const group = groupById(this.plan, mode.groupId);
320
+ if (!group) {
321
+ this.showGroups(this.plan.groups[0]?.id);
322
+ return;
323
+ }
324
+ this.plan = assignSelectedFiles(this.plan, group.id, mode.selectedPaths);
325
+ this.showGroups(group.id);
326
+ return;
327
+ }
328
+
329
+ if (mode.selectedPaths.length === 0) {
330
+ this.ctx.ui.notify("No files selected.", "info");
331
+ this.showGroups(this.selectedGroupId);
332
+ return;
333
+ }
334
+
335
+ this.openNewMessage(mode.selectedPaths);
336
+ }
337
+
338
+ private openEditMessage(group: CommitGroup): void {
339
+ const editor = this.createMessageEditor(group.message);
340
+ editor.onSubmit = (value) => this.saveEditedMessage(group.id, value);
341
+ this.mode = { kind: "message", purpose: "edit", groupId: group.id, editor };
342
+ this.pendingDelete = undefined;
343
+ this.syncFocus();
344
+ this.syncPanel();
345
+ }
346
+
347
+ private openNewMessage(files: readonly string[]): void {
348
+ const editor = this.createMessageEditor("");
349
+ editor.onSubmit = (value) => void this.saveNewMessage(files, value);
350
+ this.mode = { kind: "message", purpose: "new", editor };
351
+ this.pendingDelete = undefined;
352
+ this.syncFocus();
353
+ this.syncPanel();
354
+ }
355
+
356
+ private saveEditedMessage(groupId: string, value: string): void {
357
+ if (!value.trim()) {
358
+ this.showGroups(groupId);
359
+ return;
360
+ }
361
+ try {
362
+ this.plan = setMessage(this.plan, groupId, requireCommitMessage(value));
363
+ this.showGroups(groupId);
364
+ } catch (error) {
365
+ this.showInvalidCommitMessage(value, error);
366
+ }
367
+ }
368
+
369
+ private async saveNewMessage(files: readonly string[], value: string): Promise<void> {
370
+ const trimmed = value.trim();
371
+ if (trimmed) {
372
+ try {
373
+ const group = { id: randomUUID(), message: requireCommitMessage(trimmed), files: [...files] };
374
+ this.plan = addGroup(this.plan, group);
375
+ this.showGroups(group.id);
376
+ } catch (error) {
377
+ this.showInvalidCommitMessage(value, error);
378
+ }
379
+ return;
380
+ }
381
+
382
+ await this.runBusy("Generating commit message", async () => {
383
+ const message = await regenerateMessage(this.ctx, this.evidence, files, this.plan.groups);
384
+ const group = { id: randomUUID(), message, files: [...files] };
385
+ this.plan = addGroup(this.plan, group);
386
+ this.showGroups(group.id);
387
+ });
388
+ }
389
+
390
+ private openMessageRegenerationNote(group: CommitGroup): void {
391
+ const input = this.createNoteInput((note) => void this.regenerateSelectedMessage(group.id, note));
392
+ this.mode = { kind: "note", target: "message", groupId: group.id, input };
393
+ this.pendingDelete = undefined;
394
+ this.syncFocus();
395
+ this.syncPanel();
396
+ }
397
+
398
+ private openPlanRegenerationNote(): void {
399
+ const input = this.createNoteInput((note) => void this.regenerateWholePlan(note));
400
+ this.mode = { kind: "note", target: "plan", input };
401
+ this.pendingDelete = undefined;
402
+ this.syncFocus();
403
+ this.syncPanel();
404
+ }
405
+
406
+ private async regenerateSelectedMessage(groupId: string, note: string): Promise<void> {
407
+ const group = groupById(this.plan, groupId);
408
+ if (!group) {
409
+ this.showGroups(this.plan.groups[0]?.id);
410
+ return;
411
+ }
412
+
413
+ await this.runBusy(`Regenerating ${subjectLine(group.message)}`, async () => {
414
+ const message = await regenerateMessage(
415
+ this.ctx,
416
+ this.evidence,
417
+ group.files,
418
+ this.plan.groups,
419
+ group.id,
420
+ note,
421
+ );
422
+ this.plan = setMessage(this.plan, group.id, message);
423
+ this.showGroups(group.id);
424
+ });
425
+ }
426
+
427
+ private async regenerateWholePlan(note: string): Promise<void> {
428
+ await this.runBusy("Regenerating commit plan", async () => {
429
+ const evidence = await loadChangeSet(
430
+ this.git,
431
+ this.root,
432
+ this.ctx.sessionManager.getBranch(),
433
+ this.markerType,
434
+ );
435
+ assertCommittableState(evidence.files);
436
+ const plan = {
437
+ files: evidence.files,
438
+ worktreeSignature: await computeWorktreeSignature(this.git, this.root, evidence.files),
439
+ groups: await generatePlan(this.ctx, evidence, this.plan.groups, note),
440
+ };
441
+ this.evidence = evidence;
442
+ this.plan = plan;
443
+ this.showGroups(plan.groups[0]?.id);
444
+ });
445
+ }
446
+
447
+ private createFileList(
448
+ targetGroupId: string | undefined,
449
+ initialFiles: readonly string[],
450
+ ): { list: SelectableList<CommitFileItem>; selectedPaths: string[] } {
451
+ const ownerByPath = new Map(
452
+ this.plan.groups.flatMap((group) => group.files.map((file) => [file, group] as const)),
453
+ );
454
+ const initial = new Set(initialFiles);
455
+ const items = orderPickerFiles(this.plan, targetGroupId, initialFiles).map((file) =>
456
+ toCommitFileItem(file, ownerByPath.get(file.path)),
457
+ );
458
+ const selectedPaths = items.filter((item) => initial.has(item.path)).map((item) => item.path);
459
+ const list = new SelectableList(this.theme, {
460
+ items,
461
+ emptyMessage: "No dirty files.",
462
+ selection: { kind: "multi" },
463
+ filter: { searchText: (item) => `${item.path} ${item.status} ${item.ownerSubject ?? "unassigned"}` },
464
+ actions: [],
465
+ maxVisible: MAX_PICKER_FILES,
466
+ renderItem: (item, state, width) => this.renderFile(item, targetGroupId, state.active, width),
467
+ onResult: () => {},
468
+ onSelectionChange: (selected) => {
469
+ const mode = this.mode;
470
+ if (mode.kind !== "files") return;
471
+ mode.selectedPaths = selected.map((item) => item.path);
472
+ this.syncPanel();
473
+ },
474
+ });
475
+ list.setSelectedIds(initialFiles);
476
+ return { list, selectedPaths };
477
+ }
478
+
479
+ private createMessageEditor(value: string): Editor {
480
+ const editor = new Editor(this.tui, editorTheme(this.theme));
481
+ editor.setText(value);
482
+ editor.focused = this._focused;
483
+ return editor;
484
+ }
485
+
486
+ private showInvalidCommitMessage(value: string, error: unknown): void {
487
+ const mode = this.mode;
488
+ if (mode.kind === "message") mode.editor.setText(value);
489
+ this.ctx.ui.notify(`Invalid commit message: ${errorText(error)}`, "error");
490
+ this.syncPanel();
491
+ }
492
+
493
+ private createNoteInput(onSubmit: (value: string) => void): Input {
494
+ const input = new Input();
495
+ input.focused = this._focused;
496
+ input.onSubmit = onSubmit;
497
+ input.onEscape = () => this.showGroups(this.selectedGroupId);
498
+ return input;
499
+ }
500
+
501
+ private confirmDeleteGroup(): void {
502
+ const pending = this.pendingDelete;
503
+ if (!pending) return;
504
+ const oldIndex = this.plan.groups.findIndex((group) => group.id === pending.id);
505
+ const groups = this.plan.groups.filter((group) => group.id !== pending.id);
506
+ this.plan = { ...this.plan, groups };
507
+ this.pendingDelete = undefined;
508
+ this.showGroups(groups[Math.min(oldIndex, Math.max(0, groups.length - 1))]?.id);
509
+ }
510
+
511
+ private async runBusy(message: string, task: () => Promise<void>): Promise<void> {
512
+ if (this.busyMessage) return;
513
+ this.busyMessage = message;
514
+ this.syncPanel();
515
+ try {
516
+ await task();
517
+ } catch (error) {
518
+ this.ctx.ui.notify(`${message} failed: ${errorText(error)}`, "error");
519
+ } finally {
520
+ this.busyMessage = undefined;
521
+ this.syncPanel();
522
+ }
523
+ }
524
+
525
+ private showGroups(activeId: string | undefined): void {
526
+ this.mode = { kind: "groups" };
527
+ this.pendingDelete = undefined;
528
+ this.selectedGroupId = this.plan.groups.some((group) => group.id === activeId)
529
+ ? activeId
530
+ : (this.plan.groups[0]?.id ?? undefined);
531
+ this.groupList.setItems(this.plan.groups, this.selectedGroupId);
532
+ this.syncFocus();
533
+ this.syncPanel();
534
+ }
535
+
536
+ private syncSelectedGroupFromCursor(): void {
537
+ this.selectedGroupId = this.groupList.getCurrentItem()?.id;
538
+ }
539
+
540
+ private syncPanel(): void {
541
+ this.panelConfig.title = this.titleText();
542
+ this.panelConfig.secondary = this.secondaryText();
543
+ this.panelConfig.header = this.headerLines();
544
+ this.panelConfig.footer = this.footer();
545
+ this.tui.requestRender();
546
+ }
547
+
548
+ private syncFocus(): void {
549
+ this.groupList.focused = this._focused && this.mode.kind === "groups";
550
+ if (this.mode.kind === "files") this.mode.list.focused = this._focused;
551
+ if (this.mode.kind === "message") this.mode.editor.focused = this._focused;
552
+ if (this.mode.kind === "note") this.mode.input.focused = this._focused;
553
+ }
554
+
555
+ private activeBody(): Component {
556
+ if (this.mode.kind === "files") return this.mode.list;
557
+ if (this.mode.kind === "message") return this.mode.editor;
558
+ if (this.mode.kind === "note") return this.mode.input;
559
+ return this.groupList;
560
+ }
561
+
562
+ private renderBody(width: number): string[] {
563
+ if (this.mode.kind === "message") return this.renderMessageEditor(this.mode, width);
564
+ if (this.mode.kind === "note") return this.renderNoteInput(this.mode, width);
565
+ return this.activeBody().render(width);
566
+ }
567
+
568
+ private renderMessageEditor(mode: MessageMode, width: number): string[] {
569
+ const renderWidth = Math.max(1, width);
570
+ const label = mode.purpose === "new" ? "Commit message (empty = auto-generate)" : "Commit message";
571
+ return [
572
+ truncateToWidth(this.theme.fg("accent", this.theme.bold(label)), renderWidth, ""),
573
+ ...mode.editor.render(renderWidth),
574
+ ];
575
+ }
576
+
577
+ private renderNoteInput(mode: NoteMode, width: number): string[] {
578
+ const renderWidth = Math.max(1, width);
579
+ const label = mode.target === "plan" ? "Regeneration note for plan" : "Regeneration note for message";
580
+ return [
581
+ truncateToWidth(this.theme.fg("accent", this.theme.bold(`${label} (optional)`)), renderWidth, ""),
582
+ ...mode.input.render(renderWidth),
583
+ ];
584
+ }
585
+
586
+ private renderGroup(group: CommitGroup, active: boolean, width: number): string[] {
587
+ const subject = subjectLine(group.message);
588
+ const count = `${group.files.length} file${group.files.length === 1 ? "" : "s"}`;
589
+ const suffix = this.theme.fg(group.files.length === 0 ? "warning" : "dim", ` ${count}`);
590
+ const titleWidth = Math.max(8, width - visibleWidth(count) - 2);
591
+ const title = this.theme.fg(active ? "accent" : "text", truncateToWidth(subject, titleWidth, ""));
592
+ return [truncateToWidth(`${title}${suffix}`, width, "")];
593
+ }
594
+
595
+ private renderFile(
596
+ item: CommitFileItem,
597
+ targetGroupId: string | undefined,
598
+ active: boolean,
599
+ width: number,
600
+ ): string[] {
601
+ const owner = item.ownerId
602
+ ? item.ownerId === targetGroupId
603
+ ? "current"
604
+ : `currently: ${item.ownerSubject ?? item.ownerId}`
605
+ : "";
606
+ const hint = owner ? this.theme.fg(item.ownerId === targetGroupId ? "success" : "muted", ` ${owner}`) : "";
607
+ const pathWidth = Math.max(8, width - visibleWidth(item.status) - visibleWidth(owner) - 4);
608
+ const path = this.theme.fg(active ? "accent" : "text", truncateToWidth(item.path, pathWidth, ""));
609
+ return [truncateToWidth(`${this.theme.fg("muted", item.status)} ${path}${hint}`, width, "")];
610
+ }
611
+
612
+ private titleText(): string {
613
+ if (this.mode.kind === "files") return this.mode.purpose === "new" ? "New commit files" : "Assign files";
614
+ if (this.mode.kind === "message") return this.mode.purpose === "new" ? "New commit" : "Edit commit";
615
+ if (this.mode.kind === "note") return "Regenerate";
616
+ return "Commit plan";
617
+ }
618
+
619
+ private secondaryText(): string {
620
+ if (this.mode.kind === "files") {
621
+ return `${this.mode.selectedPaths.length} selected · ${this.plan.files.length} files`;
622
+ }
623
+ const groupCount = this.plan.groups.length;
624
+ return `${groupCount} commit${groupCount === 1 ? "" : "s"} · ${this.plan.files.length} files`;
625
+ }
626
+
627
+ private headerLines(): readonly string[] {
628
+ const lines: string[] = [];
629
+ if (this.busyMessage) lines.push(this.theme.fg("muted", this.busyMessage));
630
+ if (this.mode.kind === "groups") {
631
+ const count = unassignedFiles(this.plan).length;
632
+ if (count > 0) lines.push(this.theme.fg("warning", `${count} unassigned file(s)`));
633
+ }
634
+ return lines;
635
+ }
636
+
637
+ private footer(): ToolPanelConfig["footer"] {
638
+ if (this.pendingDelete) {
639
+ return {
640
+ kind: "destructiveAck",
641
+ message: `Remove commit group: ${subjectLine(this.pendingDelete.message)}?`,
642
+ hints: [bindingHint("tui.select.confirm", "confirm"), bindingHint("tui.select.cancel", "cancel")],
643
+ };
644
+ }
645
+ return { kind: "hints", hints: this.footerHints() };
646
+ }
647
+
648
+ private footerHints(): readonly ToolKeyHint[] {
649
+ if (this.busyMessage) return [];
650
+ if (this.mode.kind === "files") {
651
+ if (this.mode.list.isFilterFocused()) return this.mode.list.getKeyHints();
652
+ return [
653
+ ...this.mode.list.getKeyHints(),
654
+ bindingHint("tui.select.confirm", "save"),
655
+ bindingHint("tui.select.cancel", "cancel"),
656
+ ];
657
+ }
658
+ if (this.mode.kind === "message") {
659
+ return [
660
+ bindingHint("tui.input.submit", this.mode.purpose === "new" ? "save/auto" : "save"),
661
+ bindingHint("tui.input.newLine", "newline"),
662
+ bindingHint("tui.select.cancel", "cancel"),
663
+ ];
664
+ }
665
+ if (this.mode.kind === "note") {
666
+ return [bindingHint("tui.input.submit", "generate"), bindingHint("tui.select.cancel", "cancel")];
667
+ }
668
+
669
+ return this.groupList.getKeyHints();
670
+ }
671
+ }
672
+
673
+ function setMessage(plan: CommitPlanState, groupId: string, message: string): CommitPlanState {
674
+ return { ...plan, groups: plan.groups.map((group) => (group.id === groupId ? { ...group, message } : group)) };
675
+ }
676
+
677
+ function assignSelectedFiles(plan: CommitPlanState, groupId: string, files: readonly string[]): CommitPlanState {
678
+ const selected = new Set(files);
679
+ return {
680
+ ...plan,
681
+ groups: plan.groups.map((group) =>
682
+ group.id === groupId
683
+ ? { ...group, files: [...selected] }
684
+ : { ...group, files: group.files.filter((file) => !selected.has(file)) },
685
+ ),
686
+ };
687
+ }
688
+
689
+ function addGroup(plan: CommitPlanState, group: CommitGroup): CommitPlanState {
690
+ const selected = new Set(group.files);
691
+ return {
692
+ ...plan,
693
+ groups: [
694
+ ...plan.groups.map((item) => ({ ...item, files: item.files.filter((file) => !selected.has(file)) })),
695
+ group,
696
+ ],
697
+ };
698
+ }
699
+
700
+ function groupById(plan: CommitPlanState, groupId: string): CommitGroup | undefined {
701
+ return plan.groups.find((item) => item.id === groupId);
702
+ }
703
+
704
+ function unassignedFiles(plan: CommitPlanState): string[] {
705
+ const assigned = new Set(plan.groups.flatMap((group) => group.files));
706
+ return plan.files.filter((file) => !assigned.has(file.path)).map((file) => file.path);
707
+ }
708
+
709
+ function orderPickerFiles(
710
+ plan: CommitPlanState,
711
+ targetGroupId: string | undefined,
712
+ initialFiles: readonly string[],
713
+ ): DirtyFile[] {
714
+ const initial = new Set(initialFiles);
715
+ const assigned = new Set(plan.groups.flatMap((group) => group.files));
716
+ return [...plan.files].sort(
717
+ (left, right) =>
718
+ rankPickerFile(left.path, targetGroupId, initial, assigned) -
719
+ rankPickerFile(right.path, targetGroupId, initial, assigned) || left.path.localeCompare(right.path),
720
+ );
721
+ }
722
+
723
+ function rankPickerFile(
724
+ path: string,
725
+ targetGroupId: string | undefined,
726
+ initial: ReadonlySet<string>,
727
+ assigned: ReadonlySet<string>,
728
+ ): number {
729
+ if (initial.has(path)) return 0;
730
+ if (!targetGroupId && !assigned.has(path)) return 1;
731
+ return 2;
732
+ }
733
+
734
+ function toCommitFileItem(file: DirtyFile, owner: CommitGroup | undefined): CommitFileItem {
735
+ return {
736
+ id: file.path,
737
+ path: file.path,
738
+ status: file.status,
739
+ ...(owner ? { ownerId: owner.id, ownerSubject: subjectLine(owner.message) } : {}),
740
+ };
741
+ }
742
+
743
+ function subjectLine(message: string): string {
744
+ return message.split("\n")[0] ?? message;
745
+ }