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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-review",
3
- "version": "0.2.3",
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
  },
@@ -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 = createInteractiveCommandDeps();
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 = process.cwd();
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
- p.log.info(NO_PENDING_HANDOFFS_MESSAGE);
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
- p.log.step(`Applying handoff: ${selection.handoff.handoffId}`);
79
+ applyDeps.logStep(`Applying handoff: ${selection.handoff.handoffId}`);
63
80
 
64
81
  try {
65
- const artifact = await applyPendingHandoff(undefined, projectPath, selection.handoff.handoffId);
66
- await appendLog(artifact.logPath, {
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
- p.log.success("Review handoff applied.");
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
+ }
@@ -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
- p.log.info("No active review sessions.");
47
+ listDeps.logInfo("No active review sessions.");
33
48
  } else {
34
- p.log.info("Active review sessions:");
49
+ listDeps.logInfo("Active review sessions:");
35
50
  for (const session of activeSessions) {
36
51
  const worktree = session.worktreeBranch ? ` ${session.worktreeBranch}` : "";
37
- console.log(
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
- console.log(`${sessionName} (tmux only)`);
57
+ listDeps.print(`${sessionName} (tmux only)`);
43
58
  }
44
59
  }
45
60
  }
61
+
62
+ export type { ListDeps };
@@ -1,13 +1,34 @@
1
1
  import { getGitBranch } from "@/lib/logger";
2
2
 
3
- export async function runStatus(): Promise<void> {
4
- const projectPath = process.cwd();
5
- const branch = await getGitBranch(projectPath);
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
- await renderDashboard({
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 };
@@ -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 = createInteractiveCommandDeps();
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 p.select({
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 (p.isCancel(selection)) {
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
- p.log.step(`Stopping session: ${session.sessionName}`);
256
- const handoffNote = await stopSessionWithHandoff(session, process.cwd());
257
- p.log.success("Review stopped.");
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
- p.log.message(`Handoff:\n${handoffNote}`);
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 = process.cwd();
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
- p.log.info("No active review sessions.");
274
- await removeAllSessionStates();
317
+ deps.logInfo("No active review sessions.");
318
+ await deps.removeAllSessionStates();
275
319
  return;
276
320
  }
277
321
 
278
- p.log.step(`Stopping ${sessionNames.length} session(s)...`);
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 new Promise<void>((resolve) => {
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
- p.log.message(` Stopped: ${sessionName}`);
351
+ deps.logMessage(` Stopped: ${sessionName}`);
310
352
  }
311
353
 
312
354
  for (const stoppedSession of stoppedActiveSessions) {
313
355
  if (stoppedSession.handoffNote) {
314
- p.log.message(`Handoff:\n${stoppedSession.handoffNote}`);
356
+ deps.logMessage(`Handoff:\n${stoppedSession.handoffNote}`);
315
357
  }
316
358
  }
317
359
 
318
- await removeAllSessionStates();
319
- p.log.success(`Stopped ${sessionNames.length} session(s).`);
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
- p.log.info("No active review session for current working directory.");
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
- p.log.message(`\nThere are ${allSessions.length} other session(s) running.`);
350
- p.log.message(
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(process.cwd(), options.session, stopDeps);
448
+ await stopCurrentProjectSession(stopDeps.cwd(), options.session, stopDeps);
407
449
  }
450
+
451
+ export type { StopDeps };
@@ -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
 
@@ -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 state = useWorkspaceState(projectPath, branch, refreshInterval);
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
- currentSession: state.currentSession,
42
- setShowStopPicker,
43
- onError: setRunError,
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
- <DashboardOverlays
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", shift: true, action: "submit" }]}
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 ? "[Shift+Enter]" : "[Enter]";
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
- export function useDashboardRunControl(projectPath: string): DashboardRunControl {
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 = Bun.spawn([process.execPath, CLI_PATH, command, ...argv], {
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
- export function useDashboardStopControl({
26
- currentSession,
27
- setShowStopPicker,
28
- onError,
29
- }: DashboardStopControlOptions): DashboardStopControl {
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({ marker: stoppingSession, currentSession })
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
- const LIVE_REFRESH_INTERVAL = TMUX_CAPTURE_MIN_INTERVAL_MS;
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(TMUX_CAPTURE_MIN_INTERVAL_MS);
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(getProjectName(projectPath), projectLogSessions);
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 = TMUX_CAPTURE_MIN_INTERVAL_MS;
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, LIVE_REFRESH_INTERVAL);
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
  }