@shanepadgett/tau-agent 0.38.0 → 0.39.1

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.
@@ -11,6 +11,10 @@ Press `Ctrl+F` in the picker to fetch all configured remotes and refresh the
11
11
  open list. Selecting a remote branch creates a local branch that tracks it.
12
12
  Cancelling the picker does not switch branches.
13
13
 
14
+ After you pick a branch, Tau asks you to confirm. On confirm it switches, then
15
+ runs `git clean -fd` so untracked files and folders left from other branches
16
+ are removed. Ignored files such as `.env` stay.
17
+
14
18
  Run `/branch new`, choose `feature`, `fix`, or `chore`, then enter a branch
15
19
  name. The name is lowercased, punctuation and whitespace become hyphens, and
16
20
  leading or trailing hyphens are removed.
@@ -18,5 +22,5 @@ leading or trailing hyphens are removed.
18
22
  For example, selecting `fix` and entering `Fix login. Please` creates and
19
23
  switches to `fix/fix-login-please`.
20
24
 
21
- Cancelling either creation prompt creates nothing. Uncommitted changes follow
22
- normal `git switch` behavior for both switching and creation.
25
+ Cancelling either creation prompt creates nothing. Uncommitted tracked changes
26
+ follow normal `git switch` behavior for both switching and creation.
@@ -99,8 +99,15 @@ async function chooseBranch(git: GitRunner, ctx: ExtensionCommandContext): Promi
99
99
  });
100
100
  if (!choice) return;
101
101
 
102
+ const confirmed = await ctx.ui.confirm(
103
+ `Switch to ${choice.name}?`,
104
+ "After switch, untracked files and folders will be removed. Ignored files like .env stay.",
105
+ );
106
+ if (!confirmed) return;
107
+
102
108
  if (choice.kind === "local") await git.run(["switch", choice.name], { cwd: root });
103
109
  else await git.run(["switch", "--track", "-c", choice.name, choice.upstream], { cwd: root });
110
+ await git.run(["clean", "-fd"], { cwd: root });
104
111
  ctx.ui.notify(`Switched to ${choice.name}.`, "info");
105
112
  }
106
113
 
@@ -10,6 +10,6 @@ Generate a semantic commit plan from current repository changes, review it, then
10
10
 
11
11
  ## Overview
12
12
 
13
- `/commit` looks at staged, unstaged, and untracked files, gathers bounded change evidence, and asks a model for a small set of meaningful conventional commits. You can review the plan in the TUI, edit messages, move files between commits, regenerate messages, and then commit the approved groups.
13
+ `/commit` takes over the editor for the full run. It gathers change evidence, asks a model for a small set of meaningful conventional commits, lets you review and edit the plan, commits the approved groups, then asks whether to run `git push`.
14
14
 
15
- Unassigned files are left uncommitted. After successful commits, Tau asks whether to run `git push`.
15
+ Unassigned files are left uncommitted. Progress and prompts stay in the commit panel until the flow ends; success still uses the normal notify messages.
@@ -45,11 +45,16 @@ interface CommitPlanToolCommit {
45
45
  files: readonly number[];
46
46
  }
47
47
 
48
+ export interface CommitGenerationOptions {
49
+ onStatus?: (status: string) => void | Promise<void>;
50
+ }
51
+
48
52
  export async function generatePlan(
49
53
  ctx: ExtensionCommandContext,
50
54
  evidence: CommitEvidence,
51
55
  previousPlan: readonly CommitGroup[] = [],
52
56
  regenerationNote = "",
57
+ options?: CommitGenerationOptions,
53
58
  ): Promise<CommitGroup[]> {
54
59
  const prompt = buildPlanPrompt(evidence, previousPlan, regenerationNote);
55
60
  return generateToolValidated(
@@ -68,7 +73,11 @@ export async function generatePlan(
68
73
  "Previous response:",
69
74
  text,
70
75
  ].join("\n"),
71
- { statusKey: "commit", notifyOnFallback: true },
76
+ {
77
+ statusKey: options?.onStatus ? undefined : "commit",
78
+ onStatus: options?.onStatus,
79
+ notifyOnFallback: true,
80
+ },
72
81
  );
73
82
  }
74
83
 
@@ -79,6 +88,7 @@ export async function regenerateMessage(
79
88
  previousPlan: readonly CommitGroup[] = [],
80
89
  selectedGroupId: string | undefined = undefined,
81
90
  regenerationNote = "",
91
+ options?: CommitGenerationOptions,
82
92
  ): Promise<string> {
83
93
  const selected = evidence.files.filter((file) => files.includes(file.path));
84
94
  const prompt = buildMessagePrompt(evidence, selected, previousPlan, selectedGroupId, regenerationNote);
@@ -89,7 +99,8 @@ export async function regenerateMessage(
89
99
  requireCommitMessage,
90
100
  undefined,
91
101
  {
92
- statusKey: "commit",
102
+ statusKey: options?.onStatus ? undefined : "commit",
103
+ onStatus: options?.onStatus,
93
104
  notifyOnFallback: true,
94
105
  },
95
106
  );
@@ -1,40 +1,21 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { emitAgentBlocked } from "../../shared/agent-blocked.ts";
3
- import { createGitRunner, type GitRunner, loadRepoStatus } from "../../shared/git.ts";
2
+ import { createGitRunner, loadRepoStatus } from "../../shared/git.ts";
4
3
  import { errorText } from "../../shared/text.ts";
5
4
  import { type CommitPlanState, generatePlan } from "./commit-plan.ts";
6
- import {
7
- assertCommittableState,
8
- commitStaged,
9
- computeWorktreeSignature,
10
- type DirtyFile,
11
- loadChangeSet,
12
- stageFilesOnly,
13
- } from "./git-change-set.ts";
14
- import { reviewPlan } from "./review-ui.ts";
5
+ import { assertCommittableState, computeWorktreeSignature, loadChangeSet } from "./git-change-set.ts";
6
+ import { executeCommitPlan, PartialCommitError, runCommitFlow } from "./review-ui.ts";
15
7
 
16
8
  const COMMIT_MARKER_TYPE = "tau.commit";
17
- const PUSH_TIMEOUT_MS = 120_000;
18
-
19
- interface CommitMarker {
20
- hash: string;
21
- subject: string;
22
- timestamp: number;
23
- }
24
9
 
25
10
  export default function commitExtension(pi: ExtensionAPI): void {
26
11
  pi.registerCommand("commit", {
27
12
  description: "Generate semantic commit groups and commit selected repository changes",
28
13
  handler: async (_args, ctx) => {
29
14
  await ctx.waitForIdle();
30
- ctx.ui.setStatus("commit", "preparing commit plan");
31
-
32
15
  try {
33
16
  await runCommit(pi, ctx);
34
17
  } catch (error) {
35
18
  ctx.ui.notify(`Commit failed: ${errorText(error)}`, "error");
36
- } finally {
37
- ctx.ui.setStatus("commit", undefined);
38
19
  }
39
20
  },
40
21
  });
@@ -56,83 +37,35 @@ async function runCommit(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promis
56
37
  return;
57
38
  }
58
39
 
40
+ if (ctx.hasUI) {
41
+ await runCommitFlow(pi, ctx, git, repo.root, COMMIT_MARKER_TYPE);
42
+ return;
43
+ }
44
+
59
45
  const evidence = await loadChangeSet(git, repo.root, ctx.sessionManager.getBranch(), COMMIT_MARKER_TYPE);
60
46
  assertCommittableState(evidence.files);
61
- let state: CommitPlanState = {
47
+ const state: CommitPlanState = {
62
48
  files: evidence.files,
63
49
  worktreeSignature: await computeWorktreeSignature(git, repo.root, evidence.files),
64
50
  groups: await generatePlan(ctx, evidence),
65
51
  };
66
-
67
- if (ctx.hasUI) {
68
- emitAgentBlocked(pi, { body: "Waiting for commit plan review", source: "commit.review" });
69
- const reviewed = await reviewPlan(ctx, git, repo.root, evidence, state, state.groups[0]?.id, COMMIT_MARKER_TYPE);
70
- if (!reviewed) return;
71
- state = reviewed;
72
- }
73
-
74
- const completed = await executePlan(pi, ctx, git, repo.root, state);
75
- if (completed.length === 0) return;
76
-
77
- if (ctx.hasUI && (await ctx.ui.confirm("Push after commits?", "Run `git push` after all commits succeeded?"))) {
78
- ctx.ui.setStatus("commit", "pushing");
79
- await git.run(["push"], { cwd: repo.root, timeout: PUSH_TIMEOUT_MS });
52
+ try {
53
+ const completed = await executeCommitPlan(pi, git, repo.root, state, COMMIT_MARKER_TYPE);
54
+ if (completed.length === 0) {
55
+ ctx.ui.notify("No commit groups to execute.", "info");
56
+ return;
57
+ }
80
58
  ctx.ui.notify(
81
- `Committed and pushed ${completed.length} commit(s): ${completed.map((item) => item.hash).join(", ")}`,
59
+ `Committed ${completed.length} commit(s): ${completed.map((item) => item.hash).join(", ")}`,
82
60
  "info",
83
61
  );
84
- return;
85
- }
86
-
87
- ctx.ui.notify(`Committed ${completed.length} commit(s): ${completed.map((item) => item.hash).join(", ")}`, "info");
88
- }
89
-
90
- async function executePlan(
91
- pi: ExtensionAPI,
92
- ctx: ExtensionCommandContext,
93
- git: GitRunner,
94
- root: string,
95
- state: CommitPlanState,
96
- ): Promise<CommitMarker[]> {
97
- const groups = state.groups.filter((group) => group.files.length > 0);
98
- if (groups.length === 0) {
99
- ctx.ui.notify("No commit groups to execute.", "info");
100
- return [];
101
- }
102
-
103
- if ((await computeWorktreeSignature(git, root, state.files)) !== state.worktreeSignature) {
104
- throw new Error("Working tree changed during commit review. Regenerate the commit plan and try again.");
105
- }
106
-
107
- const filesByPath = new Map(state.files.map((file) => [file.path, file]));
108
- const completed: CommitMarker[] = [];
109
- try {
110
- for (const group of groups) {
111
- const subject = group.message.split("\n")[0] ?? group.message;
112
- ctx.ui.setStatus("commit", `committing ${subject}`);
113
- await stageFilesOnly(
114
- git,
115
- root,
116
- group.files.flatMap((path) => knownFile(filesByPath, path)),
117
- );
118
- const hash = await commitStaged(git, root, group.message);
119
- const marker = { hash, subject, timestamp: Date.now() };
120
- completed.push(marker);
121
- pi.appendEntry<CommitMarker>(COMMIT_MARKER_TYPE, marker);
122
- }
123
62
  } catch (error) {
124
- if (completed.length > 0) {
63
+ if (error instanceof PartialCommitError) {
125
64
  ctx.ui.notify(
126
- `Partially committed ${completed.length}: ${completed.map((item) => item.hash).join(", ")}; then failed: ${errorText(error)}`,
65
+ `Partially committed ${error.completed.length}: ${error.completed.map((item) => item.hash).join(", ")}; then failed: ${error.message}`,
127
66
  "warning",
128
67
  );
129
68
  }
130
69
  throw error;
131
70
  }
132
- return completed;
133
- }
134
-
135
- function knownFile(filesByPath: ReadonlyMap<string, DirtyFile>, path: string): DirtyFile[] {
136
- const file = filesByPath.get(path);
137
- return file ? [file] : [];
138
71
  }
@@ -1,20 +1,24 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
+ CancellableLoader,
4
5
  type Component,
5
6
  Editor,
6
7
  type Focusable,
7
8
  getKeybindings,
8
9
  Input,
9
10
  Key,
11
+ Loader,
12
+ matchesKey,
10
13
  type TUI,
11
14
  truncateToWidth,
12
15
  visibleWidth,
16
+ wrapTextWithAnsi,
13
17
  } from "@earendil-works/pi-tui";
18
+ import { emitAgentBlocked } from "../../shared/agent-blocked.ts";
14
19
  import type { GitRunner } from "../../shared/git.ts";
15
20
  import { errorText } from "../../shared/text.ts";
16
- import { editorTheme } from "@shanepadgett/tau-tui";
17
- import { bindingHint, rawHint, type ToolKeyHint } from "@shanepadgett/tau-tui";
21
+ import { bindingHint, editorTheme, rawHint, type ToolKeyHint } from "@shanepadgett/tau-tui";
18
22
  import { SelectableList, type SelectableListItem, type SelectableListResult } from "@shanepadgett/tau-tui";
19
23
  import { ToolPanel, type ToolPanelConfig } from "@shanepadgett/tau-tui";
20
24
  import {
@@ -27,13 +31,22 @@ import {
27
31
  import {
28
32
  assertCommittableState,
29
33
  type CommitEvidence,
34
+ commitStaged,
30
35
  computeWorktreeSignature,
31
36
  type DirtyFile,
32
37
  loadChangeSet,
38
+ stageFilesOnly,
33
39
  } from "./git-change-set.ts";
34
40
 
35
41
  const MAX_GROUPS = 10;
36
42
  const MAX_PICKER_FILES = 14;
43
+ const PUSH_TIMEOUT_MS = 120_000;
44
+
45
+ export interface CommitMarker {
46
+ hash: string;
47
+ subject: string;
48
+ timestamp: number;
49
+ }
37
50
 
38
51
  type ReviewMode = { kind: "groups" } | FileMode | MessageMode | NoteMode;
39
52
 
@@ -60,6 +73,29 @@ type NoteMode =
60
73
  | { kind: "note"; target: "message"; groupId: string; input: Input }
61
74
  | { kind: "note"; target: "plan"; input: Input };
62
75
 
76
+ type FlowPhase =
77
+ | { kind: "working"; cancellable: boolean }
78
+ | { kind: "review"; mode: ReviewMode }
79
+ | {
80
+ kind: "committing";
81
+ groups: readonly CommitGroup[];
82
+ rows: CommitProgressRow[];
83
+ detail: string;
84
+ }
85
+ | { kind: "pushAsk"; completed: readonly CommitMarker[] }
86
+ | {
87
+ kind: "failed";
88
+ message: string;
89
+ completed: readonly CommitMarker[];
90
+ rows: readonly CommitProgressRow[];
91
+ };
92
+
93
+ interface CommitProgressRow {
94
+ subject: string;
95
+ state: "pending" | "active" | "done" | "error";
96
+ hash?: string;
97
+ }
98
+
63
99
  interface CommitFileItem extends SelectableListItem {
64
100
  path: string;
65
101
  status: string;
@@ -67,79 +103,117 @@ interface CommitFileItem extends SelectableListItem {
67
103
  ownerSubject?: string;
68
104
  }
69
105
 
70
- export async function reviewPlan(
106
+ export async function runCommitFlow(
107
+ pi: ExtensionAPI,
71
108
  ctx: ExtensionCommandContext,
72
109
  git: GitRunner,
73
110
  root: string,
74
- evidence: CommitEvidence,
75
- initialPlan: CommitPlanState,
76
- initialSelectedGroupId: string | undefined,
77
111
  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,
112
+ ): Promise<void> {
113
+ await ctx.ui.custom<void>(
114
+ (tui, theme, _keybindings, done) => new CommitFlowPanel(pi, ctx, tui, theme, git, root, markerType, done),
115
+ );
116
+ }
117
+
118
+ export class PartialCommitError extends Error {
119
+ readonly completed: readonly CommitMarker[];
120
+
121
+ constructor(message: string, completed: readonly CommitMarker[]) {
122
+ super(message);
123
+ this.name = "PartialCommitError";
124
+ this.completed = completed;
125
+ }
126
+ }
127
+
128
+ export async function executeCommitPlan(
129
+ pi: ExtensionAPI,
130
+ git: GitRunner,
131
+ root: string,
132
+ state: CommitPlanState,
133
+ markerType: string,
134
+ onProgress?: (update: {
135
+ index: number;
136
+ total: number;
137
+ subject: string;
138
+ phase: "start" | "done";
139
+ hash?: string;
140
+ }) => void,
141
+ ): Promise<CommitMarker[]> {
142
+ const groups = state.groups.filter((group) => group.files.length > 0);
143
+ if (groups.length === 0) return [];
144
+
145
+ if ((await computeWorktreeSignature(git, root, state.files)) !== state.worktreeSignature) {
146
+ throw new Error("Working tree changed during commit review. Regenerate the commit plan and try again.");
147
+ }
148
+
149
+ const filesByPath = new Map(state.files.map((file) => [file.path, file]));
150
+ const completed: CommitMarker[] = [];
151
+ try {
152
+ for (const [index, group] of groups.entries()) {
153
+ const subject = subjectLine(group.message);
154
+ onProgress?.({ index, total: groups.length, subject, phase: "start" });
155
+ await stageFilesOnly(
85
156
  git,
86
157
  root,
87
- evidence,
88
- initialPlan,
89
- initialSelectedGroupId,
90
- markerType,
91
- done,
92
- ),
93
- );
158
+ group.files.flatMap((path) => knownFile(filesByPath, path)),
159
+ );
160
+ const hash = await commitStaged(git, root, group.message);
161
+ const marker = { hash, subject, timestamp: Date.now() };
162
+ completed.push(marker);
163
+ pi.appendEntry<CommitMarker>(markerType, marker);
164
+ onProgress?.({ index, total: groups.length, subject, phase: "done", hash });
165
+ }
166
+ } catch (error) {
167
+ if (completed.length > 0) throw new PartialCommitError(errorText(error), completed);
168
+ throw error;
169
+ }
170
+ return completed;
94
171
  }
95
172
 
96
- class CommitReviewPanel implements Component, Focusable {
173
+ class CommitFlowPanel implements Component, Focusable {
174
+ private readonly pi: ExtensionAPI;
97
175
  private readonly ctx: ExtensionCommandContext;
98
176
  private readonly tui: TUI;
99
177
  private readonly theme: Theme;
100
178
  private readonly git: GitRunner;
101
179
  private readonly root: string;
102
180
  private readonly markerType: string;
103
- private readonly done: (state: CommitPlanState | undefined) => void;
181
+ private readonly done: () => void;
104
182
  private readonly panelConfig: ToolPanelConfig;
105
183
  private readonly panel: ToolPanel;
106
184
  private readonly body: Component;
107
- private evidence: CommitEvidence;
108
- private plan: CommitPlanState;
185
+ private evidence: CommitEvidence | undefined;
186
+ private plan: CommitPlanState | undefined;
109
187
  private selectedGroupId: string | undefined;
110
- private mode: ReviewMode = { kind: "groups" };
188
+ private phase: FlowPhase = { kind: "working", cancellable: true };
111
189
  private groupList: SelectableList<CommitGroup>;
112
190
  private pendingDelete: CommitGroup | undefined;
113
- private busyMessage: string | undefined;
191
+ private working: Loader | CancellableLoader | undefined;
192
+ private closed = false;
114
193
  private _focused = false;
115
194
 
116
195
  constructor(
196
+ pi: ExtensionAPI,
117
197
  ctx: ExtensionCommandContext,
118
198
  tui: TUI,
119
199
  theme: Theme,
120
200
  git: GitRunner,
121
201
  root: string,
122
- evidence: CommitEvidence,
123
- initialPlan: CommitPlanState,
124
- initialSelectedGroupId: string | undefined,
125
202
  markerType: string,
126
- done: (state: CommitPlanState | undefined) => void,
203
+ done: () => void,
127
204
  ) {
205
+ this.pi = pi;
128
206
  this.ctx = ctx;
129
207
  this.tui = tui;
130
208
  this.theme = theme;
131
209
  this.git = git;
132
210
  this.root = root;
133
- this.evidence = evidence;
134
- this.plan = initialPlan;
135
- this.selectedGroupId = initialSelectedGroupId;
136
211
  this.markerType = markerType;
137
212
  this.done = done;
138
- this.groupList = this.createGroupList(initialPlan.groups);
139
- this.groupList.setItems(initialPlan.groups, initialSelectedGroupId);
213
+ this.groupList = this.createGroupList([]);
140
214
  this.body = {
141
215
  render: (width) => this.renderBody(width),
142
- invalidate: () => this.activeBody().invalidate(),
216
+ invalidate: () => this.activeBody()?.invalidate(),
143
217
  };
144
218
  this.panelConfig = {
145
219
  title: this.titleText(),
@@ -149,6 +223,7 @@ class CommitReviewPanel implements Component, Focusable {
149
223
  footer: this.footer(),
150
224
  };
151
225
  this.panel = new ToolPanel(theme, this.panelConfig);
226
+ void this.bootstrap();
152
227
  }
153
228
 
154
229
  get focused(): boolean {
@@ -169,16 +244,111 @@ class CommitReviewPanel implements Component, Focusable {
169
244
  };
170
245
 
171
246
  handleInput(data: string): void {
172
- if (this.busyMessage) return;
247
+ if (this.closed) return;
248
+
249
+ if (this.working instanceof CancellableLoader) {
250
+ this.working.handleInput(data);
251
+ return;
252
+ }
253
+ if (this.working || this.phase.kind === "working") return;
254
+ if (this.phase.kind === "committing") return;
255
+ if (this.phase.kind === "pushAsk") {
256
+ this.handlePushAskInput(data);
257
+ return;
258
+ }
259
+ if (this.phase.kind === "failed") {
260
+ if (
261
+ getKeybindings().matches(data, "tui.select.confirm") ||
262
+ getKeybindings().matches(data, "tui.select.cancel")
263
+ ) {
264
+ this.close();
265
+ }
266
+ return;
267
+ }
268
+
173
269
  if (this.pendingDelete) {
174
270
  this.handleDeleteAck(data);
175
271
  return;
176
272
  }
177
273
 
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);
274
+ const mode = this.phase.mode;
275
+ if (mode.kind === "groups") this.handleGroupsInput(data);
276
+ else if (mode.kind === "files") this.handleFilesInput(data, mode);
277
+ else if (mode.kind === "message") this.handleMessageInput(data, mode);
278
+ else this.handleNoteInput(data, mode);
279
+ }
280
+
281
+ private async bootstrap(): Promise<void> {
282
+ try {
283
+ this.startWorking("Gathering changes", true);
284
+ const evidence = await loadChangeSet(
285
+ this.git,
286
+ this.root,
287
+ this.ctx.sessionManager.getBranch(),
288
+ this.markerType,
289
+ );
290
+ if (this.isCancelled()) return this.cancelFlow();
291
+ assertCommittableState(evidence.files);
292
+ this.evidence = evidence;
293
+
294
+ this.startWorking("Generating plan", true);
295
+ const groups = await generatePlan(this.generationCtx(), evidence, [], "", {
296
+ onStatus: (status) => this.setWorkingMessage(status),
297
+ });
298
+ if (this.isCancelled()) return this.cancelFlow();
299
+
300
+ this.plan = {
301
+ files: evidence.files,
302
+ worktreeSignature: await computeWorktreeSignature(this.git, this.root, evidence.files),
303
+ groups,
304
+ };
305
+ if (this.isCancelled()) return this.cancelFlow();
306
+ this.stopWorking();
307
+ emitAgentBlocked(this.pi, { body: "Waiting for commit plan review", source: "commit.review" });
308
+ this.showGroups(groups[0]?.id);
309
+ } catch (error) {
310
+ if (this.isCancelled() || this.closed) return this.cancelFlow();
311
+ this.stopWorking();
312
+ this.ctx.ui.notify(`Commit failed: ${errorText(error)}`, "error");
313
+ this.close();
314
+ }
315
+ }
316
+
317
+ private generationCtx(): ExtensionCommandContext {
318
+ const signal = this.working instanceof CancellableLoader ? this.working.signal : this.ctx.signal;
319
+ return { ...this.ctx, signal };
320
+ }
321
+
322
+ private isCancelled(): boolean {
323
+ return this.working instanceof CancellableLoader && this.working.aborted;
324
+ }
325
+
326
+ private startWorking(message: string, cancellable: boolean): void {
327
+ this.stopWorking();
328
+ const accent = (text: string) => this.theme.fg("accent", text);
329
+ const muted = (text: string) => this.theme.fg("muted", text);
330
+ if (cancellable) {
331
+ const loader = new CancellableLoader(this.tui, accent, muted, message);
332
+ loader.onAbort = () => this.cancelFlow();
333
+ this.working = loader;
334
+ } else {
335
+ this.working = new Loader(this.tui, accent, muted, message);
336
+ }
337
+ this.phase = { kind: "working", cancellable };
338
+ this.syncFocus();
339
+ this.syncPanel();
340
+ }
341
+
342
+ private setWorkingMessage(message: string): void {
343
+ this.working?.setMessage(message);
344
+ this.tui.requestRender();
345
+ }
346
+
347
+ private stopWorking(): void {
348
+ if (!this.working) return;
349
+ if (this.working instanceof CancellableLoader) this.working.dispose();
350
+ else this.working.stop();
351
+ this.working = undefined;
182
352
  }
183
353
 
184
354
  private createGroupList(groups: readonly CommitGroup[]): SelectableList<CommitGroup> {
@@ -210,11 +380,11 @@ class CommitReviewPanel implements Component, Focusable {
210
380
  private handleGroupResult(result: SelectableListResult<CommitGroup>): void {
211
381
  if (result.kind === "cancel") {
212
382
  this.ctx.ui.notify("Commit cancelled.", "info");
213
- this.done(undefined);
383
+ this.close();
214
384
  return;
215
385
  }
216
386
  if (result.kind === "primary") {
217
- this.done(this.plan);
387
+ void this.beginCommitting();
218
388
  return;
219
389
  }
220
390
 
@@ -298,30 +468,38 @@ class CommitReviewPanel implements Component, Focusable {
298
468
  this.syncPanel();
299
469
  }
300
470
 
471
+ private handlePushAskInput(data: string): void {
472
+ const keybindings = getKeybindings();
473
+ if (keybindings.matches(data, "tui.select.confirm") || matchesKey(data, "y") || data === "y" || data === "Y") {
474
+ void this.beginPush();
475
+ return;
476
+ }
477
+ if (keybindings.matches(data, "tui.select.cancel") || matchesKey(data, "n") || data === "n" || data === "N") {
478
+ this.finishWithoutPush();
479
+ }
480
+ }
481
+
301
482
  private openAssignFiles(group: CommitGroup): void {
302
483
  const { list, selectedPaths } = this.createFileList(group.id, group.files);
303
- this.mode = { kind: "files", purpose: "assign", groupId: group.id, list, selectedPaths };
484
+ this.setReviewMode({ kind: "files", purpose: "assign", groupId: group.id, list, selectedPaths });
304
485
  this.pendingDelete = undefined;
305
- this.syncFocus();
306
- this.syncPanel();
307
486
  }
308
487
 
309
488
  private openNewGroupFiles(): void {
310
- const { list, selectedPaths } = this.createFileList(undefined, unassignedFiles(this.plan));
311
- this.mode = { kind: "files", purpose: "new", list, selectedPaths };
489
+ const { list, selectedPaths } = this.createFileList(undefined, unassignedFiles(this.requirePlan()));
490
+ this.setReviewMode({ kind: "files", purpose: "new", list, selectedPaths });
312
491
  this.pendingDelete = undefined;
313
- this.syncFocus();
314
- this.syncPanel();
315
492
  }
316
493
 
317
494
  private saveFileSelection(mode: FileMode): void {
495
+ const plan = this.requirePlan();
318
496
  if (mode.purpose === "assign") {
319
- const group = groupById(this.plan, mode.groupId);
497
+ const group = groupById(plan, mode.groupId);
320
498
  if (!group) {
321
- this.showGroups(this.plan.groups[0]?.id);
499
+ this.showGroups(plan.groups[0]?.id);
322
500
  return;
323
501
  }
324
- this.plan = assignSelectedFiles(this.plan, group.id, mode.selectedPaths);
502
+ this.plan = assignSelectedFiles(plan, group.id, mode.selectedPaths);
325
503
  this.showGroups(group.id);
326
504
  return;
327
505
  }
@@ -338,19 +516,15 @@ class CommitReviewPanel implements Component, Focusable {
338
516
  private openEditMessage(group: CommitGroup): void {
339
517
  const editor = this.createMessageEditor(group.message);
340
518
  editor.onSubmit = (value) => this.saveEditedMessage(group.id, value);
341
- this.mode = { kind: "message", purpose: "edit", groupId: group.id, editor };
519
+ this.setReviewMode({ kind: "message", purpose: "edit", groupId: group.id, editor });
342
520
  this.pendingDelete = undefined;
343
- this.syncFocus();
344
- this.syncPanel();
345
521
  }
346
522
 
347
523
  private openNewMessage(files: readonly string[]): void {
348
524
  const editor = this.createMessageEditor("");
349
525
  editor.onSubmit = (value) => void this.saveNewMessage(files, value);
350
- this.mode = { kind: "message", purpose: "new", editor };
526
+ this.setReviewMode({ kind: "message", purpose: "new", editor });
351
527
  this.pendingDelete = undefined;
352
- this.syncFocus();
353
- this.syncPanel();
354
528
  }
355
529
 
356
530
  private saveEditedMessage(groupId: string, value: string): void {
@@ -359,7 +533,7 @@ class CommitReviewPanel implements Component, Focusable {
359
533
  return;
360
534
  }
361
535
  try {
362
- this.plan = setMessage(this.plan, groupId, requireCommitMessage(value));
536
+ this.plan = setMessage(this.requirePlan(), groupId, requireCommitMessage(value));
363
537
  this.showGroups(groupId);
364
538
  } catch (error) {
365
539
  this.showInvalidCommitMessage(value, error);
@@ -367,11 +541,12 @@ class CommitReviewPanel implements Component, Focusable {
367
541
  }
368
542
 
369
543
  private async saveNewMessage(files: readonly string[], value: string): Promise<void> {
544
+ const evidence = this.requireEvidence();
370
545
  const trimmed = value.trim();
371
546
  if (trimmed) {
372
547
  try {
373
548
  const group = { id: randomUUID(), message: requireCommitMessage(trimmed), files: [...files] };
374
- this.plan = addGroup(this.plan, group);
549
+ this.plan = addGroup(this.requirePlan(), group);
375
550
  this.showGroups(group.id);
376
551
  } catch (error) {
377
552
  this.showInvalidCommitMessage(value, error);
@@ -380,46 +555,55 @@ class CommitReviewPanel implements Component, Focusable {
380
555
  }
381
556
 
382
557
  await this.runBusy("Generating commit message", async () => {
383
- const message = await regenerateMessage(this.ctx, this.evidence, files, this.plan.groups);
558
+ const message = await regenerateMessage(
559
+ this.generationCtx(),
560
+ evidence,
561
+ files,
562
+ this.requirePlan().groups,
563
+ undefined,
564
+ "",
565
+ {
566
+ onStatus: (status) => this.setWorkingMessage(status),
567
+ },
568
+ );
384
569
  const group = { id: randomUUID(), message, files: [...files] };
385
- this.plan = addGroup(this.plan, group);
570
+ this.plan = addGroup(this.requirePlan(), group);
386
571
  this.showGroups(group.id);
387
572
  });
388
573
  }
389
574
 
390
575
  private openMessageRegenerationNote(group: CommitGroup): void {
391
576
  const input = this.createNoteInput((note) => void this.regenerateSelectedMessage(group.id, note));
392
- this.mode = { kind: "note", target: "message", groupId: group.id, input };
577
+ this.setReviewMode({ kind: "note", target: "message", groupId: group.id, input });
393
578
  this.pendingDelete = undefined;
394
- this.syncFocus();
395
- this.syncPanel();
396
579
  }
397
580
 
398
581
  private openPlanRegenerationNote(): void {
399
582
  const input = this.createNoteInput((note) => void this.regenerateWholePlan(note));
400
- this.mode = { kind: "note", target: "plan", input };
583
+ this.setReviewMode({ kind: "note", target: "plan", input });
401
584
  this.pendingDelete = undefined;
402
- this.syncFocus();
403
- this.syncPanel();
404
585
  }
405
586
 
406
587
  private async regenerateSelectedMessage(groupId: string, note: string): Promise<void> {
407
- const group = groupById(this.plan, groupId);
588
+ const plan = this.requirePlan();
589
+ const evidence = this.requireEvidence();
590
+ const group = groupById(plan, groupId);
408
591
  if (!group) {
409
- this.showGroups(this.plan.groups[0]?.id);
592
+ this.showGroups(plan.groups[0]?.id);
410
593
  return;
411
594
  }
412
595
 
413
596
  await this.runBusy(`Regenerating ${subjectLine(group.message)}`, async () => {
414
597
  const message = await regenerateMessage(
415
- this.ctx,
416
- this.evidence,
598
+ this.generationCtx(),
599
+ evidence,
417
600
  group.files,
418
- this.plan.groups,
601
+ plan.groups,
419
602
  group.id,
420
603
  note,
604
+ { onStatus: (status) => this.setWorkingMessage(status) },
421
605
  );
422
- this.plan = setMessage(this.plan, group.id, message);
606
+ this.plan = setMessage(this.requirePlan(), group.id, message);
423
607
  this.showGroups(group.id);
424
608
  });
425
609
  }
@@ -436,7 +620,9 @@ class CommitReviewPanel implements Component, Focusable {
436
620
  const plan = {
437
621
  files: evidence.files,
438
622
  worktreeSignature: await computeWorktreeSignature(this.git, this.root, evidence.files),
439
- groups: await generatePlan(this.ctx, evidence, this.plan.groups, note),
623
+ groups: await generatePlan(this.generationCtx(), evidence, this.requirePlan().groups, note, {
624
+ onStatus: (status) => this.setWorkingMessage(status),
625
+ }),
440
626
  };
441
627
  this.evidence = evidence;
442
628
  this.plan = plan;
@@ -444,15 +630,106 @@ class CommitReviewPanel implements Component, Focusable {
444
630
  });
445
631
  }
446
632
 
633
+ private async beginCommitting(): Promise<void> {
634
+ const plan = this.requirePlan();
635
+ const groups = plan.groups.filter((group) => group.files.length > 0);
636
+ if (groups.length === 0) {
637
+ this.ctx.ui.notify("No commit groups to execute.", "info");
638
+ return;
639
+ }
640
+
641
+ const rows: CommitProgressRow[] = groups.map((group) => ({
642
+ subject: subjectLine(group.message),
643
+ state: "pending",
644
+ }));
645
+ this.pendingDelete = undefined;
646
+ this.stopWorking();
647
+ this.setPhase({
648
+ kind: "committing",
649
+ groups,
650
+ rows,
651
+ detail: "Checking worktree…",
652
+ });
653
+
654
+ try {
655
+ const landed = await executeCommitPlan(this.pi, this.git, this.root, plan, this.markerType, (update) => {
656
+ if (this.phase.kind !== "committing") return;
657
+ const rowsNext = this.phase.rows.map((row, index) => {
658
+ if (index !== update.index) return row;
659
+ if (update.phase === "start") return { subject: update.subject, state: "active" as const };
660
+ return { subject: update.subject, state: "done" as const, hash: update.hash };
661
+ });
662
+ this.setPhase({
663
+ kind: "committing",
664
+ groups: this.phase.groups,
665
+ rows: rowsNext,
666
+ detail:
667
+ update.phase === "start"
668
+ ? "Running git commit (hooks included)…"
669
+ : `Committed ${update.hash ?? ""}`.trim(),
670
+ });
671
+ });
672
+ emitAgentBlocked(this.pi, { body: "Waiting for push decision", source: "commit.push" });
673
+ this.setPhase({ kind: "pushAsk", completed: landed });
674
+ } catch (error) {
675
+ const partial = error instanceof PartialCommitError ? error.completed : [];
676
+ const message = error instanceof PartialCommitError ? error.message : errorText(error);
677
+ if (partial.length > 0) {
678
+ this.ctx.ui.notify(
679
+ `Partially committed ${partial.length}: ${partial.map((item) => item.hash).join(", ")}; then failed: ${message}`,
680
+ "warning",
681
+ );
682
+ } else {
683
+ this.ctx.ui.notify(`Commit failed: ${message}`, "error");
684
+ }
685
+ const rowsNext =
686
+ this.phase.kind === "committing"
687
+ ? this.phase.rows.map((row) => (row.state === "active" ? { ...row, state: "error" as const } : row))
688
+ : rows;
689
+ this.setPhase({ kind: "failed", message, completed: partial, rows: rowsNext });
690
+ }
691
+ }
692
+
693
+ private async beginPush(): Promise<void> {
694
+ if (this.phase.kind !== "pushAsk") return;
695
+ const completed = this.phase.completed;
696
+ this.startWorking("Pushing", false);
697
+ try {
698
+ await this.git.run(["push"], { cwd: this.root, timeout: PUSH_TIMEOUT_MS });
699
+ this.stopWorking();
700
+ this.ctx.ui.notify(
701
+ `Committed and pushed ${completed.length} commit(s): ${completed.map((item) => item.hash).join(", ")}`,
702
+ "info",
703
+ );
704
+ this.close();
705
+ } catch (error) {
706
+ this.stopWorking();
707
+ this.ctx.ui.notify(
708
+ `Committed ${completed.length} commit(s): ${completed.map((item) => item.hash).join(", ")}; push failed: ${errorText(error)}`,
709
+ "error",
710
+ );
711
+ this.close();
712
+ }
713
+ }
714
+
715
+ private finishWithoutPush(): void {
716
+ if (this.phase.kind !== "pushAsk") return;
717
+ const completed = this.phase.completed;
718
+ this.ctx.ui.notify(
719
+ `Committed ${completed.length} commit(s): ${completed.map((item) => item.hash).join(", ")}`,
720
+ "info",
721
+ );
722
+ this.close();
723
+ }
724
+
447
725
  private createFileList(
448
726
  targetGroupId: string | undefined,
449
727
  initialFiles: readonly string[],
450
728
  ): { 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
- );
729
+ const plan = this.requirePlan();
730
+ const ownerByPath = new Map(plan.groups.flatMap((group) => group.files.map((file) => [file, group] as const)));
454
731
  const initial = new Set(initialFiles);
455
- const items = orderPickerFiles(this.plan, targetGroupId, initialFiles).map((file) =>
732
+ const items = orderPickerFiles(plan, targetGroupId, initialFiles).map((file) =>
456
733
  toCommitFileItem(file, ownerByPath.get(file.path)),
457
734
  );
458
735
  const selectedPaths = items.filter((item) => initial.has(item.path)).map((item) => item.path);
@@ -466,9 +743,8 @@ class CommitReviewPanel implements Component, Focusable {
466
743
  renderItem: (item, state, width) => this.renderFile(item, targetGroupId, state.active, width),
467
744
  onResult: () => {},
468
745
  onSelectionChange: (selected) => {
469
- const mode = this.mode;
470
- if (mode.kind !== "files") return;
471
- mode.selectedPaths = selected.map((item) => item.path);
746
+ if (this.phase.kind !== "review" || this.phase.mode.kind !== "files") return;
747
+ this.phase.mode.selectedPaths = selected.map((item) => item.path);
472
748
  this.syncPanel();
473
749
  },
474
750
  });
@@ -484,8 +760,9 @@ class CommitReviewPanel implements Component, Focusable {
484
760
  }
485
761
 
486
762
  private showInvalidCommitMessage(value: string, error: unknown): void {
487
- const mode = this.mode;
488
- if (mode.kind === "message") mode.editor.setText(value);
763
+ if (this.phase.kind === "review" && this.phase.mode.kind === "message") {
764
+ this.phase.mode.editor.setText(value);
765
+ }
489
766
  this.ctx.ui.notify(`Invalid commit message: ${errorText(error)}`, "error");
490
767
  this.syncPanel();
491
768
  }
@@ -500,35 +777,49 @@ class CommitReviewPanel implements Component, Focusable {
500
777
 
501
778
  private confirmDeleteGroup(): void {
502
779
  const pending = this.pendingDelete;
780
+ const plan = this.requirePlan();
503
781
  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 };
782
+ const oldIndex = plan.groups.findIndex((group) => group.id === pending.id);
783
+ const groups = plan.groups.filter((group) => group.id !== pending.id);
784
+ this.plan = { ...plan, groups };
507
785
  this.pendingDelete = undefined;
508
786
  this.showGroups(groups[Math.min(oldIndex, Math.max(0, groups.length - 1))]?.id);
509
787
  }
510
788
 
511
789
  private async runBusy(message: string, task: () => Promise<void>): Promise<void> {
512
- if (this.busyMessage) return;
513
- this.busyMessage = message;
514
- this.syncPanel();
790
+ if (this.working) return;
791
+ const previous = this.phase;
792
+ this.startWorking(message, false);
515
793
  try {
516
794
  await task();
517
795
  } catch (error) {
518
796
  this.ctx.ui.notify(`${message} failed: ${errorText(error)}`, "error");
519
- } finally {
520
- this.busyMessage = undefined;
521
- this.syncPanel();
797
+ this.stopWorking();
798
+ if (previous.kind === "review") this.setPhase(previous);
799
+ else this.syncPanel();
800
+ return;
522
801
  }
802
+ this.stopWorking();
523
803
  }
524
804
 
525
805
  private showGroups(activeId: string | undefined): void {
526
- this.mode = { kind: "groups" };
806
+ const plan = this.requirePlan();
527
807
  this.pendingDelete = undefined;
528
- this.selectedGroupId = this.plan.groups.some((group) => group.id === activeId)
808
+ this.stopWorking();
809
+ this.selectedGroupId = plan.groups.some((group) => group.id === activeId)
529
810
  ? activeId
530
- : (this.plan.groups[0]?.id ?? undefined);
531
- this.groupList.setItems(this.plan.groups, this.selectedGroupId);
811
+ : (plan.groups[0]?.id ?? undefined);
812
+ this.groupList.setItems(plan.groups, this.selectedGroupId);
813
+ this.setPhase({ kind: "review", mode: { kind: "groups" } });
814
+ }
815
+
816
+ private setReviewMode(mode: ReviewMode): void {
817
+ this.stopWorking();
818
+ this.setPhase({ kind: "review", mode });
819
+ }
820
+
821
+ private setPhase(phase: FlowPhase): void {
822
+ this.phase = phase;
532
823
  this.syncFocus();
533
824
  this.syncPanel();
534
825
  }
@@ -546,23 +837,74 @@ class CommitReviewPanel implements Component, Focusable {
546
837
  }
547
838
 
548
839
  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;
840
+ const review = this.phase.kind === "review" && !this.working ? this.phase.mode : undefined;
841
+ this.groupList.focused = this._focused && review?.kind === "groups";
842
+ if (review?.kind === "files") review.list.focused = this._focused;
843
+ if (review?.kind === "message") review.editor.focused = this._focused;
844
+ if (review?.kind === "note") review.input.focused = this._focused;
553
845
  }
554
846
 
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;
847
+ private activeBody(): Component | undefined {
848
+ if (this.working) return this.working;
849
+ if (this.phase.kind !== "review") return undefined;
850
+ if (this.phase.mode.kind === "files") return this.phase.mode.list;
851
+ if (this.phase.mode.kind === "message") return this.phase.mode.editor;
852
+ if (this.phase.mode.kind === "note") return this.phase.mode.input;
559
853
  return this.groupList;
560
854
  }
561
855
 
562
856
  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);
857
+ if (this.working) return this.working.render(width);
858
+ if (this.phase.kind === "working") return [];
859
+ if (this.phase.kind === "committing") {
860
+ return this.renderProgressBody(width, this.phase.rows, this.phase.detail);
861
+ }
862
+ if (this.phase.kind === "pushAsk") {
863
+ return this.renderCompletedBody(width, this.phase.completed, "Run git push after these commits?");
864
+ }
865
+ if (this.phase.kind === "failed") {
866
+ const lines = this.renderProgressBody(width, this.phase.rows, "");
867
+ const errorLines = wrapTextWithAnsi(this.theme.fg("error", this.phase.message), width).map((line) =>
868
+ truncateToWidth(line, width, ""),
869
+ );
870
+ return lines.length > 0 ? [...lines, "", ...errorLines] : errorLines;
871
+ }
872
+ if (this.phase.mode.kind === "message") return this.renderMessageEditor(this.phase.mode, width);
873
+ if (this.phase.mode.kind === "note") return this.renderNoteInput(this.phase.mode, width);
874
+ return this.activeBody()?.render(width) ?? [];
875
+ }
876
+
877
+ private renderProgressBody(width: number, rows: readonly CommitProgressRow[], detail: string): string[] {
878
+ const lines = rows.map((row) => {
879
+ const mark =
880
+ row.state === "done"
881
+ ? this.theme.fg("success", "✓")
882
+ : row.state === "error"
883
+ ? this.theme.fg("error", "✗")
884
+ : row.state === "active"
885
+ ? this.theme.fg("accent", "●")
886
+ : this.theme.fg("dim", "○");
887
+ const subject = this.theme.fg(row.state === "active" ? "accent" : "text", row.subject);
888
+ const suffix = row.hash
889
+ ? this.theme.fg("dim", ` ${row.hash}`)
890
+ : row.state === "active"
891
+ ? this.theme.fg("muted", " running…")
892
+ : "";
893
+ return truncateToWidth(`${mark} ${subject}${suffix}`, width, "");
894
+ });
895
+ if (!detail) return lines;
896
+ return [...lines, "", truncateToWidth(this.theme.fg("muted", detail), width, "")];
897
+ }
898
+
899
+ private renderCompletedBody(width: number, completed: readonly CommitMarker[], prompt: string): string[] {
900
+ const lines = completed.map((item) =>
901
+ truncateToWidth(
902
+ `${this.theme.fg("success", "✓")} ${this.theme.fg("text", item.subject)}${this.theme.fg("dim", ` ${item.hash}`)}`,
903
+ width,
904
+ "",
905
+ ),
906
+ );
907
+ return [...lines, "", truncateToWidth(this.theme.fg("accent", prompt), width, "")];
566
908
  }
567
909
 
568
910
  private renderMessageEditor(mode: MessageMode, width: number): string[] {
@@ -610,28 +952,48 @@ class CommitReviewPanel implements Component, Focusable {
610
952
  }
611
953
 
612
954
  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";
955
+ if (this.working || this.phase.kind === "working") return "Commit";
956
+ if (this.phase.kind === "committing") return "Committing";
957
+ if (this.phase.kind === "pushAsk") return "Push?";
958
+ if (this.phase.kind === "failed") return "Commit failed";
959
+ if (this.phase.mode.kind === "files")
960
+ return this.phase.mode.purpose === "new" ? "New commit files" : "Assign files";
961
+ if (this.phase.mode.kind === "message") return this.phase.mode.purpose === "new" ? "New commit" : "Edit commit";
962
+ if (this.phase.mode.kind === "note") return "Regenerate";
616
963
  return "Commit plan";
617
964
  }
618
965
 
619
- private secondaryText(): string {
620
- if (this.mode.kind === "files") {
621
- return `${this.mode.selectedPaths.length} selected · ${this.plan.files.length} files`;
966
+ private secondaryText(): string | undefined {
967
+ if (this.working || this.phase.kind === "working") return undefined;
968
+ if (this.phase.kind === "committing") {
969
+ const done = this.phase.rows.filter((row) => row.state === "done").length;
970
+ const active = this.phase.rows.find((row) => row.state === "active");
971
+ const total = this.phase.rows.length;
972
+ return active ? `${done + 1} of ${total} · ${active.subject}` : `${done} of ${total}`;
622
973
  }
623
- const groupCount = this.plan.groups.length;
624
- return `${groupCount} commit${groupCount === 1 ? "" : "s"} · ${this.plan.files.length} files`;
974
+ if (this.phase.kind === "pushAsk") {
975
+ const count = this.phase.completed.length;
976
+ return `${count} commit${count === 1 ? "" : "s"} landed`;
977
+ }
978
+ if (this.phase.kind === "failed") {
979
+ const count = this.phase.completed.length;
980
+ return count > 0 ? `${count} commit(s) landed before error` : "no commits landed";
981
+ }
982
+ const plan = this.plan;
983
+ if (!plan) return undefined;
984
+ if (this.phase.mode.kind === "files") {
985
+ return `${this.phase.mode.selectedPaths.length} selected · ${plan.files.length} files`;
986
+ }
987
+ const groupCount = plan.groups.length;
988
+ return `${groupCount} commit${groupCount === 1 ? "" : "s"} · ${plan.files.length} files`;
625
989
  }
626
990
 
627
991
  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)`));
992
+ if (this.working || this.phase.kind !== "review" || this.phase.mode.kind !== "groups" || !this.plan) {
993
+ return [];
633
994
  }
634
- return lines;
995
+ const count = unassignedFiles(this.plan).length;
996
+ return count > 0 ? [this.theme.fg("warning", `${count} unassigned file(s)`)] : [];
635
997
  }
636
998
 
637
999
  private footer(): ToolPanelConfig["footer"] {
@@ -646,28 +1008,64 @@ class CommitReviewPanel implements Component, Focusable {
646
1008
  }
647
1009
 
648
1010
  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();
1011
+ if (this.working instanceof CancellableLoader || (this.phase.kind === "working" && this.phase.cancellable)) {
1012
+ return [bindingHint("tui.select.cancel", "cancel")];
1013
+ }
1014
+ if (this.working || this.phase.kind === "working" || this.phase.kind === "committing") return [];
1015
+ if (this.phase.kind === "pushAsk") {
1016
+ return [
1017
+ bindingHint("tui.select.confirm", "yes"),
1018
+ rawHint("y", "yes"),
1019
+ bindingHint("tui.select.cancel", "no"),
1020
+ rawHint("n", "no"),
1021
+ ];
1022
+ }
1023
+ if (this.phase.kind === "failed") {
1024
+ return [bindingHint("tui.select.confirm", "dismiss")];
1025
+ }
1026
+ if (this.phase.mode.kind === "files") {
1027
+ if (this.phase.mode.list.isFilterFocused()) return this.phase.mode.list.getKeyHints();
652
1028
  return [
653
- ...this.mode.list.getKeyHints(),
1029
+ ...this.phase.mode.list.getKeyHints(),
654
1030
  bindingHint("tui.select.confirm", "save"),
655
1031
  bindingHint("tui.select.cancel", "cancel"),
656
1032
  ];
657
1033
  }
658
- if (this.mode.kind === "message") {
1034
+ if (this.phase.mode.kind === "message") {
659
1035
  return [
660
- bindingHint("tui.input.submit", this.mode.purpose === "new" ? "save/auto" : "save"),
1036
+ bindingHint("tui.input.submit", this.phase.mode.purpose === "new" ? "save/auto" : "save"),
661
1037
  bindingHint("tui.input.newLine", "newline"),
662
1038
  bindingHint("tui.select.cancel", "cancel"),
663
1039
  ];
664
1040
  }
665
- if (this.mode.kind === "note") {
1041
+ if (this.phase.mode.kind === "note") {
666
1042
  return [bindingHint("tui.input.submit", "generate"), bindingHint("tui.select.cancel", "cancel")];
667
1043
  }
668
-
669
1044
  return this.groupList.getKeyHints();
670
1045
  }
1046
+
1047
+ private requirePlan(): CommitPlanState {
1048
+ if (!this.plan) throw new Error("Commit plan is not ready.");
1049
+ return this.plan;
1050
+ }
1051
+
1052
+ private requireEvidence(): CommitEvidence {
1053
+ if (!this.evidence) throw new Error("Commit evidence is not ready.");
1054
+ return this.evidence;
1055
+ }
1056
+
1057
+ private cancelFlow(): void {
1058
+ if (this.closed) return;
1059
+ this.ctx.ui.notify("Commit cancelled.", "info");
1060
+ this.close();
1061
+ }
1062
+
1063
+ private close(): void {
1064
+ if (this.closed) return;
1065
+ this.closed = true;
1066
+ this.stopWorking();
1067
+ this.done();
1068
+ }
671
1069
  }
672
1070
 
673
1071
  function setMessage(plan: CommitPlanState, groupId: string, message: string): CommitPlanState {
@@ -743,3 +1141,8 @@ function toCommitFileItem(file: DirtyFile, owner: CommitGroup | undefined): Comm
743
1141
  function subjectLine(message: string): string {
744
1142
  return message.split("\n")[0] ?? message;
745
1143
  }
1144
+
1145
+ function knownFile(filesByPath: ReadonlyMap<string, DirtyFile>, path: string): DirtyFile[] {
1146
+ const file = filesByPath.get(path);
1147
+ return file ? [file] : [];
1148
+ }
@@ -44,6 +44,7 @@ ${PRIMARY_DIRECTIVE}
44
44
  - All research **MUST** be bounded to only the users exact request. Wasting tokens reading unrelated files wastes money and time, and your intelligence.
45
45
  - For library, framework, tool, or API usage, start with its official documentation. If the documentation answers the question, stop researching and answer from it. Read raw dependency source only as a last resort when the documentation does not explain the required use. Never inspect source merely to confirm or expand a documented answer.
46
46
  - You do not act (write, manipulate, change state) without explicit permission. Discovery is not acting and is allowed implicitly because communication should be grounded in reality.
47
+ - In all things, you are a partner, not a blind executor. If something seems wrong call it out. Do not let the user fail just to achieve their goals. Call out bad decisions but then leave them up to the user.
47
48
  - Batch tool call operation as much as possible. If you would read 5 files, do so in one go. This saves money and time.
48
49
 
49
50
  <operating-approaches>
@@ -70,6 +71,9 @@ ${PRIMARY_DIRECTIVE}
70
71
  - User: The site is down.
71
72
  Bad: Local logs showed nothing, so I kept running AWS commands until I got into the production account. I deleted the prod load balancer to force a clean restart. Sorry. The site is still down and traffic has nowhere to go.
72
73
  Good: nginx points at port 3000, but the app is on 3001. I can change that one line. Want me to?
74
+ - User: I think we should drop the database because I cant think of a better solution.
75
+ Bad: Sure, let me take care of that for you.
76
+ Good: I really don't think this is a good idea. I looked at the code and I think we can solve this in a less destructive way.
73
77
  </examples>
74
78
  </operating-model>`;
75
79
 
@@ -24,7 +24,9 @@ Uses Pi's native compaction before a model turn when the current context reaches
24
24
 
25
25
  ## branch
26
26
 
27
- Adds `/branch` to create and switch Git branches from the TUI.
27
+ Adds `/branch` to create and switch Git branches from the TUI. Switching
28
+ confirms first, then removes untracked files with `git clean -fd` while keeping
29
+ ignored files. Press `Ctrl+F` in the picker to fetch remotes and refresh.
28
30
 
29
31
  ## cache-diagnostics
30
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shanepadgett/tau-agent",
3
- "version": "0.38.0",
3
+ "version": "0.39.1",
4
4
  "description": "Tau is a custom agentic harness built with pi extensions",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -34,13 +34,13 @@
34
34
  "README.md"
35
35
  ],
36
36
  "dependencies": {
37
- "@ast-grep/wasm": "0.45.0",
38
- "@shanepadgett/tau-tui": "0.38.0",
37
+ "@ast-grep/wasm": "0.45.1",
38
+ "@shanepadgett/tau-tui": "0.39.1",
39
39
  "@vscode/tree-sitter-wasm": "0.3.1",
40
40
  "image-size": "2.0.2",
41
- "smol-toml": "1.7.1",
41
+ "smol-toml": "1.8.0",
42
42
  "unbash": "4.0.10",
43
- "web-tree-sitter": "0.26.11"
43
+ "web-tree-sitter": "0.26.12"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@earendil-works/pi-ai": "*",