@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.
- package/extensions/branch/README.md +6 -2
- package/extensions/branch/index.ts +7 -0
- package/extensions/commit/README.md +2 -2
- package/extensions/commit/commit-plan.ts +13 -2
- package/extensions/commit/index.ts +18 -85
- package/extensions/commit/review-ui.ts +535 -132
- package/extensions/soul/prompt.ts +4 -0
- package/extensions/tau-help/help.md +3 -1
- package/package.json +5 -5
|
@@ -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
|
|
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`
|
|
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.
|
|
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
|
-
{
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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 (
|
|
63
|
+
if (error instanceof PartialCommitError) {
|
|
125
64
|
ctx.ui.notify(
|
|
126
|
-
`Partially committed ${completed.length}: ${completed.map((item) => item.hash).join(", ")}; then failed: ${
|
|
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
|
|
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<
|
|
79
|
-
|
|
80
|
-
(tui, theme, _keybindings, done) =>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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: (
|
|
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
|
|
188
|
+
private phase: FlowPhase = { kind: "working", cancellable: true };
|
|
111
189
|
private groupList: SelectableList<CommitGroup>;
|
|
112
190
|
private pendingDelete: CommitGroup | undefined;
|
|
113
|
-
private
|
|
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: (
|
|
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(
|
|
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()
|
|
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.
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
else if (
|
|
181
|
-
else this.
|
|
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.
|
|
383
|
+
this.close();
|
|
214
384
|
return;
|
|
215
385
|
}
|
|
216
386
|
if (result.kind === "primary") {
|
|
217
|
-
this.
|
|
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.
|
|
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.
|
|
311
|
-
this.
|
|
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(
|
|
497
|
+
const group = groupById(plan, mode.groupId);
|
|
320
498
|
if (!group) {
|
|
321
|
-
this.showGroups(
|
|
499
|
+
this.showGroups(plan.groups[0]?.id);
|
|
322
500
|
return;
|
|
323
501
|
}
|
|
324
|
-
this.plan = assignSelectedFiles(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
588
|
+
const plan = this.requirePlan();
|
|
589
|
+
const evidence = this.requireEvidence();
|
|
590
|
+
const group = groupById(plan, groupId);
|
|
408
591
|
if (!group) {
|
|
409
|
-
this.showGroups(
|
|
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.
|
|
416
|
-
|
|
598
|
+
this.generationCtx(),
|
|
599
|
+
evidence,
|
|
417
600
|
group.files,
|
|
418
|
-
|
|
601
|
+
plan.groups,
|
|
419
602
|
group.id,
|
|
420
603
|
note,
|
|
604
|
+
{ onStatus: (status) => this.setWorkingMessage(status) },
|
|
421
605
|
);
|
|
422
|
-
this.plan = setMessage(this.
|
|
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.
|
|
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
|
|
452
|
-
|
|
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(
|
|
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
|
-
|
|
470
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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 =
|
|
505
|
-
const groups =
|
|
506
|
-
this.plan = { ...
|
|
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.
|
|
513
|
-
|
|
514
|
-
this.
|
|
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
|
-
|
|
520
|
-
|
|
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
|
-
|
|
806
|
+
const plan = this.requirePlan();
|
|
527
807
|
this.pendingDelete = undefined;
|
|
528
|
-
this.
|
|
808
|
+
this.stopWorking();
|
|
809
|
+
this.selectedGroupId = plan.groups.some((group) => group.id === activeId)
|
|
529
810
|
? activeId
|
|
530
|
-
: (
|
|
531
|
-
this.groupList.setItems(
|
|
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.
|
|
550
|
-
|
|
551
|
-
if (
|
|
552
|
-
if (
|
|
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.
|
|
557
|
-
if (this.
|
|
558
|
-
if (this.mode.kind === "
|
|
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.
|
|
564
|
-
if (this.
|
|
565
|
-
|
|
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.
|
|
614
|
-
if (this.
|
|
615
|
-
if (this.
|
|
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.
|
|
621
|
-
|
|
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
|
-
|
|
624
|
-
|
|
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
|
-
|
|
629
|
-
|
|
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
|
-
|
|
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.
|
|
650
|
-
|
|
651
|
-
|
|
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.
|
|
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.
|
|
38
|
-
"@shanepadgett/tau-tui": "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.
|
|
41
|
+
"smol-toml": "1.8.0",
|
|
42
42
|
"unbash": "4.0.10",
|
|
43
|
-
"web-tree-sitter": "0.26.
|
|
43
|
+
"web-tree-sitter": "0.26.12"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"@earendil-works/pi-ai": "*",
|