ralph-review 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +123 -16
  2. package/package.json +6 -4
  3. package/src/cli-core.ts +51 -88
  4. package/src/cli-rrr.ts +1 -4
  5. package/src/cli.ts +1 -2
  6. package/src/commands/apply.ts +6 -14
  7. package/src/commands/config-handlers.ts +68 -69
  8. package/src/commands/config-model.ts +147 -125
  9. package/src/commands/doctor.ts +2 -4
  10. package/src/commands/fix.ts +73 -51
  11. package/src/commands/handoff-selection.ts +6 -8
  12. package/src/commands/interactive-deps.ts +18 -0
  13. package/src/commands/log.ts +12 -12
  14. package/src/commands/run.ts +32 -33
  15. package/src/commands/stop.ts +6 -13
  16. package/src/commands/update.ts +2 -4
  17. package/src/lib/agents/claude.ts +4 -16
  18. package/src/lib/agents/core.ts +16 -0
  19. package/src/lib/agents/droid.ts +4 -15
  20. package/src/lib/cli-parser.ts +19 -14
  21. package/src/lib/handoff.ts +16 -7
  22. package/src/lib/logging/session-log.ts +2 -1
  23. package/src/lib/prompts/defaults/review.md +1 -1
  24. package/src/lib/prompts/protocol.ts +2 -1
  25. package/src/lib/review-workflow/findings/artifact.ts +3 -1
  26. package/src/lib/review-workflow/findings/types.ts +1 -1
  27. package/src/lib/review-workflow/remediation/prompt.ts +7 -7
  28. package/src/lib/review-workflow/remediation/run-batch-fix-phase.ts +30 -20
  29. package/src/lib/review-workflow/remediation/run-fix-session.ts +70 -68
  30. package/src/lib/review-workflow/results/finalize-result.ts +20 -3
  31. package/src/lib/review-workflow/run-review-cycle.ts +1 -12
  32. package/src/lib/review-workflow/session-status.ts +13 -0
  33. package/src/lib/review-workflow/shared/framed-json.ts +2 -47
  34. package/src/lib/session/state.ts +50 -38
  35. package/src/lib/structured-output.ts +24 -9
  36. package/src/lib/tui/dashboard/HelpOverlay.tsx +13 -57
  37. package/src/lib/tui/dashboard/StatusBar.tsx +12 -50
  38. package/src/lib/tui/dashboard/StopSessionPickerOverlay.tsx +4 -22
  39. package/src/lib/tui/sessions/detail/DetailPane.tsx +6 -64
  40. package/src/lib/tui/sessions/detail/IdleStateView.tsx +10 -12
  41. package/src/lib/tui/sessions/detail/SessionDetailView.tsx +1 -1
  42. package/src/lib/tui/sessions/detail/session-detail-parts.tsx +66 -87
  43. package/src/lib/tui/sessions/history/SessionListOverlay.tsx +17 -75
  44. package/src/lib/tui/sessions/review-summary-parser.ts +2 -68
  45. package/src/lib/tui/shared/CenteredModal.tsx +44 -0
  46. package/src/lib/tui/shared/KeyboardShortcutsModal.tsx +14 -0
  47. package/src/lib/tui/shared/ShortcutHint.tsx +33 -0
  48. package/src/lib/tui/workspace/Workspace.tsx +6 -91
  49. package/src/lib/tui/workspace/use-workspace-state.ts +44 -37
  50. package/src/lib/types/fix.ts +15 -48
  51. package/src/lib/types/guards.ts +47 -0
  52. package/src/lib/types/review.ts +5 -39
@@ -56,7 +56,9 @@ export interface RunFixSessionDependencies {
56
56
  discardSessionWorktree: typeof discardSessionWorktree;
57
57
  }
58
58
 
59
- async function defaultPromptForSelection(artifact: FindingsArtifact): Promise<FindingId[] | null> {
59
+ export async function promptForFixSelection(
60
+ artifact: FindingsArtifact
61
+ ): Promise<FindingId[] | null> {
60
62
  const selection = await p.multiselect({
61
63
  message: "Choose findings to fix",
62
64
  options: artifact.findings.map((finding) => ({
@@ -80,7 +82,7 @@ const DEFAULT_RUN_FIX_SESSION_DEPENDENCIES: RunFixSessionDependencies = {
80
82
  createSessionWorktreeAt,
81
83
  updateSelection,
82
84
  appendLog,
83
- promptForSelection: defaultPromptForSelection,
85
+ promptForSelection: promptForFixSelection,
84
86
  runBatchFixPhase,
85
87
  appendFixResults,
86
88
  updateRetainedWorktree,
@@ -123,6 +125,26 @@ function buildRetainedCleanupWorktree(
123
125
  };
124
126
  }
125
127
 
128
+ async function discardRetainedWorktree(
129
+ deps: RunFixSessionDependencies,
130
+ artifact: FindingsArtifact,
131
+ worktree: GitSessionWorktree,
132
+ retainedWorktree: RetainedSessionWorktree
133
+ ): Promise<FindingsArtifact> {
134
+ deps.discardSessionWorktree(buildRetainedCleanupWorktree(worktree, retainedWorktree));
135
+ try {
136
+ await deps.updateRetainedWorktree(
137
+ CONFIG_DIR,
138
+ artifact.projectPath,
139
+ artifact.sessionId,
140
+ undefined
141
+ );
142
+ } catch {
143
+ // Best-effort cleanup; the remediation result is still returned.
144
+ }
145
+ return artifact;
146
+ }
147
+
126
148
  function resolveSelectionMode(selector: FixSessionSelector | undefined): {
127
149
  mode: "all" | "priority" | "id" | "interactive";
128
150
  count: number;
@@ -232,6 +254,26 @@ async function emitProgress(
232
254
  await onProgress?.(updates);
233
255
  }
234
256
 
257
+ async function emitResultProgress(
258
+ onProgress: RunFixSessionOptions["onProgress"],
259
+ result: FixSessionResult,
260
+ updates: Partial<SessionState> = {}
261
+ ): Promise<void> {
262
+ await emitProgress(onProgress, {
263
+ currentPhase: result.phase,
264
+ phase: result.phase,
265
+ sessionStatus: result.sessionStatus,
266
+ currentAgent: null,
267
+ selectedFindingIds: result.selection.selectedFindingIds,
268
+ reviewOutcome: result.reviewOutcome,
269
+ handoffStatus: result.handoffStatus,
270
+ handoffId: result.handoffId,
271
+ handoffUpdatedAt: result.handoffUpdatedAt,
272
+ commitSha: result.commitSha,
273
+ ...updates,
274
+ });
275
+ }
276
+
235
277
  export async function runFixSession(
236
278
  config: Config,
237
279
  options: RunFixSessionOptions,
@@ -263,18 +305,7 @@ export async function runFixSession(
263
305
  unselectedFindings: [...artifact.findings],
264
306
  reason: resolvedSelection.error,
265
307
  });
266
- await emitProgress(options.onProgress, {
267
- currentPhase: result.phase,
268
- phase: result.phase,
269
- sessionStatus: result.sessionStatus,
270
- currentAgent: null,
271
- selectedFindingIds: result.selection.selectedFindingIds,
272
- reviewOutcome: result.reviewOutcome,
273
- handoffStatus: result.handoffStatus,
274
- handoffId: result.handoffId,
275
- handoffUpdatedAt: result.handoffUpdatedAt,
276
- commitSha: result.commitSha,
277
- });
308
+ await emitResultProgress(options.onProgress, result);
278
309
  return result;
279
310
  }
280
311
 
@@ -287,18 +318,7 @@ export async function runFixSession(
287
318
  reason: "Selection cancelled. Findings remain pending.",
288
319
  unselectedFindings: [...artifact.findings],
289
320
  });
290
- await emitProgress(options.onProgress, {
291
- currentPhase: result.phase,
292
- phase: result.phase,
293
- sessionStatus: result.sessionStatus,
294
- currentAgent: null,
295
- selectedFindingIds: result.selection.selectedFindingIds,
296
- reviewOutcome: result.reviewOutcome,
297
- handoffStatus: result.handoffStatus,
298
- handoffId: result.handoffId,
299
- handoffUpdatedAt: result.handoffUpdatedAt,
300
- commitSha: result.commitSha,
301
- });
321
+ await emitResultProgress(options.onProgress, result);
302
322
  return result;
303
323
  }
304
324
 
@@ -334,18 +354,7 @@ export async function runFixSession(
334
354
  selection: resolvedSelection.selection,
335
355
  unselectedFindings: [...artifact.findings],
336
356
  });
337
- await emitProgress(options.onProgress, {
338
- currentPhase: result.phase,
339
- phase: result.phase,
340
- sessionStatus: result.sessionStatus,
341
- currentAgent: null,
342
- selectedFindingIds: result.selection.selectedFindingIds,
343
- reviewOutcome: result.reviewOutcome,
344
- handoffStatus: result.handoffStatus,
345
- handoffId: result.handoffId,
346
- handoffUpdatedAt: result.handoffUpdatedAt,
347
- commitSha: result.commitSha,
348
- });
357
+ await emitResultProgress(options.onProgress, result);
349
358
  return result;
350
359
  }
351
360
 
@@ -412,7 +421,21 @@ export async function runFixSession(
412
421
  worktree,
413
422
  });
414
423
 
415
- if (result.reviewOutcome === "incomplete") {
424
+ if (result.handoffStatus) {
425
+ if (artifactWithFixResults.retainedWorktree) {
426
+ const artifactWithoutRetainedWorktree = await discardRetainedWorktree(
427
+ deps,
428
+ artifact,
429
+ worktree,
430
+ artifactWithFixResults.retainedWorktree
431
+ );
432
+ artifactForResult = artifactWithoutRetainedWorktree;
433
+ result = {
434
+ ...result,
435
+ artifact: artifactWithoutRetainedWorktree,
436
+ };
437
+ }
438
+ } else if (result.reviewOutcome === "incomplete") {
416
439
  try {
417
440
  retainedWorktree = deps.finalizeSessionWorktree(worktree) ?? undefined;
418
441
  } catch {
@@ -435,14 +458,11 @@ export async function runFixSession(
435
458
  };
436
459
  }
437
460
  } else if (artifactWithFixResults.retainedWorktree) {
438
- deps.discardSessionWorktree(
439
- buildRetainedCleanupWorktree(worktree, artifactWithFixResults.retainedWorktree)
440
- );
441
- const artifactWithoutRetainedWorktree = await deps.updateRetainedWorktree(
442
- CONFIG_DIR,
443
- artifact.projectPath,
444
- artifact.sessionId,
445
- undefined
461
+ const artifactWithoutRetainedWorktree = await discardRetainedWorktree(
462
+ deps,
463
+ artifact,
464
+ worktree,
465
+ artifactWithFixResults.retainedWorktree
446
466
  );
447
467
  artifactForResult = artifactWithoutRetainedWorktree;
448
468
  result = {
@@ -451,17 +471,7 @@ export async function runFixSession(
451
471
  };
452
472
  }
453
473
 
454
- await emitProgress(options.onProgress, {
455
- currentPhase: result.phase,
456
- phase: result.phase,
457
- sessionStatus: result.sessionStatus,
458
- currentAgent: null,
459
- selectedFindingIds: result.selection.selectedFindingIds,
460
- reviewOutcome: result.reviewOutcome,
461
- handoffStatus: result.handoffStatus,
462
- handoffId: result.handoffId,
463
- handoffUpdatedAt: result.handoffUpdatedAt,
464
- commitSha: result.commitSha,
474
+ await emitResultProgress(options.onProgress, result, {
465
475
  worktreeProjectPath: result.retainedWorktree?.worktreeProjectPath,
466
476
  worktreeBranch: result.retainedWorktree?.worktreeBranch,
467
477
  worktreeMergeReady: result.retainedWorktree?.mergeReady,
@@ -491,17 +501,9 @@ export async function runFixSession(
491
501
  (finding) => !selection.selectedFindingIds.includes(finding.id)
492
502
  ) ?? [],
493
503
  });
494
- await emitProgress(options.onProgress, {
504
+ await emitResultProgress(options.onProgress, result, {
495
505
  currentPhase: phase,
496
- phase: phase,
497
- sessionStatus: result.sessionStatus,
498
- currentAgent: null,
499
- selectedFindingIds: result.selection.selectedFindingIds,
500
- reviewOutcome: result.reviewOutcome,
501
- handoffStatus: result.handoffStatus,
502
- handoffId: result.handoffId,
503
- handoffUpdatedAt: result.handoffUpdatedAt,
504
- commitSha: result.commitSha,
506
+ phase,
505
507
  worktreeProjectPath: retainedWorktree?.worktreeProjectPath,
506
508
  worktreeBranch: retainedWorktree?.worktreeBranch,
507
509
  worktreeMergeReady: retainedWorktree?.mergeReady,
@@ -24,6 +24,9 @@ const DEFAULT_FINALIZE_RESULT_DEPENDENCIES: FinalizeResultDependencies = {
24
24
  appendLog,
25
25
  };
26
26
 
27
+ const MIXED_UNRESOLVED_REASON =
28
+ "Some selected findings were resolved, but others remain unresolved. Ralph retained the remediation worktree instead of creating a handoff because the partial edits may be unsafe to apply automatically.";
29
+
27
30
  export async function finalizeResult(
28
31
  input: FinalizeResultInput,
29
32
  deps: FinalizeResultDependencies = DEFAULT_FINALIZE_RESULT_DEPENDENCIES
@@ -39,13 +42,27 @@ export async function finalizeResult(
39
42
  const unselectedFindings = input.artifact.findings.filter(
40
43
  (finding) => !input.selection.selectedFindingIds.includes(finding.id)
41
44
  );
45
+ const hasResolvedSelectedFindings = input.fixResults.some(
46
+ (result) => result.status === "resolved"
47
+ );
48
+ const hasUnresolvedSelectedFindings = unresolvedSelectedFindings.length > 0;
49
+ const hasSkippedSelectedFindings = input.fixResults.some((result) => result.status === "skipped");
50
+ const shouldCreateHandoff = hasResolvedSelectedFindings && !hasUnresolvedSelectedFindings;
42
51
 
43
52
  let reviewOutcome: FixSessionResult["reviewOutcome"];
44
53
  let reason: string;
45
54
 
46
- if (unresolvedSelectedFindings.length > 0) {
55
+ if (hasUnresolvedSelectedFindings) {
47
56
  reviewOutcome = "incomplete";
48
- reason = "Some selected findings remain unresolved after remediation.";
57
+ reason = hasResolvedSelectedFindings
58
+ ? MIXED_UNRESOLVED_REASON
59
+ : "Some selected findings remain unresolved after remediation.";
60
+ } else if (hasResolvedSelectedFindings) {
61
+ reviewOutcome = "fixed-selected";
62
+ reason = "Selected findings were resolved by remediation.";
63
+ } else if (hasSkippedSelectedFindings) {
64
+ reviewOutcome = "fixed-selected";
65
+ reason = "Selected findings were skipped after verification.";
49
66
  } else {
50
67
  reviewOutcome = "fixed-selected";
51
68
  reason = "Selected findings were resolved by remediation.";
@@ -56,7 +73,7 @@ export async function finalizeResult(
56
73
  let handoffUpdatedAt: number | undefined;
57
74
  let commitSha: string | undefined;
58
75
 
59
- if (reviewOutcome === "fixed-selected") {
76
+ if (shouldCreateHandoff) {
60
77
  const handoff = await deps.createOrAutoApplyHandoff(undefined, {
61
78
  sessionId: input.artifact.sessionId,
62
79
  projectPath: input.artifact.projectPath,
@@ -24,6 +24,7 @@ import {
24
24
  type RunReviewSessionDependencies,
25
25
  runReviewSession,
26
26
  } from "@/lib/review-workflow/review/run-review-session";
27
+ import { mapSessionStatusToFinalStatus } from "@/lib/review-workflow/session-status";
27
28
  import { updateSessionState } from "@/lib/session";
28
29
  import { parseFixSummaryOutput, parseReviewSummaryOutput } from "@/lib/structured-output";
29
30
  import type {
@@ -203,18 +204,6 @@ function resetInterrupt(): void {
203
204
  interrupted = false;
204
205
  }
205
206
 
206
- function mapSessionStatusToFinalStatus(status: SessionStatus): CycleResult["finalStatus"] {
207
- if (status === "failed") {
208
- return "failed";
209
- }
210
-
211
- if (status === "interrupted") {
212
- return "interrupted";
213
- }
214
-
215
- return "completed";
216
- }
217
-
218
207
  function createSessionEndEntry(
219
208
  result: CycleResult | undefined,
220
209
  error: unknown,
@@ -0,0 +1,13 @@
1
+ import type { SessionEndEntry, SessionStatus } from "@/lib/types";
2
+
3
+ export function mapSessionStatusToFinalStatus(status: SessionStatus): SessionEndEntry["status"] {
4
+ if (status === "failed") {
5
+ return "failed";
6
+ }
7
+
8
+ if (status === "interrupted") {
9
+ return "interrupted";
10
+ }
11
+
12
+ return "completed";
13
+ }
@@ -1,3 +1,5 @@
1
+ import { removeTrailingCommas } from "@/lib/structured-output";
2
+
1
3
  interface ParseFramedJsonOptions<T> {
2
4
  extractedText: string | null;
3
5
  rawOutput: string;
@@ -10,53 +12,6 @@ function normalizeCandidate(candidate: string): string {
10
12
  return candidate.replace(/\r\n?/g, "\n").trim();
11
13
  }
12
14
 
13
- function removeTrailingCommas(candidate: string): string {
14
- let output = "";
15
- let inString = false;
16
- let escaped = false;
17
-
18
- for (let index = 0; index < candidate.length; index += 1) {
19
- const char = candidate[index];
20
-
21
- if (escaped) {
22
- output += char;
23
- escaped = false;
24
- continue;
25
- }
26
-
27
- if (char === "\\") {
28
- output += char;
29
- escaped = true;
30
- continue;
31
- }
32
-
33
- if (char === '"') {
34
- output += char;
35
- inString = !inString;
36
- continue;
37
- }
38
-
39
- if (inString || char !== ",") {
40
- output += char;
41
- continue;
42
- }
43
-
44
- let lookahead = index + 1;
45
- while (lookahead < candidate.length && /\s/u.test(candidate[lookahead] ?? "")) {
46
- lookahead += 1;
47
- }
48
-
49
- const nextChar = candidate[lookahead];
50
- if (nextChar === "}" || nextChar === "]") {
51
- continue;
52
- }
53
-
54
- output += char;
55
- }
56
-
57
- return output;
58
- }
59
-
60
15
  function extractFramedPayload(output: string, startToken: string, endToken: string): string | null {
61
16
  const normalized = normalizeCandidate(output);
62
17
  const startIndex = normalized.indexOf(startToken);
@@ -45,34 +45,39 @@ interface SessionStateGuardOptions {
45
45
  expectedSessionId?: string;
46
46
  }
47
47
 
48
- interface CreateSessionStateOptions {
49
- branch?: string;
48
+ interface CreateSessionStateOptions
49
+ extends Partial<
50
+ Pick<
51
+ SessionState,
52
+ | "branch"
53
+ | "state"
54
+ | "mode"
55
+ | "pid"
56
+ | "startTime"
57
+ | "lastHeartbeat"
58
+ | "sessionPath"
59
+ | "worktreeProjectPath"
60
+ | "worktreeBranch"
61
+ | "worktreeMergeReady"
62
+ | "worktreeCommitSha"
63
+ | "endTime"
64
+ | "reason"
65
+ | "currentPhase"
66
+ | "phase"
67
+ | "sessionStatus"
68
+ | "reviewOutcome"
69
+ | "handoffStatus"
70
+ | "handoffId"
71
+ | "handoffUpdatedAt"
72
+ | "commitSha"
73
+ | "artifactPath"
74
+ | "baselineCommitSha"
75
+ | "sourceBaselineFingerprint"
76
+ | "accumulatedFindings"
77
+ | "selectedFindingIds"
78
+ >
79
+ > {
50
80
  sessionId: string;
51
- state?: SessionStatus;
52
- mode?: SessionMode;
53
- pid?: number;
54
- startTime?: number;
55
- lastHeartbeat?: number;
56
- sessionPath?: string;
57
- worktreeProjectPath?: string;
58
- worktreeBranch?: string;
59
- worktreeMergeReady?: boolean;
60
- worktreeCommitSha?: string;
61
- endTime?: number;
62
- reason?: string;
63
- currentPhase?: ReviewPhase;
64
- phase?: ReviewPhase;
65
- sessionStatus?: WorkflowSessionStatus;
66
- reviewOutcome?: ReviewOutcome;
67
- handoffStatus?: HandoffStatus;
68
- handoffId?: string;
69
- handoffUpdatedAt?: number;
70
- commitSha?: string;
71
- artifactPath?: string;
72
- baselineCommitSha?: string;
73
- sourceBaselineFingerprint?: string;
74
- accumulatedFindings?: StoredFinding[];
75
- selectedFindingIds?: FindingId[];
76
81
  }
77
82
 
78
83
  export interface SessionState {
@@ -201,17 +206,28 @@ async function readSessionStateByPath(sessionStatePath: string): Promise<Session
201
206
  }
202
207
  }
203
208
 
209
+ async function readExpectedSessionStateByPath(
210
+ sessionStatePath: string,
211
+ options: SessionStateGuardOptions
212
+ ): Promise<SessionState | null> {
213
+ const existing = await readSessionStateByPath(sessionStatePath);
214
+ if (!existing) {
215
+ return null;
216
+ }
217
+
218
+ if (options.expectedSessionId && existing.sessionId !== options.expectedSessionId) {
219
+ return null;
220
+ }
221
+
222
+ return existing;
223
+ }
224
+
204
225
  async function removeSessionStateByPath(
205
226
  sessionStatePath: string,
206
227
  options: SessionStateGuardOptions = {}
207
228
  ): Promise<boolean> {
208
229
  return queueSessionStateWrite(sessionStatePath, async () => {
209
- const existing = await readSessionStateByPath(sessionStatePath);
210
- if (!existing) {
211
- return false;
212
- }
213
-
214
- if (options.expectedSessionId && existing.sessionId !== options.expectedSessionId) {
230
+ if (!(await readExpectedSessionStateByPath(sessionStatePath, options))) {
215
231
  return false;
216
232
  }
217
233
 
@@ -351,15 +367,11 @@ export async function updateSessionState(
351
367
  const sessionStatePath = getSessionStatePath(storageRoot, projectPath, sessionId);
352
368
 
353
369
  return await queueSessionStateWrite(sessionStatePath, async () => {
354
- const existing = await readSessionStateByPath(sessionStatePath);
370
+ const existing = await readExpectedSessionStateByPath(sessionStatePath, options);
355
371
  if (!existing) {
356
372
  return false;
357
373
  }
358
374
 
359
- if (options.expectedSessionId && existing.sessionId !== options.expectedSessionId) {
360
- return false;
361
- }
362
-
363
375
  const merged = { ...existing } as Record<string, unknown>;
364
376
  for (const [key, value] of Object.entries(updates)) {
365
377
  if (value === undefined) {
@@ -42,6 +42,12 @@ interface RepairedCandidate {
42
42
  changed: boolean;
43
43
  }
44
44
 
45
+ export interface JsonObjectSlice {
46
+ start: number;
47
+ end: number;
48
+ value: string;
49
+ }
50
+
45
51
  function normalizeCandidateText(candidate: string): string {
46
52
  return candidate
47
53
  .replace(/^\uFEFF/, "")
@@ -72,8 +78,8 @@ function extractJsonBlock(output: string): string | null {
72
78
  return match[1].trim();
73
79
  }
74
80
 
75
- function extractBalancedJsonObjects(text: string): string[] {
76
- const objects: string[] = [];
81
+ export function extractBalancedJsonObjectSlices(text: string): JsonObjectSlice[] {
82
+ const objects: JsonObjectSlice[] = [];
77
83
  let depth = 0;
78
84
  let startIndex = -1;
79
85
  let inString = false;
@@ -87,12 +93,12 @@ function extractBalancedJsonObjects(text: string): string[] {
87
93
  continue;
88
94
  }
89
95
 
90
- if (char === "\\") {
91
- escaped = true;
92
- continue;
93
- }
94
-
95
96
  if (inString) {
97
+ if (char === "\\") {
98
+ escaped = true;
99
+ continue;
100
+ }
101
+
96
102
  if (char === '"') {
97
103
  inString = false;
98
104
  }
@@ -122,7 +128,12 @@ function extractBalancedJsonObjects(text: string): string[] {
122
128
 
123
129
  depth -= 1;
124
130
  if (depth === 0 && startIndex >= 0) {
125
- objects.push(text.slice(startIndex, index + 1));
131
+ const endIndex = index + 1;
132
+ objects.push({
133
+ start: startIndex,
134
+ end: endIndex,
135
+ value: text.slice(startIndex, endIndex),
136
+ });
126
137
  startIndex = -1;
127
138
  }
128
139
  }
@@ -130,6 +141,10 @@ function extractBalancedJsonObjects(text: string): string[] {
130
141
  return objects;
131
142
  }
132
143
 
144
+ function extractBalancedJsonObjects(text: string): string[] {
145
+ return extractBalancedJsonObjectSlices(text).map((object) => object.value);
146
+ }
147
+
133
148
  function isolateJsonObject(candidate: string): string {
134
149
  const objects = extractBalancedJsonObjects(candidate);
135
150
  if (objects.length === 0) {
@@ -140,7 +155,7 @@ function isolateJsonObject(candidate: string): string {
140
155
  return lastObject?.trim() || candidate;
141
156
  }
142
157
 
143
- function removeTrailingCommas(candidate: string): string {
158
+ export function removeTrailingCommas(candidate: string): string {
144
159
  let output = "";
145
160
  let inString = false;
146
161
  let escaped = false;
@@ -1,5 +1,5 @@
1
1
  import { useKeyboard } from "@opentui/react";
2
- import { TUI_COLORS } from "@/lib/tui/shared/colors";
2
+ import { KeyboardShortcutsModal } from "@/lib/tui/shared/KeyboardShortcutsModal";
3
3
 
4
4
  interface HelpOverlayProps {
5
5
  onClose: () => void;
@@ -12,60 +12,16 @@ export function HelpOverlay({ onClose }: HelpOverlayProps) {
12
12
  }
13
13
  });
14
14
 
15
- return (
16
- <box
17
- position="absolute"
18
- left={0}
19
- top={0}
20
- width="100%"
21
- height="100%"
22
- justifyContent="center"
23
- alignItems="center"
24
- >
25
- <box
26
- border
27
- borderStyle="double"
28
- title="Keyboard Shortcuts"
29
- titleAlignment="left"
30
- padding={2}
31
- width={44}
32
- backgroundColor="#1a1a2e"
33
- >
34
- <box flexDirection="column" gap={1}>
35
- <text>
36
- <span fg={TUI_COLORS.accent.key}>[r]</span>
37
- <span fg={TUI_COLORS.text.muted}> Run new review session</span>
38
- </text>
39
- <text>
40
- <span fg={TUI_COLORS.accent.key}>[s]</span>
41
- <span fg={TUI_COLORS.text.muted}> Stop running review session</span>
42
- </text>
43
- <text>
44
- <span fg={TUI_COLORS.accent.key}>[o]</span>
45
- <span fg={TUI_COLORS.text.muted}> Toggle output drawer</span>
46
- </text>
47
- <text>
48
- <span fg={TUI_COLORS.accent.key}>[Tab ←/→]</span>
49
- <span fg={TUI_COLORS.text.muted}> Switch panel focus</span>
50
- </text>
51
- <text>
52
- <span fg={TUI_COLORS.accent.key}>[↑/↓ j/k]</span>
53
- <span fg={TUI_COLORS.text.muted}> Scroll focused panel</span>
54
- </text>
55
- <text>
56
- <span fg={TUI_COLORS.accent.key}>[l]</span>
57
- <span fg={TUI_COLORS.text.muted}> View logs</span>
58
- </text>
59
- <text>
60
- <span fg={TUI_COLORS.accent.key}>[Esc/q]</span>
61
- <span fg={TUI_COLORS.text.muted}> Quit TUI (Won't stop review)</span>
62
- </text>
63
- <text>
64
- <span fg={TUI_COLORS.accent.key}>[h/?]</span>
65
- <span fg={TUI_COLORS.text.muted}> Toggle help</span>
66
- </text>
67
- </box>
68
- </box>
69
- </box>
70
- );
15
+ return <KeyboardShortcutsModal shortcuts={DASHBOARD_SHORTCUTS} />;
71
16
  }
17
+
18
+ const DASHBOARD_SHORTCUTS = [
19
+ { keys: "[r]", label: "Run new review session" },
20
+ { keys: "[s]", label: "Stop running review session" },
21
+ { keys: "[o]", label: "Toggle output drawer" },
22
+ { keys: "[Tab ←/→]", label: "Switch panel focus" },
23
+ { keys: "[↑/↓ j/k]", label: "Scroll focused panel" },
24
+ { keys: "[l]", label: "View logs" },
25
+ { keys: "[Esc/q]", label: "Quit TUI (Won't stop review)" },
26
+ { keys: "[h/?]", label: "Toggle help" },
27
+ ] as const;