ralph-review 0.2.3 → 0.2.5
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/package.json +5 -5
- package/src/commands/apply.ts +33 -10
- package/src/commands/interactive-deps.ts +25 -0
- package/src/commands/list.ts +24 -7
- package/src/commands/status.ts +25 -4
- package/src/commands/stop.ts +97 -53
- package/src/lib/agents/models.ts +9 -0
- package/src/lib/review-workflow/review/run-review-session.ts +14 -0
- package/src/lib/stop-session.ts +63 -1
- package/src/lib/tui/dashboard/Dashboard.tsx +14 -9
- package/src/lib/tui/dashboard/ReviewModeOverlay.tsx +2 -2
- package/src/lib/tui/dashboard/use-dashboard-run-control.ts +11 -3
- package/src/lib/tui/dashboard/use-dashboard-stop-control.ts +17 -10
- package/src/lib/tui/shared/types.ts +10 -0
- package/src/lib/tui/workspace/use-workspace-state.ts +69 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ralph-review",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Orchestrating coding agents for code review, verification and fixing via the ralph loop.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -53,16 +53,16 @@
|
|
|
53
53
|
"publish:recover": "bun run scripts/publish.ts --recover",
|
|
54
54
|
"publish:recover:execute": "bun run scripts/publish.ts --recover --execute",
|
|
55
55
|
"rr": "bun run src/cli.ts",
|
|
56
|
-
"test": "AGENT=1 bun test",
|
|
57
|
-
"prepublishOnly": "bun test",
|
|
56
|
+
"test": "AGENT=1 bun test --max-concurrency=1",
|
|
57
|
+
"prepublishOnly": "bun test --max-concurrency=1",
|
|
58
58
|
"typecheck": "tsc --noEmit",
|
|
59
59
|
"knip": "knip-bun",
|
|
60
60
|
"check-duplicates": "bunx jscpd src tests --exitCode 1 --reporters ai",
|
|
61
61
|
"lint": "biome check --write .",
|
|
62
62
|
"lint:ci": "biome ci .",
|
|
63
63
|
"lint-staged": "lint-staged",
|
|
64
|
-
"check": "bun run typecheck && bun run knip && bun run lint && bun run check-duplicates && AGENT=1 bun test --coverage",
|
|
65
|
-
"check:ci": "bun run typecheck && bun run knip && bun run lint:ci && bun run check-duplicates && AGENT=1 bun test --coverage",
|
|
64
|
+
"check": "bun run typecheck && bun run knip && bun run lint && bun run check-duplicates && AGENT=1 bun test --max-concurrency=1 --coverage",
|
|
65
|
+
"check:ci": "bun run typecheck && bun run knip && bun run lint:ci && bun run check-duplicates && AGENT=1 bun test --max-concurrency=1 --coverage",
|
|
66
66
|
"prepare": "husky && bun run setup-hooks",
|
|
67
67
|
"setup-hooks": "bun -e 'await Bun.write(\".husky/pre-commit\", \"#!/usr/bin/env sh\\n\\nbun run knip && bun run lint-staged\\n\")' && chmod +x .husky/pre-commit"
|
|
68
68
|
},
|
package/src/commands/apply.ts
CHANGED
|
@@ -1,20 +1,35 @@
|
|
|
1
|
-
import * as p from "@clack/prompts";
|
|
2
1
|
import { resolvePendingHandoffSelection } from "@/commands/handoff-selection";
|
|
3
2
|
import {
|
|
4
3
|
createInteractiveCommandDeps,
|
|
4
|
+
createPromptDeps,
|
|
5
5
|
type InteractiveCommandDeps,
|
|
6
|
+
type PromptDeps,
|
|
6
7
|
} from "@/commands/interactive-deps";
|
|
7
8
|
import { parseCommand } from "@/lib/cli-parser";
|
|
8
9
|
import { applyPendingHandoff, listProjectPendingHandoffs } from "@/lib/handoff";
|
|
9
10
|
import { appendLog } from "@/lib/logger";
|
|
11
|
+
import type { LogEntry } from "@/lib/types";
|
|
10
12
|
|
|
11
13
|
interface ApplyOptions {
|
|
12
14
|
session?: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
type ApplyDeps = InteractiveCommandDeps
|
|
17
|
+
type ApplyDeps = InteractiveCommandDeps &
|
|
18
|
+
PromptDeps & {
|
|
19
|
+
cwd: () => string;
|
|
20
|
+
listProjectPendingHandoffs: typeof listProjectPendingHandoffs;
|
|
21
|
+
applyPendingHandoff: typeof applyPendingHandoff;
|
|
22
|
+
appendLog: (logPath: string, entry: LogEntry) => Promise<void>;
|
|
23
|
+
};
|
|
16
24
|
|
|
17
|
-
const DEFAULT_APPLY_DEPS =
|
|
25
|
+
const DEFAULT_APPLY_DEPS: ApplyDeps = {
|
|
26
|
+
...createInteractiveCommandDeps(),
|
|
27
|
+
...createPromptDeps(),
|
|
28
|
+
cwd: () => process.cwd(),
|
|
29
|
+
listProjectPendingHandoffs,
|
|
30
|
+
applyPendingHandoff,
|
|
31
|
+
appendLog,
|
|
32
|
+
};
|
|
18
33
|
|
|
19
34
|
const NO_PENDING_HANDOFFS_MESSAGE = "No pending review handoffs for current working directory.";
|
|
20
35
|
|
|
@@ -37,10 +52,10 @@ export async function runApply(args: string[], deps: Partial<ApplyDeps> = {}): P
|
|
|
37
52
|
return;
|
|
38
53
|
}
|
|
39
54
|
|
|
40
|
-
const projectPath =
|
|
41
|
-
const handoffs = await listProjectPendingHandoffs(undefined, projectPath);
|
|
55
|
+
const projectPath = applyDeps.cwd();
|
|
56
|
+
const handoffs = await applyDeps.listProjectPendingHandoffs(undefined, projectPath);
|
|
42
57
|
if (handoffs.length === 0) {
|
|
43
|
-
|
|
58
|
+
applyDeps.logInfo(NO_PENDING_HANDOFFS_MESSAGE);
|
|
44
59
|
return;
|
|
45
60
|
}
|
|
46
61
|
|
|
@@ -49,6 +64,8 @@ export async function runApply(args: string[], deps: Partial<ApplyDeps> = {}): P
|
|
|
49
64
|
selector: parsed.session,
|
|
50
65
|
action: "apply",
|
|
51
66
|
isTTY: applyDeps.isTTY(),
|
|
67
|
+
select: applyDeps.select,
|
|
68
|
+
isCancel: applyDeps.isCancel,
|
|
52
69
|
});
|
|
53
70
|
|
|
54
71
|
if (!selection.handoff) {
|
|
@@ -59,21 +76,27 @@ export async function runApply(args: string[], deps: Partial<ApplyDeps> = {}): P
|
|
|
59
76
|
return;
|
|
60
77
|
}
|
|
61
78
|
|
|
62
|
-
|
|
79
|
+
applyDeps.logStep(`Applying handoff: ${selection.handoff.handoffId}`);
|
|
63
80
|
|
|
64
81
|
try {
|
|
65
|
-
const artifact = await applyPendingHandoff(
|
|
66
|
-
|
|
82
|
+
const artifact = await applyDeps.applyPendingHandoff(
|
|
83
|
+
undefined,
|
|
84
|
+
projectPath,
|
|
85
|
+
selection.handoff.handoffId
|
|
86
|
+
);
|
|
87
|
+
await applyDeps.appendLog(artifact.logPath, {
|
|
67
88
|
type: "handoff",
|
|
68
89
|
timestamp: Date.now(),
|
|
69
90
|
handoffId: artifact.handoffId,
|
|
70
91
|
handoffStatus: "applied-manual",
|
|
71
92
|
commitSha: artifact.commitSha,
|
|
72
93
|
});
|
|
73
|
-
|
|
94
|
+
applyDeps.logSuccess("Review handoff applied.");
|
|
74
95
|
} catch (error) {
|
|
75
96
|
applyDeps.logError(`${error}`);
|
|
76
97
|
applyDeps.exit(1);
|
|
77
98
|
return;
|
|
78
99
|
}
|
|
79
100
|
}
|
|
101
|
+
|
|
102
|
+
export type { ApplyDeps };
|
|
@@ -8,6 +8,20 @@ export interface InteractiveCommandDeps {
|
|
|
8
8
|
isTTY: () => boolean;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export type PromptSelect = (input: {
|
|
12
|
+
message: string;
|
|
13
|
+
options: Array<{ value: string; label: string; hint: string }>;
|
|
14
|
+
}) => Promise<unknown>;
|
|
15
|
+
|
|
16
|
+
export interface PromptDeps {
|
|
17
|
+
logInfo: (message: string) => void;
|
|
18
|
+
logMessage: (message: string) => void;
|
|
19
|
+
logStep: (message: string) => void;
|
|
20
|
+
logSuccess: (message: string) => void;
|
|
21
|
+
select: PromptSelect;
|
|
22
|
+
isCancel: (value: unknown) => boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
export function createInteractiveCommandDeps(): InteractiveCommandDeps {
|
|
12
26
|
return {
|
|
13
27
|
getCommandDef,
|
|
@@ -16,3 +30,14 @@ export function createInteractiveCommandDeps(): InteractiveCommandDeps {
|
|
|
16
30
|
isTTY: () => process.stdout.isTTY === true,
|
|
17
31
|
};
|
|
18
32
|
}
|
|
33
|
+
|
|
34
|
+
export function createPromptDeps(): PromptDeps {
|
|
35
|
+
return {
|
|
36
|
+
logInfo: p.log.info,
|
|
37
|
+
logMessage: p.log.message,
|
|
38
|
+
logStep: p.log.step,
|
|
39
|
+
logSuccess: p.log.success,
|
|
40
|
+
select: p.select,
|
|
41
|
+
isCancel: p.isCancel,
|
|
42
|
+
};
|
|
43
|
+
}
|
package/src/commands/list.ts
CHANGED
|
@@ -2,6 +2,20 @@ import * as p from "@clack/prompts";
|
|
|
2
2
|
import { listAllActiveSessions } from "@/lib/session-state";
|
|
3
3
|
import { listRalphSessions } from "@/lib/tmux";
|
|
4
4
|
|
|
5
|
+
interface ListDeps {
|
|
6
|
+
listAllActiveSessions: typeof listAllActiveSessions;
|
|
7
|
+
listRalphSessions: typeof listRalphSessions;
|
|
8
|
+
logInfo: (message: string) => void;
|
|
9
|
+
print: (...args: unknown[]) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DEFAULT_LIST_DEPS: ListDeps = {
|
|
13
|
+
listAllActiveSessions,
|
|
14
|
+
listRalphSessions,
|
|
15
|
+
logInfo: p.log.info,
|
|
16
|
+
print: (...args) => console.log(...args),
|
|
17
|
+
};
|
|
18
|
+
|
|
5
19
|
function formatRelativeStart(startTime: number): string {
|
|
6
20
|
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - startTime) / 1000));
|
|
7
21
|
|
|
@@ -18,10 +32,11 @@ function formatRelativeStart(startTime: number): string {
|
|
|
18
32
|
return `${elapsedHours}h ago`;
|
|
19
33
|
}
|
|
20
34
|
|
|
21
|
-
export async function runList(): Promise<void> {
|
|
35
|
+
export async function runList(deps: Partial<ListDeps> = {}): Promise<void> {
|
|
36
|
+
const listDeps = { ...DEFAULT_LIST_DEPS, ...deps };
|
|
22
37
|
const [activeSessions, tmuxSessions] = await Promise.all([
|
|
23
|
-
listAllActiveSessions(),
|
|
24
|
-
listRalphSessions(),
|
|
38
|
+
listDeps.listAllActiveSessions(),
|
|
39
|
+
listDeps.listRalphSessions(),
|
|
25
40
|
]);
|
|
26
41
|
const trackedTmuxSessions = new Set(activeSessions.map((session) => session.sessionName));
|
|
27
42
|
const untrackedTmuxSessions = tmuxSessions.filter(
|
|
@@ -29,17 +44,19 @@ export async function runList(): Promise<void> {
|
|
|
29
44
|
);
|
|
30
45
|
|
|
31
46
|
if (activeSessions.length === 0 && untrackedTmuxSessions.length === 0) {
|
|
32
|
-
|
|
47
|
+
listDeps.logInfo("No active review sessions.");
|
|
33
48
|
} else {
|
|
34
|
-
|
|
49
|
+
listDeps.logInfo("Active review sessions:");
|
|
35
50
|
for (const session of activeSessions) {
|
|
36
51
|
const worktree = session.worktreeBranch ? ` ${session.worktreeBranch}` : "";
|
|
37
|
-
|
|
52
|
+
listDeps.print(
|
|
38
53
|
`${session.sessionId.slice(0, 8)} ${session.sessionName} ${session.projectPath}${worktree} ${formatRelativeStart(session.startTime)}`
|
|
39
54
|
);
|
|
40
55
|
}
|
|
41
56
|
for (const sessionName of untrackedTmuxSessions) {
|
|
42
|
-
|
|
57
|
+
listDeps.print(`${sessionName} (tmux only)`);
|
|
43
58
|
}
|
|
44
59
|
}
|
|
45
60
|
}
|
|
61
|
+
|
|
62
|
+
export type { ListDeps };
|
package/src/commands/status.ts
CHANGED
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
import { getGitBranch } from "@/lib/logger";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
interface StatusDeps {
|
|
4
|
+
cwd: () => string;
|
|
5
|
+
getGitBranch: typeof getGitBranch;
|
|
6
|
+
renderDashboard: (payload: { projectPath: string; branch: string | undefined }) => Promise<void>;
|
|
7
|
+
}
|
|
6
8
|
|
|
9
|
+
async function renderDashboardWithDynamicImport(payload: {
|
|
10
|
+
projectPath: string;
|
|
11
|
+
branch: string | undefined;
|
|
12
|
+
}): Promise<void> {
|
|
7
13
|
const { renderDashboard } = await import("@/lib/tui/index");
|
|
14
|
+
await renderDashboard(payload);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_STATUS_DEPS: StatusDeps = {
|
|
18
|
+
cwd: () => process.cwd(),
|
|
19
|
+
getGitBranch,
|
|
20
|
+
renderDashboard: renderDashboardWithDynamicImport,
|
|
21
|
+
};
|
|
8
22
|
|
|
9
|
-
|
|
23
|
+
export async function runStatus(deps: Partial<StatusDeps> = {}): Promise<void> {
|
|
24
|
+
const statusDeps = { ...DEFAULT_STATUS_DEPS, ...deps };
|
|
25
|
+
const projectPath = statusDeps.cwd();
|
|
26
|
+
const branch = await statusDeps.getGitBranch(projectPath);
|
|
27
|
+
|
|
28
|
+
await statusDeps.renderDashboard({
|
|
10
29
|
projectPath,
|
|
11
30
|
branch,
|
|
12
31
|
});
|
|
13
32
|
}
|
|
33
|
+
|
|
34
|
+
export type { StatusDeps };
|
package/src/commands/stop.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import * as p from "@clack/prompts";
|
|
2
1
|
import {
|
|
3
2
|
createInteractiveCommandDeps,
|
|
3
|
+
createPromptDeps,
|
|
4
4
|
type InteractiveCommandDeps,
|
|
5
|
+
type PromptDeps,
|
|
5
6
|
} from "@/commands/interactive-deps";
|
|
6
7
|
import { parseCommand } from "@/lib/cli-parser";
|
|
7
8
|
import { listProjectPendingHandoffs } from "@/lib/handoff";
|
|
@@ -25,9 +26,47 @@ interface StopOptions {
|
|
|
25
26
|
session?: string;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
type StopDeps = InteractiveCommandDeps
|
|
29
|
+
type StopDeps = InteractiveCommandDeps &
|
|
30
|
+
PromptDeps & {
|
|
31
|
+
cwd: () => string;
|
|
32
|
+
computeSessionStats: typeof computeSessionStats;
|
|
33
|
+
listProjectPendingHandoffs: typeof listProjectPendingHandoffs;
|
|
34
|
+
listAllActiveSessions: typeof listAllActiveSessions;
|
|
35
|
+
listProjectActiveSessions: typeof listProjectActiveSessions;
|
|
36
|
+
removeAllSessionStates: typeof removeAllSessionStates;
|
|
37
|
+
stopActiveSession: typeof stopActiveSession;
|
|
38
|
+
updateSessionState: typeof updateSessionState;
|
|
39
|
+
sendInterrupt: typeof sendInterrupt;
|
|
40
|
+
readSessionState: typeof readSessionState;
|
|
41
|
+
sessionExists: typeof sessionExists;
|
|
42
|
+
killSession: typeof killSession;
|
|
43
|
+
removeSessionState: typeof removeSessionState;
|
|
44
|
+
listRalphSessions: typeof listRalphSessions;
|
|
45
|
+
sleep: (ms: number) => Promise<void>;
|
|
46
|
+
};
|
|
29
47
|
|
|
30
|
-
const DEFAULT_STOP_DEPS =
|
|
48
|
+
const DEFAULT_STOP_DEPS: StopDeps = {
|
|
49
|
+
...createInteractiveCommandDeps(),
|
|
50
|
+
...createPromptDeps(),
|
|
51
|
+
cwd: () => process.cwd(),
|
|
52
|
+
computeSessionStats,
|
|
53
|
+
listProjectPendingHandoffs,
|
|
54
|
+
listAllActiveSessions,
|
|
55
|
+
listProjectActiveSessions,
|
|
56
|
+
removeAllSessionStates,
|
|
57
|
+
stopActiveSession,
|
|
58
|
+
updateSessionState,
|
|
59
|
+
sendInterrupt,
|
|
60
|
+
readSessionState,
|
|
61
|
+
sessionExists,
|
|
62
|
+
killSession,
|
|
63
|
+
removeSessionState,
|
|
64
|
+
listRalphSessions,
|
|
65
|
+
sleep: (ms) =>
|
|
66
|
+
new Promise<void>((resolve) => {
|
|
67
|
+
setTimeout(resolve, ms);
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
31
70
|
|
|
32
71
|
type ResolvedStopHandoff = {
|
|
33
72
|
handoffStatus: Extract<HandoffStatus, "applied-auto" | "pending-apply" | "apply-conflicted">;
|
|
@@ -90,12 +129,13 @@ function createLogSessionFromPath(session: ActiveSession): LogSession | null {
|
|
|
90
129
|
}
|
|
91
130
|
|
|
92
131
|
async function resolveStoppedSessionHandoff(
|
|
93
|
-
session: ActiveSession
|
|
132
|
+
session: ActiveSession,
|
|
133
|
+
deps: StopDeps
|
|
94
134
|
): Promise<ResolvedStopHandoff | null> {
|
|
95
135
|
const logSession = createLogSessionFromPath(session);
|
|
96
136
|
if (logSession) {
|
|
97
137
|
try {
|
|
98
|
-
const stats = await computeSessionStats(logSession);
|
|
138
|
+
const stats = await deps.computeSessionStats(logSession);
|
|
99
139
|
if (
|
|
100
140
|
(!stats.sessionId || stats.sessionId === session.sessionId) &&
|
|
101
141
|
isReportedStopHandoffStatus(stats.handoffStatus)
|
|
@@ -112,7 +152,7 @@ async function resolveStoppedSessionHandoff(
|
|
|
112
152
|
}
|
|
113
153
|
|
|
114
154
|
try {
|
|
115
|
-
const pendingHandoffs = await listProjectPendingHandoffs(undefined, session.projectPath);
|
|
155
|
+
const pendingHandoffs = await deps.listProjectPendingHandoffs(undefined, session.projectPath);
|
|
116
156
|
const matchingHandoffs = pendingHandoffs.filter(
|
|
117
157
|
(handoff) => handoff.sessionId === session.sessionId
|
|
118
158
|
);
|
|
@@ -153,9 +193,10 @@ function formatProjectScopedCommand(
|
|
|
153
193
|
|
|
154
194
|
async function resolveStoppedSessionHandoffNote(
|
|
155
195
|
session: ActiveSession,
|
|
156
|
-
currentProjectPath: string
|
|
196
|
+
currentProjectPath: string,
|
|
197
|
+
deps: StopDeps
|
|
157
198
|
): Promise<string | null> {
|
|
158
|
-
const handoff = await resolveStoppedSessionHandoff(session);
|
|
199
|
+
const handoff = await resolveStoppedSessionHandoff(session, deps);
|
|
159
200
|
if (!handoff) {
|
|
160
201
|
return null;
|
|
161
202
|
}
|
|
@@ -181,18 +222,20 @@ async function resolveStoppedSessionHandoffNote(
|
|
|
181
222
|
|
|
182
223
|
async function stopSessionWithHandoff(
|
|
183
224
|
session: ActiveSession,
|
|
184
|
-
currentProjectPath: string
|
|
225
|
+
currentProjectPath: string,
|
|
226
|
+
deps: StopDeps
|
|
185
227
|
): Promise<string | null> {
|
|
186
|
-
await stopActiveSession(session, {
|
|
187
|
-
updateSessionState,
|
|
188
|
-
sendInterrupt,
|
|
189
|
-
readSessionState,
|
|
190
|
-
sessionExists,
|
|
191
|
-
killSession,
|
|
192
|
-
removeSessionState,
|
|
228
|
+
await deps.stopActiveSession(session, {
|
|
229
|
+
updateSessionState: deps.updateSessionState,
|
|
230
|
+
sendInterrupt: deps.sendInterrupt,
|
|
231
|
+
readSessionState: deps.readSessionState,
|
|
232
|
+
sessionExists: deps.sessionExists,
|
|
233
|
+
killSession: deps.killSession,
|
|
234
|
+
removeSessionState: deps.removeSessionState,
|
|
235
|
+
sleep: deps.sleep,
|
|
193
236
|
});
|
|
194
237
|
|
|
195
|
-
return await resolveStoppedSessionHandoffNote(session, currentProjectPath);
|
|
238
|
+
return await resolveStoppedSessionHandoffNote(session, currentProjectPath, deps);
|
|
196
239
|
}
|
|
197
240
|
|
|
198
241
|
function findSessionBySelector(
|
|
@@ -233,9 +276,10 @@ function findSessionBySelector(
|
|
|
233
276
|
}
|
|
234
277
|
|
|
235
278
|
async function chooseProjectSession(
|
|
236
|
-
projectSessions: ActiveSession[]
|
|
279
|
+
projectSessions: ActiveSession[],
|
|
280
|
+
deps: StopDeps
|
|
237
281
|
): Promise<ActiveSession | null> {
|
|
238
|
-
const selection = await
|
|
282
|
+
const selection = await deps.select({
|
|
239
283
|
message: "Choose a review session to stop",
|
|
240
284
|
options: projectSessions.map((session) => ({
|
|
241
285
|
value: session.sessionId,
|
|
@@ -244,38 +288,38 @@ async function chooseProjectSession(
|
|
|
244
288
|
})),
|
|
245
289
|
});
|
|
246
290
|
|
|
247
|
-
if (
|
|
291
|
+
if (deps.isCancel(selection)) {
|
|
248
292
|
return null;
|
|
249
293
|
}
|
|
250
294
|
|
|
251
295
|
return projectSessions.find((session) => session.sessionId === selection) ?? null;
|
|
252
296
|
}
|
|
253
297
|
|
|
254
|
-
async function stopSession(session: ActiveSession): Promise<void> {
|
|
255
|
-
|
|
256
|
-
const handoffNote = await stopSessionWithHandoff(session,
|
|
257
|
-
|
|
298
|
+
async function stopSession(session: ActiveSession, deps: StopDeps): Promise<void> {
|
|
299
|
+
deps.logStep(`Stopping session: ${session.sessionName}`);
|
|
300
|
+
const handoffNote = await stopSessionWithHandoff(session, deps.cwd(), deps);
|
|
301
|
+
deps.logSuccess("Review stopped.");
|
|
258
302
|
if (handoffNote) {
|
|
259
|
-
|
|
303
|
+
deps.logMessage(`Handoff:\n${handoffNote}`);
|
|
260
304
|
}
|
|
261
305
|
}
|
|
262
306
|
|
|
263
|
-
async function stopAllSessions(): Promise<void> {
|
|
307
|
+
async function stopAllSessions(deps: StopDeps): Promise<void> {
|
|
264
308
|
const orphanStopGracePeriod = 1_000;
|
|
265
|
-
const currentProjectPath =
|
|
266
|
-
const activeSessions = await listAllActiveSessions();
|
|
267
|
-
const tmuxSessions = await listRalphSessions();
|
|
309
|
+
const currentProjectPath = deps.cwd();
|
|
310
|
+
const activeSessions = await deps.listAllActiveSessions();
|
|
311
|
+
const tmuxSessions = await deps.listRalphSessions();
|
|
268
312
|
const sessionNames = [
|
|
269
313
|
...new Set([...tmuxSessions, ...activeSessions.map((session) => session.sessionName)]),
|
|
270
314
|
];
|
|
271
315
|
|
|
272
316
|
if (sessionNames.length === 0) {
|
|
273
|
-
|
|
274
|
-
await removeAllSessionStates();
|
|
317
|
+
deps.logInfo("No active review sessions.");
|
|
318
|
+
await deps.removeAllSessionStates();
|
|
275
319
|
return;
|
|
276
320
|
}
|
|
277
321
|
|
|
278
|
-
|
|
322
|
+
deps.logStep(`Stopping ${sessionNames.length} session(s)...`);
|
|
279
323
|
|
|
280
324
|
const activeSessionsByName = new Map(
|
|
281
325
|
activeSessions.map((session) => [session.sessionName, session] as const)
|
|
@@ -285,38 +329,36 @@ async function stopAllSessions(): Promise<void> {
|
|
|
285
329
|
);
|
|
286
330
|
const activeStopPromise = Promise.all(
|
|
287
331
|
activeSessions.map(async (session) => ({
|
|
288
|
-
handoffNote: await stopSessionWithHandoff(session, currentProjectPath),
|
|
332
|
+
handoffNote: await stopSessionWithHandoff(session, currentProjectPath, deps),
|
|
289
333
|
}))
|
|
290
334
|
);
|
|
291
335
|
|
|
292
336
|
for (const sessionName of orphanSessionNames) {
|
|
293
|
-
await sendInterrupt(sessionName);
|
|
337
|
+
await deps.sendInterrupt(sessionName);
|
|
294
338
|
}
|
|
295
339
|
|
|
296
340
|
const stoppedActiveSessions = await activeStopPromise;
|
|
297
341
|
|
|
298
342
|
if (orphanSessionNames.length > 0) {
|
|
299
|
-
await
|
|
300
|
-
setTimeout(resolve, orphanStopGracePeriod);
|
|
301
|
-
});
|
|
343
|
+
await deps.sleep(orphanStopGracePeriod);
|
|
302
344
|
}
|
|
303
345
|
|
|
304
346
|
for (const sessionName of orphanSessionNames) {
|
|
305
|
-
await killSession(sessionName);
|
|
347
|
+
await deps.killSession(sessionName);
|
|
306
348
|
}
|
|
307
349
|
|
|
308
350
|
for (const sessionName of sessionNames) {
|
|
309
|
-
|
|
351
|
+
deps.logMessage(` Stopped: ${sessionName}`);
|
|
310
352
|
}
|
|
311
353
|
|
|
312
354
|
for (const stoppedSession of stoppedActiveSessions) {
|
|
313
355
|
if (stoppedSession.handoffNote) {
|
|
314
|
-
|
|
356
|
+
deps.logMessage(`Handoff:\n${stoppedSession.handoffNote}`);
|
|
315
357
|
}
|
|
316
358
|
}
|
|
317
359
|
|
|
318
|
-
await removeAllSessionStates();
|
|
319
|
-
|
|
360
|
+
await deps.removeAllSessionStates();
|
|
361
|
+
deps.logSuccess(`Stopped ${sessionNames.length} session(s).`);
|
|
320
362
|
}
|
|
321
363
|
|
|
322
364
|
async function stopCurrentProjectSession(
|
|
@@ -325,7 +367,7 @@ async function stopCurrentProjectSession(
|
|
|
325
367
|
deps: StopDeps
|
|
326
368
|
): Promise<void> {
|
|
327
369
|
const projectSessions = getCurrentProjectSessions(
|
|
328
|
-
await listProjectActiveSessions(undefined, projectPath),
|
|
370
|
+
await deps.listProjectActiveSessions(undefined, projectPath),
|
|
329
371
|
projectPath
|
|
330
372
|
);
|
|
331
373
|
|
|
@@ -337,17 +379,17 @@ async function stopCurrentProjectSession(
|
|
|
337
379
|
return;
|
|
338
380
|
}
|
|
339
381
|
|
|
340
|
-
await stopSession(match.session);
|
|
382
|
+
await stopSession(match.session, deps);
|
|
341
383
|
return;
|
|
342
384
|
}
|
|
343
385
|
|
|
344
386
|
if (projectSessions.length === 0) {
|
|
345
|
-
|
|
387
|
+
deps.logInfo("No active review session for current working directory.");
|
|
346
388
|
|
|
347
|
-
const allSessions = await listAllActiveSessions();
|
|
389
|
+
const allSessions = await deps.listAllActiveSessions();
|
|
348
390
|
if (allSessions.length > 0) {
|
|
349
|
-
|
|
350
|
-
|
|
391
|
+
deps.logMessage(`\nThere are ${allSessions.length} other session(s) running.`);
|
|
392
|
+
deps.logMessage(
|
|
351
393
|
'Use "rr stop --all" to stop all running review sessions, or "rr" to see details.'
|
|
352
394
|
);
|
|
353
395
|
}
|
|
@@ -357,7 +399,7 @@ async function stopCurrentProjectSession(
|
|
|
357
399
|
if (projectSessions.length === 1) {
|
|
358
400
|
const onlySession = projectSessions[0];
|
|
359
401
|
if (onlySession) {
|
|
360
|
-
await stopSession(onlySession);
|
|
402
|
+
await stopSession(onlySession, deps);
|
|
361
403
|
}
|
|
362
404
|
return;
|
|
363
405
|
}
|
|
@@ -370,12 +412,12 @@ async function stopCurrentProjectSession(
|
|
|
370
412
|
return;
|
|
371
413
|
}
|
|
372
414
|
|
|
373
|
-
const selectedSession = await chooseProjectSession(projectSessions);
|
|
415
|
+
const selectedSession = await chooseProjectSession(projectSessions, deps);
|
|
374
416
|
if (!selectedSession) {
|
|
375
417
|
return;
|
|
376
418
|
}
|
|
377
419
|
|
|
378
|
-
await stopSession(selectedSession);
|
|
420
|
+
await stopSession(selectedSession, deps);
|
|
379
421
|
}
|
|
380
422
|
|
|
381
423
|
export async function runStop(args: string[], deps: Partial<StopDeps> = {}): Promise<void> {
|
|
@@ -399,9 +441,11 @@ export async function runStop(args: string[], deps: Partial<StopDeps> = {}): Pro
|
|
|
399
441
|
}
|
|
400
442
|
|
|
401
443
|
if (options.all) {
|
|
402
|
-
await stopAllSessions();
|
|
444
|
+
await stopAllSessions(stopDeps);
|
|
403
445
|
return;
|
|
404
446
|
}
|
|
405
447
|
|
|
406
|
-
await stopCurrentProjectSession(
|
|
448
|
+
await stopCurrentProjectSession(stopDeps.cwd(), options.session, stopDeps);
|
|
407
449
|
}
|
|
450
|
+
|
|
451
|
+
export type { StopDeps };
|
package/src/lib/agents/models.ts
CHANGED
|
@@ -52,6 +52,15 @@ export function registerDroidReasoningOptions(
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
export function resetRegisteredReasoningOptions(): void {
|
|
56
|
+
Object.keys(codexReasoningLevelsByModel).forEach((model) => {
|
|
57
|
+
delete codexReasoningLevelsByModel[model];
|
|
58
|
+
});
|
|
59
|
+
Object.keys(droidReasoningLevelsByModel).forEach((model) => {
|
|
60
|
+
delete droidReasoningLevelsByModel[model];
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
export function getDroidReasoningOptions(model: string): ReasoningLevel[] {
|
|
56
65
|
const levels = droidReasoningLevelsByModel[model];
|
|
57
66
|
return levels ? [...levels] : [];
|
|
@@ -325,6 +325,20 @@ export async function runReviewSession(
|
|
|
325
325
|
if (entry.type === "review_iteration") {
|
|
326
326
|
latestPersistedFindings = [...latestPersistedFindings, ...entry.findings];
|
|
327
327
|
completedReviewIterations = entry.iteration;
|
|
328
|
+
|
|
329
|
+
if (worktree && latestPersistedFindings.length > 0) {
|
|
330
|
+
await deps.saveFindingsArtifact(
|
|
331
|
+
CONFIG_DIR,
|
|
332
|
+
createFindingsArtifact(
|
|
333
|
+
sessionId,
|
|
334
|
+
projectPath,
|
|
335
|
+
sessionPath,
|
|
336
|
+
worktree,
|
|
337
|
+
latestPersistedFindings
|
|
338
|
+
)
|
|
339
|
+
);
|
|
340
|
+
shouldDeleteSessionRefs = false;
|
|
341
|
+
}
|
|
328
342
|
}
|
|
329
343
|
};
|
|
330
344
|
|
package/src/lib/stop-session.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { discardSessionWorktree, type GitSessionWorktree } from "@/lib/git";
|
|
2
|
-
import { deleteSessionFiles, readLog } from "@/lib/logger";
|
|
2
|
+
import { appendLog, deleteSessionFiles, readLog } from "@/lib/logger";
|
|
3
3
|
import {
|
|
4
4
|
type ActiveSession,
|
|
5
5
|
readSessionState,
|
|
@@ -27,6 +27,7 @@ interface WaitForGracefulStopDeps {
|
|
|
27
27
|
|
|
28
28
|
interface StopActiveSessionDeps {
|
|
29
29
|
readLog: typeof readLog;
|
|
30
|
+
appendLog: typeof appendLog;
|
|
30
31
|
deleteSessionFiles: typeof deleteSessionFiles;
|
|
31
32
|
updateSessionState: typeof updateSessionState;
|
|
32
33
|
sendInterrupt: typeof sendInterrupt;
|
|
@@ -41,6 +42,7 @@ interface StopActiveSessionDeps {
|
|
|
41
42
|
|
|
42
43
|
const DEFAULT_STOP_ACTIVE_SESSION_DEPS: StopActiveSessionDeps = {
|
|
43
44
|
readLog,
|
|
45
|
+
appendLog,
|
|
44
46
|
deleteSessionFiles,
|
|
45
47
|
updateSessionState,
|
|
46
48
|
sendInterrupt,
|
|
@@ -137,6 +139,7 @@ interface SessionIterationState {
|
|
|
137
139
|
hasRecordedIteration: boolean;
|
|
138
140
|
hasRecordedReviewProgress: boolean;
|
|
139
141
|
hasSuccessfulReviewIteration: boolean;
|
|
142
|
+
entries: LogEntry[];
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
async function resolveSessionIterationState(
|
|
@@ -153,12 +156,58 @@ async function resolveSessionIterationState(
|
|
|
153
156
|
hasRecordedIteration: hasRecordedIteration(entries),
|
|
154
157
|
hasRecordedReviewProgress: hasRecordedReviewProgress(entries),
|
|
155
158
|
hasSuccessfulReviewIteration: hasSuccessfulReviewIteration(entries),
|
|
159
|
+
entries,
|
|
156
160
|
};
|
|
157
161
|
} catch {
|
|
158
162
|
return null;
|
|
159
163
|
}
|
|
160
164
|
}
|
|
161
165
|
|
|
166
|
+
function getLatestLifecycleEntry(entries: LogEntry[]): LogEntry | undefined {
|
|
167
|
+
return [...entries].reverse().find((entry) => entry.type !== "handoff");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function getLatestReviewIteration(entries: LogEntry[]) {
|
|
171
|
+
return entries.filter((entry) => entry.type === "review_iteration").at(-1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getAccumulatedReviewFindings(entries: LogEntry[]) {
|
|
175
|
+
return entries.flatMap((entry) => (entry.type === "review_iteration" ? entry.findings : []));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function terminalizeForceStoppedReviewSession(
|
|
179
|
+
session: ActiveSession,
|
|
180
|
+
iterationState: SessionIterationState | null,
|
|
181
|
+
deps: StopActiveSessionDeps
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
if (!session.sessionPath || !iterationState?.hasRecordedReviewProgress) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (getLatestLifecycleEntry(iterationState.entries)?.type === "session_end") {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const latestReviewIteration = getLatestReviewIteration(iterationState.entries);
|
|
192
|
+
if (!latestReviewIteration) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await deps.appendLog(session.sessionPath, {
|
|
197
|
+
type: "session_end",
|
|
198
|
+
timestamp: Date.now(),
|
|
199
|
+
status: "interrupted",
|
|
200
|
+
reason: "Review stopped by user.",
|
|
201
|
+
iterations: latestReviewIteration.iteration,
|
|
202
|
+
phase: "review",
|
|
203
|
+
sessionStatus: "interrupted",
|
|
204
|
+
reviewOutcome:
|
|
205
|
+
getAccumulatedReviewFindings(iterationState.entries).length > 0
|
|
206
|
+
? "findings-pending"
|
|
207
|
+
: "incomplete",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
162
211
|
function createCleanupWorktree(
|
|
163
212
|
session: ActiveSession,
|
|
164
213
|
sourceRepoPath: string
|
|
@@ -239,6 +288,15 @@ export async function stopActiveSession(
|
|
|
239
288
|
}
|
|
240
289
|
|
|
241
290
|
const finalIterationState = await resolveSessionIterationState(session, stopDeps);
|
|
291
|
+
let terminalizeSessionError: unknown;
|
|
292
|
+
if (!stoppedGracefully) {
|
|
293
|
+
try {
|
|
294
|
+
await terminalizeForceStoppedReviewSession(session, finalIterationState, stopDeps);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
terminalizeSessionError = error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
242
300
|
if (finalIterationState?.hasSuccessfulReviewIteration === false) {
|
|
243
301
|
cleanupUnpromotedSessionWorktree(session, stopDeps);
|
|
244
302
|
}
|
|
@@ -256,6 +314,10 @@ export async function stopActiveSession(
|
|
|
256
314
|
expectedSessionId: session.sessionId,
|
|
257
315
|
});
|
|
258
316
|
|
|
317
|
+
if (terminalizeSessionError) {
|
|
318
|
+
throw terminalizeSessionError;
|
|
319
|
+
}
|
|
320
|
+
|
|
259
321
|
if (deleteSessionFilesError) {
|
|
260
322
|
throw deleteSessionFilesError;
|
|
261
323
|
}
|
|
@@ -17,9 +17,11 @@ import { StatusBar } from "./StatusBar";
|
|
|
17
17
|
import { useDashboardRunControl } from "./use-dashboard-run-control";
|
|
18
18
|
import { useDashboardStopControl } from "./use-dashboard-stop-control";
|
|
19
19
|
|
|
20
|
-
export function Dashboard({ projectPath, branch, refreshInterval = 1000 }: DashboardProps) {
|
|
20
|
+
export function Dashboard({ projectPath, branch, refreshInterval = 1000, deps }: DashboardProps) {
|
|
21
21
|
const renderer = useRenderer();
|
|
22
|
-
const
|
|
22
|
+
const resolvedUseWorkspaceState = deps?.useWorkspaceState ?? useWorkspaceState;
|
|
23
|
+
const ResolvedDashboardOverlays = deps?.DashboardOverlays ?? DashboardOverlays;
|
|
24
|
+
const state = resolvedUseWorkspaceState(projectPath, branch, refreshInterval);
|
|
23
25
|
const {
|
|
24
26
|
runError,
|
|
25
27
|
startupMode,
|
|
@@ -29,7 +31,7 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000 }: Dashb
|
|
|
29
31
|
spawnRunProcess,
|
|
30
32
|
spawnFixProcess,
|
|
31
33
|
isStartupSpawning,
|
|
32
|
-
} = useDashboardRunControl(projectPath);
|
|
34
|
+
} = useDashboardRunControl(projectPath, { spawn: deps?.spawn });
|
|
33
35
|
const [focusedPane, setFocusedPane] = useState<FocusedPane>("detail");
|
|
34
36
|
const [outputVisible, setOutputVisible] = useState(false);
|
|
35
37
|
const [showHelp, setShowHelp] = useState(false);
|
|
@@ -37,11 +39,14 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000 }: Dashb
|
|
|
37
39
|
const [showSession, setShowSession] = useState(false);
|
|
38
40
|
const [showReviewModeOverlay, setShowReviewModeOverlay] = useState(false);
|
|
39
41
|
const [showStopPicker, setShowStopPicker] = useState(false);
|
|
40
|
-
const { isStoppingRun, stopSelectedSession } = useDashboardStopControl(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
const { isStoppingRun, stopSelectedSession } = useDashboardStopControl(
|
|
43
|
+
{
|
|
44
|
+
currentSession: state.currentSession,
|
|
45
|
+
setShowStopPicker,
|
|
46
|
+
onError: setRunError,
|
|
47
|
+
},
|
|
48
|
+
{ stopActiveSession: deps?.stopActiveSession }
|
|
49
|
+
);
|
|
45
50
|
|
|
46
51
|
const projectName = basename(projectPath);
|
|
47
52
|
const isExitingRef = useRef(false);
|
|
@@ -271,7 +276,7 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000 }: Dashb
|
|
|
271
276
|
liveRefreshError={state.liveRefreshError}
|
|
272
277
|
configWarning={state.configWarning}
|
|
273
278
|
/>
|
|
274
|
-
<
|
|
279
|
+
<ResolvedDashboardOverlays
|
|
275
280
|
showHelp={showHelp}
|
|
276
281
|
showRunOverlay={showRunOverlay}
|
|
277
282
|
showFixFindings={showFixFindings}
|
|
@@ -1023,7 +1023,7 @@ export function ReviewModeOverlay({
|
|
|
1023
1023
|
width={textareaWidth}
|
|
1024
1024
|
height={isWideOptionsLayout ? 5 : 4}
|
|
1025
1025
|
wrapMode="word"
|
|
1026
|
-
keyBindings={[{ name: "return",
|
|
1026
|
+
keyBindings={[{ name: "return", ctrl: true, action: "submit" }]}
|
|
1027
1027
|
onSubmit={() => {
|
|
1028
1028
|
submitWithOptions();
|
|
1029
1029
|
}}
|
|
@@ -1090,7 +1090,7 @@ export function ReviewModeOverlay({
|
|
|
1090
1090
|
function renderOptions() {
|
|
1091
1091
|
const isPriorityFocusActive = optionsFocus === "execution-auto-priority";
|
|
1092
1092
|
const isForceControlActive = optionsFocus === "force-max-iterations";
|
|
1093
|
-
const reviewStartKeyLabel = isCustomInstructionsFocused ? "[
|
|
1093
|
+
const reviewStartKeyLabel = isCustomInstructionsFocused ? "[Ctrl+Enter]" : "[Enter]";
|
|
1094
1094
|
|
|
1095
1095
|
return (
|
|
1096
1096
|
<box flexDirection="column" gap={0}>
|
|
@@ -15,10 +15,18 @@ export interface DashboardRunControl {
|
|
|
15
15
|
isStartupSpawning: () => boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
interface DashboardRunControlDeps {
|
|
19
|
+
spawn?: typeof Bun.spawn;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useDashboardRunControl(
|
|
23
|
+
projectPath: string,
|
|
24
|
+
deps?: DashboardRunControlDeps
|
|
25
|
+
): DashboardRunControl {
|
|
19
26
|
const [runError, setRunError] = useState<string | null>(null);
|
|
20
27
|
const [startupMode, setStartupMode] = useState<DashboardStartupMode>(null);
|
|
21
28
|
const isStartupSpawningRef = useRef(false);
|
|
29
|
+
const spawn = deps?.spawn ?? Bun.spawn;
|
|
22
30
|
|
|
23
31
|
const clearRunError = useCallback(() => {
|
|
24
32
|
setRunError(null);
|
|
@@ -40,7 +48,7 @@ export function useDashboardRunControl(projectPath: string): DashboardRunControl
|
|
|
40
48
|
setStartupMode(nextStartupMode);
|
|
41
49
|
|
|
42
50
|
try {
|
|
43
|
-
const subprocess =
|
|
51
|
+
const subprocess = spawn([process.execPath, CLI_PATH, command, ...argv], {
|
|
44
52
|
cwd: projectPath,
|
|
45
53
|
stdin: "ignore",
|
|
46
54
|
stdout: "pipe",
|
|
@@ -73,7 +81,7 @@ export function useDashboardRunControl(projectPath: string): DashboardRunControl
|
|
|
73
81
|
setRunError(getErrorMessage(error));
|
|
74
82
|
}
|
|
75
83
|
},
|
|
76
|
-
[projectPath]
|
|
84
|
+
[projectPath, spawn]
|
|
77
85
|
);
|
|
78
86
|
|
|
79
87
|
const spawnRunProcess = useCallback(
|
|
@@ -22,13 +22,17 @@ export interface DashboardStopControl {
|
|
|
22
22
|
stopSelectedSession: (session: ActiveSession) => Promise<void>;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
interface DashboardStopControlDeps {
|
|
26
|
+
stopActiveSession?: typeof stopActiveSession;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useDashboardStopControl(
|
|
30
|
+
options: DashboardStopControlOptions,
|
|
31
|
+
deps?: DashboardStopControlDeps
|
|
32
|
+
): DashboardStopControl {
|
|
30
33
|
const [stoppingSession, setStoppingSession] = useState<StoppingSessionState | null>(null);
|
|
31
34
|
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
35
|
+
const stopSession = deps?.stopActiveSession ?? stopActiveSession;
|
|
32
36
|
|
|
33
37
|
const clearSettleTimer = useMemo(
|
|
34
38
|
() => () => {
|
|
@@ -42,7 +46,10 @@ export function useDashboardStopControl({
|
|
|
42
46
|
|
|
43
47
|
if (
|
|
44
48
|
stoppingSession &&
|
|
45
|
-
shouldClearStoppingSessionState({
|
|
49
|
+
shouldClearStoppingSessionState({
|
|
50
|
+
marker: stoppingSession,
|
|
51
|
+
currentSession: options.currentSession,
|
|
52
|
+
})
|
|
46
53
|
) {
|
|
47
54
|
clearSettleTimer();
|
|
48
55
|
setStoppingSession(null);
|
|
@@ -57,8 +64,8 @@ export function useDashboardStopControl({
|
|
|
57
64
|
|
|
58
65
|
try {
|
|
59
66
|
await stopSelectedDashboardSession(session, {
|
|
60
|
-
setShowStopPicker,
|
|
61
|
-
stopActiveSession,
|
|
67
|
+
setShowStopPicker: options.setShowStopPicker,
|
|
68
|
+
stopActiveSession: stopSession,
|
|
62
69
|
});
|
|
63
70
|
|
|
64
71
|
const settled = settleStoppingSessionState(createStoppingSessionState(session));
|
|
@@ -78,10 +85,10 @@ export function useDashboardStopControl({
|
|
|
78
85
|
} catch (error) {
|
|
79
86
|
clearSettleTimer();
|
|
80
87
|
setStoppingSession(null);
|
|
81
|
-
onError(getErrorMessage(error));
|
|
88
|
+
options.onError(getErrorMessage(error));
|
|
82
89
|
}
|
|
83
90
|
},
|
|
84
|
-
[clearSettleTimer, onError, setShowStopPicker]
|
|
91
|
+
[clearSettleTimer, options.onError, options.setShowStopPicker, stopSession]
|
|
85
92
|
);
|
|
86
93
|
|
|
87
94
|
return {
|
|
@@ -1,5 +1,15 @@
|
|
|
1
|
+
import type { stopActiveSession } from "@/lib/stop-session";
|
|
2
|
+
import type { DashboardOverlays } from "@/lib/tui/dashboard/DashboardOverlays";
|
|
3
|
+
import type { useWorkspaceState } from "@/lib/tui/workspace/use-workspace-state";
|
|
4
|
+
|
|
1
5
|
export interface DashboardProps {
|
|
2
6
|
projectPath: string;
|
|
3
7
|
branch?: string;
|
|
4
8
|
refreshInterval?: number;
|
|
9
|
+
deps?: {
|
|
10
|
+
useWorkspaceState?: typeof useWorkspaceState;
|
|
11
|
+
DashboardOverlays?: typeof DashboardOverlays;
|
|
12
|
+
spawn?: typeof Bun.spawn;
|
|
13
|
+
stopActiveSession?: typeof stopActiveSession;
|
|
14
|
+
};
|
|
5
15
|
}
|
|
@@ -2,7 +2,7 @@ import { basename } from "node:path";
|
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { loadEffectiveConfig } from "@/lib/config";
|
|
4
4
|
import { ensureGitRepositoryAsync } from "@/lib/git";
|
|
5
|
-
import type { LogIncrementalState } from "@/lib/logger";
|
|
5
|
+
import type { LogIncrementalState, LogSession } from "@/lib/logger";
|
|
6
6
|
import {
|
|
7
7
|
computeProjectStats,
|
|
8
8
|
computeSessionStats,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
listProjectLogSessions,
|
|
12
12
|
readLogIncremental,
|
|
13
13
|
} from "@/lib/logger";
|
|
14
|
-
import type { ActiveSession } from "@/lib/session-state";
|
|
14
|
+
import type { ActiveSession, SessionState } from "@/lib/session-state";
|
|
15
15
|
import {
|
|
16
16
|
getLatestProjectActiveSession,
|
|
17
17
|
listAllActiveSessions,
|
|
@@ -35,7 +35,48 @@ import {
|
|
|
35
35
|
import type { SessionGroupData, WorkspaceState } from "./workspace-types";
|
|
36
36
|
|
|
37
37
|
const DEFAULT_REFRESH_INTERVAL = 1000;
|
|
38
|
-
|
|
38
|
+
|
|
39
|
+
export interface WorkspaceStateDeps {
|
|
40
|
+
loadEffectiveConfig: typeof loadEffectiveConfig;
|
|
41
|
+
ensureGitRepositoryAsync: typeof ensureGitRepositoryAsync;
|
|
42
|
+
listAllActiveSessions: typeof listAllActiveSessions;
|
|
43
|
+
listProjectActiveSessions: typeof listProjectActiveSessions;
|
|
44
|
+
getLatestProjectActiveSession: (
|
|
45
|
+
storageRoot: string | undefined,
|
|
46
|
+
projectPath: string
|
|
47
|
+
) => Promise<SessionState | null>;
|
|
48
|
+
getLatestProjectLogSession: (
|
|
49
|
+
storageRoot: string | undefined,
|
|
50
|
+
projectPath: string
|
|
51
|
+
) => Promise<LogSession | null>;
|
|
52
|
+
readLogIncremental: typeof readLogIncremental;
|
|
53
|
+
listProjectLogSessions: typeof listProjectLogSessions;
|
|
54
|
+
computeSessionStats: typeof computeSessionStats;
|
|
55
|
+
computeProjectStats: typeof computeProjectStats;
|
|
56
|
+
getProjectName: typeof getProjectName;
|
|
57
|
+
shouldCaptureTmux: typeof shouldCaptureTmux;
|
|
58
|
+
getSessionOutput: (sessionName: string, lines: number) => Promise<string>;
|
|
59
|
+
computeNextTmuxCaptureInterval: typeof computeNextTmuxCaptureInterval;
|
|
60
|
+
tmuxCaptureMinIntervalMs: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const defaultWorkspaceStateDeps: WorkspaceStateDeps = {
|
|
64
|
+
loadEffectiveConfig,
|
|
65
|
+
ensureGitRepositoryAsync,
|
|
66
|
+
listAllActiveSessions,
|
|
67
|
+
listProjectActiveSessions,
|
|
68
|
+
getLatestProjectActiveSession,
|
|
69
|
+
getLatestProjectLogSession,
|
|
70
|
+
readLogIncremental,
|
|
71
|
+
listProjectLogSessions,
|
|
72
|
+
computeSessionStats,
|
|
73
|
+
computeProjectStats,
|
|
74
|
+
getProjectName,
|
|
75
|
+
shouldCaptureTmux,
|
|
76
|
+
getSessionOutput,
|
|
77
|
+
computeNextTmuxCaptureInterval,
|
|
78
|
+
tmuxCaptureMinIntervalMs: TMUX_CAPTURE_MIN_INTERVAL_MS,
|
|
79
|
+
};
|
|
39
80
|
|
|
40
81
|
export function createInitialWorkspaceState(
|
|
41
82
|
overrides: Partial<WorkspaceState> = {}
|
|
@@ -121,7 +162,8 @@ function buildSessionGroups(
|
|
|
121
162
|
export function useWorkspaceState(
|
|
122
163
|
projectPath: string,
|
|
123
164
|
_branch?: string,
|
|
124
|
-
refreshInterval: number = DEFAULT_REFRESH_INTERVAL
|
|
165
|
+
refreshInterval: number = DEFAULT_REFRESH_INTERVAL,
|
|
166
|
+
deps: WorkspaceStateDeps = defaultWorkspaceStateDeps
|
|
125
167
|
): WorkspaceState {
|
|
126
168
|
const [state, setState] = useState<WorkspaceState>(() => createInitialWorkspaceState());
|
|
127
169
|
|
|
@@ -133,7 +175,7 @@ export function useWorkspaceState(
|
|
|
133
175
|
const lastTmuxCaptureRef = useRef(0);
|
|
134
176
|
const lastTmuxOutputRef = useRef("");
|
|
135
177
|
const lastTmuxSessionRef = useRef<string | null>(null);
|
|
136
|
-
const tmuxCaptureIntervalRef = useRef(
|
|
178
|
+
const tmuxCaptureIntervalRef = useRef(deps.tmuxCaptureMinIntervalMs);
|
|
137
179
|
const lastLiveMetaRef = useRef<LiveRefreshMeta | null>(null);
|
|
138
180
|
const logIncrementalStateRef = useRef<LogIncrementalState | undefined>(undefined);
|
|
139
181
|
const lastLogSessionPathRef = useRef<string | null>(null);
|
|
@@ -145,12 +187,12 @@ export function useWorkspaceState(
|
|
|
145
187
|
try {
|
|
146
188
|
const [isGitRepo, allSessions, projectSessions, currentSession, logSession, configResult] =
|
|
147
189
|
await Promise.all([
|
|
148
|
-
ensureGitRepositoryAsync(projectPath),
|
|
149
|
-
listAllActiveSessions(),
|
|
150
|
-
listProjectActiveSessions(undefined, projectPath),
|
|
151
|
-
getLatestProjectActiveSession(undefined, projectPath),
|
|
152
|
-
getLatestProjectLogSession(undefined, projectPath),
|
|
153
|
-
loadWorkspaceConfigSafe(projectPath, loadEffectiveConfig),
|
|
190
|
+
deps.ensureGitRepositoryAsync(projectPath),
|
|
191
|
+
deps.listAllActiveSessions(),
|
|
192
|
+
deps.listProjectActiveSessions(undefined, projectPath),
|
|
193
|
+
deps.getLatestProjectActiveSession(undefined, projectPath),
|
|
194
|
+
deps.getLatestProjectLogSession(undefined, projectPath),
|
|
195
|
+
loadWorkspaceConfigSafe(projectPath, deps.loadEffectiveConfig),
|
|
154
196
|
]);
|
|
155
197
|
|
|
156
198
|
const sessionGroups = buildSessionGroups(allSessions, projectPath);
|
|
@@ -172,7 +214,7 @@ export function useWorkspaceState(
|
|
|
172
214
|
|
|
173
215
|
if (logPath) {
|
|
174
216
|
const logSessionChanged = logPath !== lastLogSessionPathRef.current;
|
|
175
|
-
const incrementalResult = await readLogIncremental(
|
|
217
|
+
const incrementalResult = await deps.readLogIncremental(
|
|
176
218
|
logPath,
|
|
177
219
|
logSessionChanged ? undefined : logIncrementalStateRef.current
|
|
178
220
|
);
|
|
@@ -209,11 +251,14 @@ export function useWorkspaceState(
|
|
|
209
251
|
let projectStats: ProjectStats | null = null;
|
|
210
252
|
|
|
211
253
|
if (!currentSession) {
|
|
212
|
-
const projectLogSessions = await listProjectLogSessions(undefined, projectPath);
|
|
254
|
+
const projectLogSessions = await deps.listProjectLogSessions(undefined, projectPath);
|
|
213
255
|
const latestSession = projectLogSessions[0];
|
|
214
256
|
if (latestSession) {
|
|
215
|
-
lastSessionStats = await computeSessionStats(latestSession);
|
|
216
|
-
projectStats = await computeProjectStats(
|
|
257
|
+
lastSessionStats = await deps.computeSessionStats(latestSession);
|
|
258
|
+
projectStats = await deps.computeProjectStats(
|
|
259
|
+
deps.getProjectName(projectPath),
|
|
260
|
+
projectLogSessions
|
|
261
|
+
);
|
|
217
262
|
}
|
|
218
263
|
}
|
|
219
264
|
|
|
@@ -261,14 +306,14 @@ export function useWorkspaceState(
|
|
|
261
306
|
} finally {
|
|
262
307
|
isHeavyRefreshingRef.current = false;
|
|
263
308
|
}
|
|
264
|
-
}, [projectPath]);
|
|
309
|
+
}, [deps, projectPath]);
|
|
265
310
|
|
|
266
311
|
const refreshLive = useCallback(async () => {
|
|
267
312
|
if (isLiveRefreshingRef.current) return;
|
|
268
313
|
isLiveRefreshingRef.current = true;
|
|
269
314
|
|
|
270
315
|
try {
|
|
271
|
-
const currentSession = await getLatestProjectActiveSession(undefined, projectPath);
|
|
316
|
+
const currentSession = await deps.getLatestProjectActiveSession(undefined, projectPath);
|
|
272
317
|
|
|
273
318
|
let tmuxOutput = lastTmuxOutputRef.current;
|
|
274
319
|
const liveMeta = getLiveRefreshMeta(currentSession);
|
|
@@ -281,10 +326,10 @@ export function useWorkspaceState(
|
|
|
281
326
|
lastTmuxOutputRef.current = "";
|
|
282
327
|
lastTmuxSessionRef.current = null;
|
|
283
328
|
lastTmuxCaptureRef.current = 0;
|
|
284
|
-
tmuxCaptureIntervalRef.current =
|
|
329
|
+
tmuxCaptureIntervalRef.current = deps.tmuxCaptureMinIntervalMs;
|
|
285
330
|
} else {
|
|
286
331
|
const sessionChanged = sessionName !== lastTmuxSessionRef.current;
|
|
287
|
-
const shouldCapture = shouldCaptureTmux({
|
|
332
|
+
const shouldCapture = deps.shouldCaptureTmux({
|
|
288
333
|
sessionChanged,
|
|
289
334
|
liveMetaChanged,
|
|
290
335
|
now,
|
|
@@ -293,14 +338,14 @@ export function useWorkspaceState(
|
|
|
293
338
|
});
|
|
294
339
|
|
|
295
340
|
if (shouldCapture) {
|
|
296
|
-
const capturedOutput = await getSessionOutput(sessionName, 1000);
|
|
341
|
+
const capturedOutput = await deps.getSessionOutput(sessionName, 1000);
|
|
297
342
|
const nextOutput = capturedOutput || (sessionChanged ? "" : lastTmuxOutputRef.current);
|
|
298
343
|
const outputChanged = nextOutput !== lastTmuxOutputRef.current;
|
|
299
344
|
tmuxOutput = nextOutput;
|
|
300
345
|
lastTmuxOutputRef.current = tmuxOutput;
|
|
301
346
|
lastTmuxSessionRef.current = sessionName;
|
|
302
347
|
lastTmuxCaptureRef.current = now;
|
|
303
|
-
tmuxCaptureIntervalRef.current = computeNextTmuxCaptureInterval({
|
|
348
|
+
tmuxCaptureIntervalRef.current = deps.computeNextTmuxCaptureInterval({
|
|
304
349
|
sessionChanged,
|
|
305
350
|
liveMetaChanged,
|
|
306
351
|
outputChanged,
|
|
@@ -329,7 +374,7 @@ export function useWorkspaceState(
|
|
|
329
374
|
} finally {
|
|
330
375
|
isLiveRefreshingRef.current = false;
|
|
331
376
|
}
|
|
332
|
-
}, [projectPath]);
|
|
377
|
+
}, [deps, projectPath]);
|
|
333
378
|
|
|
334
379
|
useEffect(() => {
|
|
335
380
|
void refreshHeavy();
|
|
@@ -345,9 +390,9 @@ export function useWorkspaceState(
|
|
|
345
390
|
}, [refreshLive]);
|
|
346
391
|
|
|
347
392
|
useEffect(() => {
|
|
348
|
-
const interval = setInterval(refreshLive,
|
|
393
|
+
const interval = setInterval(refreshLive, deps.tmuxCaptureMinIntervalMs);
|
|
349
394
|
return () => clearInterval(interval);
|
|
350
|
-
}, [refreshLive]);
|
|
395
|
+
}, [deps.tmuxCaptureMinIntervalMs, refreshLive]);
|
|
351
396
|
|
|
352
397
|
return state;
|
|
353
398
|
}
|