astrabot 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +411 -0
  2. package/ai/ai.config.ts +27 -0
  3. package/ai/auto-retry.ts +117 -0
  4. package/ai/config-loader.ts +132 -0
  5. package/ai/index.ts +4 -0
  6. package/ai/retry-prompt.ts +30 -0
  7. package/bin/astra +2 -0
  8. package/core/retry/error-classifier.ts +208 -0
  9. package/core/retry/index.ts +29 -0
  10. package/core/retry/retry-config.ts +142 -0
  11. package/core/retry/retry-engine.ts +215 -0
  12. package/game/index.html +573 -0
  13. package/game/neon-breaker.html +1037 -0
  14. package/index.ts +140 -0
  15. package/modes/agent/action-tracker.ts +47 -0
  16. package/modes/agent/agent-tools.ts +338 -0
  17. package/modes/agent/approval.ts +184 -0
  18. package/modes/agent/diff-view.ts +34 -0
  19. package/modes/agent/orchestrator.ts +234 -0
  20. package/modes/agent/tool-executor.ts +993 -0
  21. package/modes/agent/types.ts +68 -0
  22. package/modes/ask/orchestrator.ts +230 -0
  23. package/modes/auto.ts +88 -0
  24. package/modes/cli.ts +43 -0
  25. package/modes/multi/agent-pool-manager.ts +337 -0
  26. package/modes/multi/examples.ts +441 -0
  27. package/modes/multi/message-broker.ts +179 -0
  28. package/modes/multi/multi-agent-orchestrator.ts +891 -0
  29. package/modes/multi/orchestrator.ts +414 -0
  30. package/modes/multi/types.ts +245 -0
  31. package/modes/multi/workflow-builder.ts +569 -0
  32. package/modes/plan/orchestrator.ts +198 -0
  33. package/modes/plan/planner.ts +121 -0
  34. package/modes/plan/selection.ts +43 -0
  35. package/modes/plan/types.ts +13 -0
  36. package/modes/plan/web-tools.ts +132 -0
  37. package/modes/setup.ts +210 -0
  38. package/package.json +62 -0
  39. package/session/index.ts +45 -0
  40. package/session/session-context.ts +188 -0
  41. package/session/session-manager.ts +374 -0
  42. package/session/session-tools.ts +109 -0
  43. package/session/store.ts +278 -0
  44. package/tsconfig.json +30 -0
  45. package/tui/spinner.ts +182 -0
  46. package/tui/terminal-md.ts +17 -0
  47. package/tui/wakeup.ts +231 -0
package/index.ts ADDED
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Command } from "commander";
4
+ import { runWakeup, printBanner } from "./tui/wakeup"; // Imported printBanner for the breathing effect
5
+ import { runSetup } from "./modes/setup";
6
+ import pkg from "./package.json" with { type: "json" };
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import os from "os";
10
+ import chalk from "chalk";
11
+ import figlet from "figlet"; // Imported figlet for the arcade banner generation
12
+ import { confirm, isCancel, select } from "@clack/prompts";
13
+ import { exec } from "child_process";
14
+
15
+ const program = new Command();
16
+
17
+ program
18
+ .name("astra")
19
+ .description("Astra CLI — AI-native development companion")
20
+ .version(pkg.version, "-v, --version", "Output the current version");
21
+
22
+ program
23
+ .command("wakeup")
24
+ .description("Show the banner and pick interaction mode")
25
+ .action(async () => {
26
+ await runWakeup();
27
+ });
28
+
29
+ program
30
+ .command("setup")
31
+ .description("Configure API keys and settings (~/.astra/.env)")
32
+ .action(async () => {
33
+ await runSetup();
34
+ });
35
+
36
+ program
37
+ .command("play")
38
+ .description("Launch an undocumented workspace arcade easter egg mini-game")
39
+ .action(async () => {
40
+ // Generate the baseline ASCII asset for the arcade room
41
+ let arcadeAscii = "";
42
+ try {
43
+ arcadeAscii = figlet.textSync("ARCADE", {
44
+ font: "ANSI Shadow",
45
+ horizontalLayout: "fitted",
46
+ });
47
+ } catch {
48
+ arcadeAscii = figlet.textSync("ARCADE", { font: "Standard" });
49
+ }
50
+
51
+ // Play the full breathing banner animation with the twinkling stars
52
+ await printBanner(arcadeAscii);
53
+
54
+ console.log(chalk.bold.magenta(" 🎮 Astra Arcade Workspace Matrix\n"));
55
+
56
+ // 1. Interactive Game Selector Prompt
57
+ const gameChoice = await select({
58
+ message: "Choose an arcade game to launch:",
59
+ options: [
60
+ { value: "index.html", label: "Retro Snake Classic" },
61
+ { value: "neon-breaker.html", label: "Neon Brick Breaker" },
62
+ { value: "exit", label: "Exit"}
63
+ ],
64
+ });
65
+
66
+ if (isCancel(gameChoice) || gameChoice==="exit") {
67
+ console.log(chalk.dim(" Arcade closed.\n"));
68
+ return;
69
+ }
70
+
71
+ // Resolve the internal path safely relative to the executing workspace binary bundle
72
+ const gameFilePath = path.join(import.meta.dir, "game", gameChoice);
73
+
74
+ if (!fs.existsSync(gameFilePath)) {
75
+ console.log(chalk.red(`\n ✗ Asset mismatch: Game asset not found at ${gameFilePath}\n`));
76
+ return;
77
+ }
78
+
79
+ const PORT = 4321;
80
+ const localUrl = `http://localhost:${PORT}`;
81
+
82
+ // 2. Spawn a background static asset file server using Bun's fast native engine
83
+ try {
84
+ Bun.serve({
85
+ port: PORT,
86
+ fetch(req) {
87
+ return new Response(Bun.file(gameFilePath));
88
+ },
89
+ });
90
+
91
+ console.log(chalk.green(`\n ✓ Local arcade matrix listening live at ${localUrl}`));
92
+ console.log(chalk.dim(" Press [Ctrl + C] in this terminal session to close down server logs.\n"));
93
+
94
+ // 3. Automatically spawn their default system web browser target
95
+ const startCmd =
96
+ process.platform === "win32" ? "start" :
97
+ process.platform === "darwin" ? "open" : "xdg-open";
98
+
99
+ exec(`${startCmd} ${localUrl}`);
100
+ } catch (err) {
101
+ console.error(chalk.red(`\n ✗ Port initialization blocked: ${(err as Error).message}\n`));
102
+ }
103
+ });
104
+
105
+ program
106
+ .command("reset")
107
+ .description("Completely remove all localized configurations, sessions, and credentials cached by Astra")
108
+ .action(async () => {
109
+ console.log(chalk.bold.yellow("\n ⚠ Danger Zone"));
110
+
111
+ const targetDir = path.join(os.homedir(), ".astra");
112
+
113
+ if (!fs.existsSync(targetDir)) {
114
+ console.log(chalk.dim(" No active data store or environment parameters discovered at ~/.astra.\n"));
115
+ return;
116
+ }
117
+
118
+ const authorized = await confirm({
119
+ message: "Are you absolutely sure you want to purge all stored configurations, environments, and historical run data?",
120
+ initialValue: false,
121
+ });
122
+
123
+ if (isCancel(authorized) || !authorized) {
124
+ console.log(chalk.dim(" Reset aborted.\n"));
125
+ return;
126
+ }
127
+
128
+ try {
129
+ fs.rmSync(targetDir, { recursive: true, force: true });
130
+ console.log(chalk.green(`\n ✓ Local cache wiped successfully from ${targetDir}`));
131
+ console.log(chalk.dim(" To completely remove the companion binary, run: ") + chalk.cyan("npm uninstall -g astra-dev-cli\n"));
132
+ } catch (error) {
133
+ console.error(chalk.red(`\n ✗ Failed to clear cache directory: ${(error as Error).message}\n`));
134
+ }
135
+ });
136
+
137
+ await program.parseAsync(process.argv);
138
+
139
+ // Export programmatic version for custom tool diagnostics or bug report attachments
140
+ export const ASTRA_VERSION = pkg.version;
@@ -0,0 +1,47 @@
1
+ import type { ActionLog, ActionStatus } from "./types";
2
+ import { isMutationType } from "./types";
3
+
4
+ export class ActionTracker{
5
+ private actions:ActionLog[] = []
6
+ private counter = 0
7
+
8
+ log(
9
+ entry: Omit<ActionLog, 'id' | 'timestamp'> & {
10
+ id?: string;
11
+ timestamp?: Date;
12
+ },
13
+ ):ActionLog {
14
+ const action:ActionLog = {
15
+ id: entry.id ?? `action_${this.counter++}`,
16
+ timestamp: entry.timestamp ?? new Date(),
17
+ type: entry.type,
18
+ path: entry.path,
19
+ details: {...entry.details},
20
+ status: entry.status,
21
+ userApproved: entry.userApproved
22
+ }
23
+ this.actions.push(action)
24
+ return action
25
+ }
26
+
27
+ getActions():readonly ActionLog[] {
28
+ return this.actions
29
+ }
30
+
31
+ getPendingMutations():ActionLog[] {
32
+ return this.actions.filter(
33
+ (a)=>isMutationType(a.type) && a.status==="pending"
34
+ )
35
+ }
36
+
37
+ getPendingMutationsForPath(path: string): ActionLog[] {
38
+ return this.getPendingMutations().filter((a) => a.path === path)
39
+ }
40
+
41
+ updateStatus(id: string, status: ActionStatus, userApproved?: boolean): void {
42
+ const a = this.actions.find((x)=>x.id===id)
43
+ if(!a) return
44
+ a.status = status
45
+ if(userApproved!==undefined) a.userApproved = userApproved
46
+ }
47
+ }
@@ -0,0 +1,338 @@
1
+ import {tool} from 'ai'
2
+ import {z} from 'zod'
3
+ import type { ToolExecutor } from './tool-executor'
4
+
5
+ interface AgentToolHooks {
6
+ afterCreateFile?: (path: string) => Promise<string | void>;
7
+ }
8
+
9
+ export function createAgentTools(executor: ToolExecutor, hooks: AgentToolHooks = {}){
10
+ return {
11
+
12
+ read_file: tool({
13
+ description: "Read a text file from the workspace. Use a path relative to the project root.",
14
+ inputSchema: z.object({
15
+ path: z.string().describe("Relative file path")
16
+ }),
17
+ execute: async({path:p}) => executor.readFile(p)
18
+ }),
19
+
20
+ create_file: tool({
21
+ description:
22
+ "Stage creation of a new file (not written until the user approves).",
23
+ inputSchema: z.object({
24
+ path: z.string(),
25
+ content: z.string(),
26
+ }),
27
+ execute: async ({ path: p, content }) => {
28
+ const staged = executor.createFile(p, content)
29
+ const followUp = await hooks.afterCreateFile?.(executor.normalizePath(p))
30
+ return followUp ?? staged
31
+ },
32
+ }),
33
+
34
+ modify_file: tool({
35
+ description:
36
+ "Stage a full-file replacement for an existing file (pending approval).",
37
+ inputSchema: z.object({
38
+ path: z.string(),
39
+ content: z.string().describe("Complete new file contents"),
40
+ }),
41
+ execute: async ({ path: p, content }) => executor.modifyFile(p, content),
42
+ }),
43
+
44
+ delete_file: tool({
45
+ description: "Stage deletion of a file (pending approval).",
46
+ inputSchema: z.object({
47
+ path: z.string(),
48
+ }),
49
+ execute: async ({ path: p }) => executor.deleteFile(p),
50
+ }),
51
+
52
+ create_folder: tool({
53
+ description:
54
+ "Stage creation of a directory tree (pending approval). Uses mkdir -p on apply.",
55
+ inputSchema: z.object({
56
+ path: z.string().describe("Relative directory path"),
57
+ }),
58
+ execute: async ({ path: p }) => executor.createFolder(p),
59
+ }),
60
+
61
+ list_files: tool({
62
+ description: "List files and directories under a path.",
63
+ inputSchema: z.object({
64
+ path: z.string(),
65
+ recursive: z.boolean().optional().default(false),
66
+ }),
67
+ execute: async ({ path: p, recursive }) =>
68
+ executor.listFiles(p, recursive),
69
+ }),
70
+
71
+ search_files: tool({
72
+ description:
73
+ 'Find files matching a glob pattern (e.g. "*.ts", "**/*.md"). Optional content substring filter.',
74
+ inputSchema: z.object({
75
+ root: z.string().describe("Directory to search, relative to root"),
76
+ pattern: z
77
+ .string()
78
+ .describe("Glob-like pattern using * and ** (forward slashes)"),
79
+ content_contains: z.string().optional(),
80
+ }),
81
+ execute: async ({ root, pattern, content_contains }) =>
82
+ executor.searchFiles(root, pattern, content_contains),
83
+ }),
84
+
85
+ analyze_codebase: tool({
86
+ description:
87
+ "Summarize structure: file counts, size, extensions. Read-only.",
88
+ inputSchema: z.object({
89
+ path: z.string().default("."),
90
+ }),
91
+ execute: async ({ path: p }) => executor.analyzeCodebase(p),
92
+ }),
93
+
94
+ read_multiple_files: tool({
95
+ description:
96
+ "Read multiple files in a single tool call. Each file is individually logged to the action trail.",
97
+ inputSchema: z.object({
98
+ paths: z.array(z.string())
99
+ }),
100
+ execute: async ({ paths }) =>
101
+ executor.readMultipleFiles(paths)
102
+ }),
103
+
104
+ grep: tool({
105
+ description:
106
+ "Search file contents using a text query.",
107
+ inputSchema: z.object({
108
+ root: z.string().default("."),
109
+ query: z.string(),
110
+ caseSensitive: z.boolean().default(false)
111
+ }),
112
+ execute: async (args) =>
113
+ executor.grep(args)
114
+ }),
115
+
116
+ replace_in_file: tool({
117
+ description:
118
+ "Replace text inside a file while preserving the rest.",
119
+ inputSchema: z.object({
120
+ path: z.string(),
121
+ search: z.string(),
122
+ replace: z.string()
123
+ }),
124
+ execute: async ({path, search, replace}) =>
125
+ executor.replaceInFile(path, search, replace)
126
+ }),
127
+
128
+ append_to_file: tool({
129
+ description:
130
+ "Append content to the end of a file.",
131
+ inputSchema: z.object({
132
+ path: z.string(),
133
+ content: z.string()
134
+ }),
135
+ execute: async ({ path, content }) =>
136
+ executor.appendToFile( path, content )
137
+ }),
138
+
139
+ insert_at_line: tool({
140
+ description:
141
+ "Insert content at a specific line.",
142
+ inputSchema: z.object({
143
+ path: z.string(),
144
+ line: z.number(),
145
+ content: z.string()
146
+ }),
147
+ execute: async ({path, line, content}) =>
148
+ executor.insertAtLine(path, line, content)
149
+ }),
150
+
151
+ run_command: tool({
152
+ description:
153
+ "Run a command immediately and capture output.",
154
+ inputSchema: z.object({
155
+ command: z.string(),
156
+ cwd: z.string().optional()
157
+ }),
158
+ execute: async ({command, cwd}) =>
159
+ executor.runCommand(command, cwd)
160
+ }),
161
+
162
+ run_background_command: tool({
163
+ description:
164
+ "Start a long-running process.",
165
+ inputSchema: z.object({
166
+ command: z.string(),
167
+ cwd: z.string().optional()
168
+ }),
169
+ execute: async (args) =>
170
+ executor.runBackgroundCommand(args)
171
+ }),
172
+
173
+ git_status: tool({
174
+ description:
175
+ "Get git status.",
176
+ inputSchema: z.object({}),
177
+ execute: async () =>
178
+ executor.gitStatus()
179
+ }),
180
+
181
+ git_diff: tool({
182
+ description:
183
+ "Get git diff.",
184
+ inputSchema: z.object({
185
+ staged: z.boolean().default(false)
186
+ }),
187
+ execute: async ({ staged }) =>
188
+ executor.gitDiff(staged)
189
+ }),
190
+
191
+ git_log: tool({
192
+ description:
193
+ "Get recent commits.",
194
+ inputSchema: z.object({
195
+ limit: z.number().default(20)
196
+ }),
197
+ execute: async ({ limit }) =>
198
+ executor.gitLog(limit)
199
+ }),
200
+
201
+ run_tests: tool({
202
+ description:
203
+ "Run the project's test suite.",
204
+ inputSchema: z.object({
205
+ filter: z.string().optional()
206
+ }),
207
+ execute: async ({ filter }) =>
208
+ executor.runTests(filter)
209
+ }),
210
+
211
+ run_test_file: tool({
212
+ description:
213
+ "Run a specific test file.",
214
+ inputSchema: z.object({
215
+ path: z.string()
216
+ }),
217
+ execute: async ({ path }) =>
218
+ executor.runTestFile(path)
219
+ }),
220
+
221
+ lint_project: tool({
222
+ description:
223
+ "Run linting.",
224
+ inputSchema: z.object({}),
225
+ execute: async () =>
226
+ executor.lintProject()
227
+ }),
228
+
229
+ format_project: tool({
230
+ description:
231
+ "Run formatting.",
232
+ inputSchema: z.object({}),
233
+ execute: async () =>
234
+ executor.formatProject()
235
+ }),
236
+
237
+ detect_framework: tool({
238
+ description:
239
+ "Detect framework, package manager and language.",
240
+ inputSchema: z.object({}),
241
+ execute: async () =>
242
+ executor.detectFramework()
243
+ }),
244
+
245
+ read_package_json: tool({
246
+ description:
247
+ "Read package.json summary.",
248
+ inputSchema: z.object({}),
249
+ execute: async () =>
250
+ executor.readPackageJson()
251
+ }),
252
+
253
+ web_search: tool({
254
+ description:
255
+ "Search the web for documentation.",
256
+ inputSchema: z.object({
257
+ query: z.string()
258
+ }),
259
+ execute: async ({ query }) =>
260
+ executor.webSearch(query)
261
+ }),
262
+
263
+ fetch_url: tool({
264
+ description:
265
+ "Fetch and summarize a URL.",
266
+ inputSchema: z.object({
267
+ url: z.string()
268
+ }),
269
+ execute: async ({ url }) =>
270
+ executor.fetchUrl(url)
271
+ }),
272
+
273
+ create_plan: tool({
274
+ description:
275
+ "Create a task execution plan.",
276
+ inputSchema: z.object({
277
+ goal: z.string()
278
+ }),
279
+ execute: async ({ goal }) =>
280
+ executor.createPlan(goal)
281
+ }),
282
+
283
+ get_plan: tool({
284
+ description:
285
+ "Retrieve current plan.",
286
+ inputSchema: z.object({}),
287
+ execute: async () =>
288
+ executor.getPlan()
289
+ }),
290
+
291
+ show_pending_changes: tool({
292
+ description:
293
+ "Show staged file operations (read-only display - does NOT apply changes). Use this to review what would be modified before user approval.",
294
+ inputSchema: z.object({}),
295
+ execute: async () =>
296
+ executor.showPendingChanges()
297
+ }),
298
+
299
+ // ❌ REMOVED: apply_changes
300
+ // This tool has been removed because applying changes must go through
301
+ // the runApprovalFlow() in orchestrator.ts which requires explicit
302
+ // user approval. Agents should never auto-apply changes without
303
+ // user consent.
304
+
305
+ discard_changes: tool({
306
+ description:
307
+ "Discard all staged operations (useful if you want to start over).",
308
+ inputSchema: z.object({}),
309
+ execute: async () =>
310
+ executor.discardChanges()
311
+ }),
312
+
313
+ execute_shell: tool({
314
+ description:
315
+ "Queue a shell command to run in the workspace after user approval. Use with care.",
316
+ inputSchema: z.object({
317
+ command: z.string().describe("Single command; runs with shell: true"),
318
+ }),
319
+ execute: async ({ command }) => executor.queueShell(command),
320
+ }),
321
+
322
+ list_skills: tool({
323
+ description:
324
+ "List absolute paths to SKILL.md files under configured skill directories (Cursor / Claude).",
325
+ inputSchema: z.object({}),
326
+ execute: async () => executor.listSkills(),
327
+ }),
328
+
329
+ read_skill: tool({
330
+ description:
331
+ "Read a SKILL.md file. Path must be absolute and under skill roots, or use a path returned by list_skills.",
332
+ inputSchema: z.object({
333
+ path: z.string(),
334
+ }),
335
+ execute: async ({ path: p }) => executor.readSkill(p),
336
+ }),
337
+ }
338
+ }
@@ -0,0 +1,184 @@
1
+ import type { ActionTracker } from "./action-tracker";
2
+ import chalk from "chalk";
3
+ import {select, isCancel} from '@clack/prompts'
4
+ import type { ActionLog } from "./types";
5
+ import { composeBeforeAfter, formatPatch } from "./diff-view";
6
+ import { renderTerminalMarkdown } from "../../tui/terminal-md";
7
+
8
+ interface ReviewGroup{
9
+ label:string;
10
+ actionIds:string[],
11
+ patch:string | null
12
+ }
13
+
14
+ interface ApprovalFlowOptions {
15
+ paths?: string[];
16
+ skipBatchPrompt?: boolean;
17
+ }
18
+
19
+ function groupPending(pending: ActionLog[]): ReviewGroup[] {
20
+ const byPath = new Map<string, ActionLog[]>();
21
+ const shells: ActionLog[] = [];
22
+
23
+ for (const a of pending) {
24
+ if (a.type === "tool_execute") {
25
+ shells.push(a);
26
+ continue;
27
+ }
28
+ const key = a.path;
29
+ if (!byPath.has(key)) byPath.set(key, []);
30
+ byPath.get(key)!.push(a);
31
+ }
32
+
33
+ const groups: ReviewGroup[] = [];
34
+
35
+ const pathEntries = [...byPath.entries()].sort(([a], [b]) =>
36
+ a.localeCompare(b),
37
+ );
38
+ for (const [p, acts] of pathEntries) {
39
+ const sorted = acts.sort(
40
+ (x, y) => x.timestamp.getTime() - y.timestamp.getTime(),
41
+ );
42
+ const ids = sorted.map((x) => x.id);
43
+
44
+ if (sorted.every((x) => x.type === "folder_create")) {
45
+ groups.push({
46
+ label: `Create folder: ${p}`,
47
+ actionIds: ids,
48
+ patch: null,
49
+ });
50
+ continue;
51
+ }
52
+
53
+ const { before, after } = composeBeforeAfter(sorted);
54
+ const patch = formatPatch(p, before, after);
55
+ const kinds = [...new Set(sorted.map((x) => x.type))].join(", ");
56
+ groups.push({ label: `${p} (${kinds})`, actionIds: ids, patch });
57
+ }
58
+
59
+ for (const s of shells) {
60
+ groups.push({
61
+ label: `Shell: ${s.details.command ?? "(no command)"}`,
62
+ actionIds: [s.id],
63
+ patch: null,
64
+ });
65
+ }
66
+
67
+ return groups;
68
+ }
69
+
70
+ /**
71
+ * Run the approval flow for staged changes.
72
+ *
73
+ * This function:
74
+ * 1. Checks if there are any pending changes
75
+ * 2. If none, returns false (nothing to approve)
76
+ * 3. If yes, prompts user to approve all, review individually, or cancel
77
+ * 4. Updates tracker with user's approval decisions
78
+ * 5. Returns true if ANY changes were approved, false if all rejected/cancelled
79
+ *
80
+ * @param tracker ActionTracker with pending mutations
81
+ * @returns true if user approved any changes, false otherwise
82
+ */
83
+ export async function runApprovalFlow(
84
+ tracker: ActionTracker,
85
+ options: ApprovalFlowOptions = {},
86
+ ):Promise<boolean>{
87
+ const pathSet = options.paths ? new Set(options.paths) : null
88
+ const pending = pathSet
89
+ ? tracker.getPendingMutations().filter((a) => pathSet.has(a.path))
90
+ : tracker.getPendingMutations()
91
+
92
+ // No changes to review
93
+ if(pending.length === 0){
94
+ console.log(chalk.dim('\nNo staged file, folder or shell changes to review.\n'))
95
+ return false // ✓ Correct: nothing to approve
96
+ }
97
+
98
+ if(!options.skipBatchPrompt){
99
+ // Ask user how to proceed
100
+ const choice = await select({
101
+ message: "Apply staged changes?",
102
+ options: [
103
+ {value: "all", label: "Approve and apply all"},
104
+ {value: "select", label: "Review one by one"},
105
+ {value: "cancel", label: "Cancel"},
106
+ ]
107
+ })
108
+
109
+ // User cancelled
110
+ if(isCancel(choice) || choice === "cancel"){
111
+ // Mark all as rejected
112
+ for(const a of pending){
113
+ tracker.updateStatus(a.id, "rejected", false)
114
+ }
115
+ return false // ✓ Correct: user rejected all
116
+ }
117
+
118
+ // User selected "Approve all" - approve everything and return immediately
119
+ if(choice === "all"){
120
+ for(const a of pending){
121
+ tracker.updateStatus(a.id, "approved", true)
122
+ }
123
+ return true // ✓ IMPORTANT: return immediately without asking about each change
124
+ }
125
+ }
126
+
127
+ // User selected "Review one by one"
128
+ // Groups changes by file for easier review
129
+ const groups = groupPending(pending);
130
+
131
+ for(const g of groups){
132
+ // Keep asking about this group until user makes a choice
133
+ while(true){
134
+ const opt = await select({
135
+ message: chalk.bold(g.label),
136
+ options: [
137
+ { value: "accept", label: "Accept" },
138
+ { value: "diff", label: "Show diff", hint: g.patch ? "" : "N/A" },
139
+ { value: "reject", label: "Reject" },
140
+ ],
141
+ })
142
+
143
+ // User hit Ctrl+C during review
144
+ if(isCancel(opt)){
145
+ for(const a of pending) {
146
+ tracker.updateStatus(a.id, "rejected", false)
147
+ }
148
+ return false
149
+ }
150
+
151
+ // User wants to see the diff
152
+ if(opt === "diff"){
153
+ if (g.patch) {
154
+ console.log(
155
+ "\n" +
156
+ renderTerminalMarkdown("```diff\n" + g.patch + "\n```\n") +
157
+ "\n",
158
+ );
159
+ }
160
+ // ✓ Loop continues, ask again for this group
161
+ continue;
162
+ }
163
+
164
+ // User accepted or rejected this group
165
+ // opt === "accept" or opt === "reject"
166
+ for(const id of g.actionIds){
167
+ tracker.updateStatus(
168
+ id,
169
+ opt === "accept" ? "approved" : "rejected",
170
+ opt === "accept"
171
+ )
172
+ }
173
+
174
+ // ✓ Break inner loop, move to next group
175
+ break
176
+ }
177
+ }
178
+
179
+ // ✓ Return true only if user approved ANY changes
180
+ // If user rejected all, this returns false (nothing gets applied)
181
+ return pending.some((a) =>
182
+ tracker.getActions().some((x) => x.id === a.id && x.status === "approved"),
183
+ )
184
+ }