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
@@ -1,4 +1,5 @@
1
1
  import { TUI_COLORS } from "@/lib/tui/shared/colors";
2
+ import { ShortcutHint } from "@/lib/tui/shared/ShortcutHint";
2
3
  import type { FocusedPane } from "@/lib/tui/workspace/workspace-types";
3
4
 
4
5
  interface StatusBarProps {
@@ -41,18 +42,9 @@ export function StatusBar({
41
42
  paddingTop={1}
42
43
  >
43
44
  <box flexDirection="row" gap={2}>
44
- <text>
45
- <span fg={TUI_COLORS.accent.key}>[↑/↓]</span>
46
- <span fg={TUI_COLORS.text.muted}> Choose</span>
47
- </text>
48
- <text>
49
- <span fg={TUI_COLORS.accent.key}>[Enter]</span>
50
- <span fg={TUI_COLORS.text.muted}> Stop</span>
51
- </text>
52
- <text>
53
- <span fg={TUI_COLORS.accent.key}>[Esc]</span>
54
- <span fg={TUI_COLORS.text.muted}> Cancel</span>
55
- </text>
45
+ <ShortcutHint keys="[↑/↓]" label="Choose" />
46
+ <ShortcutHint keys="[Enter]" label="Stop" />
47
+ <ShortcutHint keys="[Esc]" label="Cancel" />
56
48
  </box>
57
49
  <text fg={TUI_COLORS.text.dim}>Focus: Session Picker</text>
58
50
  </box>
@@ -69,44 +61,14 @@ export function StatusBar({
69
61
  paddingBottom={1}
70
62
  >
71
63
  <box flexDirection="row" gap={2}>
72
- <text>
73
- <span fg={TUI_COLORS.accent.key}>[Esc/q]</span>
74
- <span fg={TUI_COLORS.text.muted}> Quit</span>
75
- </text>
76
- {!hasSession && (
77
- <text>
78
- <span fg={TUI_COLORS.accent.key}>[r]</span>
79
- <span fg={TUI_COLORS.text.muted}> Run Review</span>
80
- </text>
81
- )}
82
- {hasSession && (
83
- <text>
84
- <span fg={TUI_COLORS.accent.key}>[s]</span>
85
- <span fg={TUI_COLORS.text.muted}> Stop Review</span>
86
- </text>
87
- )}
88
- <text>
89
- <span fg={TUI_COLORS.accent.key}>[o]</span>
90
- <span fg={TUI_COLORS.text.muted}> {outputVisible ? "Hide Output" : "Output"}</span>
91
- </text>
92
- <text>
93
- <span fg={TUI_COLORS.accent.key}>[Tab ←/→]</span>
94
- <span fg={TUI_COLORS.text.muted}> Switch</span>
95
- </text>
96
- <text>
97
- <span fg={TUI_COLORS.accent.key}>[l]</span>
98
- <span fg={TUI_COLORS.text.muted}> Logs</span>
99
- </text>
100
- {canFixPendingSession && (
101
- <text>
102
- <span fg={TUI_COLORS.accent.key}>[f]</span>
103
- <span fg={TUI_COLORS.text.muted}> Fix</span>
104
- </text>
105
- )}
106
- <text>
107
- <span fg={TUI_COLORS.accent.key}>[h]</span>
108
- <span fg={TUI_COLORS.text.muted}> Help</span>
109
- </text>
64
+ <ShortcutHint keys="[Esc/q]" label="Quit" />
65
+ {!hasSession && <ShortcutHint keys="[r]" label="Run Review" />}
66
+ {hasSession && <ShortcutHint keys="[s]" label="Stop Review" />}
67
+ <ShortcutHint keys="[o]" label={outputVisible ? "Hide Output" : "Output"} />
68
+ <ShortcutHint keys="[Tab ←/→]" label="Switch" />
69
+ <ShortcutHint keys="[l]" label="Logs" />
70
+ {canFixPendingSession && <ShortcutHint keys="[f]" label="Fix" />}
71
+ <ShortcutHint keys="[h]" label="Help" />
110
72
  </box>
111
73
  <box flexDirection="column" alignItems="flex-end">
112
74
  {liveRefreshError && (
@@ -1,4 +1,5 @@
1
1
  import type { ActiveSession } from "@/lib/session-state";
2
+ import { CenteredModal } from "@/lib/tui/shared/CenteredModal";
2
3
  import { TUI_COLORS } from "@/lib/tui/shared/colors";
3
4
 
4
5
  interface StopSessionPickerOverlayProps {
@@ -38,27 +39,8 @@ export function StopSessionPickerOverlay({
38
39
  onClose,
39
40
  }: StopSessionPickerOverlayProps) {
40
41
  return (
41
- <box
42
- position="absolute"
43
- left={0}
44
- top={0}
45
- width="100%"
46
- height="100%"
47
- justifyContent="center"
48
- alignItems="center"
49
- >
50
- <box
51
- border
52
- borderStyle="double"
53
- title="Stop Review Session"
54
- titleAlignment="left"
55
- padding={1}
56
- width={80}
57
- height={16}
58
- backgroundColor="#1a1a2e"
59
- flexDirection="column"
60
- gap={1}
61
- >
42
+ <CenteredModal title="Stop Review Session" width={80} height={16} padding={1}>
43
+ <box flexDirection="column" gap={1}>
62
44
  <text fg={TUI_COLORS.text.muted}>Choose which active worktree session to stop.</text>
63
45
  <select
64
46
  focused
@@ -84,6 +66,6 @@ export function StopSessionPickerOverlay({
84
66
  }}
85
67
  />
86
68
  </box>
87
- </box>
69
+ </CenteredModal>
88
70
  );
89
71
  }
@@ -1,75 +1,31 @@
1
- import type {
2
- FindingFixResult,
3
- FindingId,
4
- StoredFinding,
5
- } from "@/lib/review-workflow/findings/types";
6
- import type { SessionState } from "@/lib/session-state";
7
1
  import type { DashboardStartupMode } from "@/lib/tui/dashboard/use-dashboard-run-control";
8
2
  import { TUI_COLORS } from "@/lib/tui/shared/colors";
9
- import type {
10
- AgentRole,
11
- Finding,
12
- FixEntry,
13
- ProjectStats,
14
- ReviewOptions,
15
- SessionStats,
16
- SkippedEntry,
17
- } from "@/lib/types";
3
+ import type { ProjectStats, SessionStats } from "@/lib/types";
18
4
  import { IdleStateView } from "./IdleStateView";
5
+ import type { SessionDetailViewProps } from "./SessionDetailView";
19
6
  import { SessionDetailView } from "./SessionDetailView";
20
7
 
21
- interface DetailPaneProps {
22
- session: SessionState | null;
23
- fixes: FixEntry[];
24
- skipped: SkippedEntry[];
25
- findings: Finding[];
26
- storedFindings: StoredFinding[];
27
- selectedFindingIds: FindingId[];
28
- fixResults: FindingFixResult[];
29
- unresolvedSelectedFindings: StoredFinding[];
30
- auditRegressionFindings: StoredFinding[];
31
- latestReviewIteration: number | null;
32
- codexReviewText: string | null;
33
- tmuxOutput: string;
34
- maxIterations: number;
8
+ export interface DetailPaneProps extends Omit<SessionDetailViewProps, "session"> {
9
+ session: SessionDetailViewProps["session"] | null;
35
10
  isLoading: boolean;
36
11
  lastSessionStats?: SessionStats | null;
37
12
  projectStats: ProjectStats | null;
38
13
  isGitRepo: boolean;
39
- currentAgent: AgentRole | null;
40
- reviewOptions: ReviewOptions | undefined;
41
14
  startupMode: DashboardStartupMode;
42
- isStopping: boolean;
43
- activeSessionCount: number;
44
15
  canFixPendingSession?: boolean;
45
- focused?: boolean;
46
16
  }
47
17
 
48
18
  export function DetailPane({
49
19
  session,
50
- fixes,
51
- skipped,
52
- findings,
53
- storedFindings,
54
- selectedFindingIds,
55
- fixResults,
56
- unresolvedSelectedFindings,
57
- auditRegressionFindings,
58
- latestReviewIteration,
59
- codexReviewText,
60
- tmuxOutput,
61
- maxIterations,
62
20
  isLoading,
63
21
  lastSessionStats = null,
64
22
  projectStats,
65
23
  isGitRepo,
66
- currentAgent,
67
- reviewOptions,
68
24
  startupMode,
69
25
  isStopping,
70
- activeSessionCount,
71
26
  canFixPendingSession = false,
72
27
  focused = false,
28
+ ...sessionDetailProps
73
29
  }: DetailPaneProps) {
74
30
  const borderColor = focused ? TUI_COLORS.ui.borderFocused : TUI_COLORS.ui.border;
75
31
 
@@ -104,23 +60,9 @@ export function DetailPane({
104
60
  <scrollbox flexGrow={1} focused={focused}>
105
61
  {session ? (
106
62
  <SessionDetailView
63
+ {...sessionDetailProps}
107
64
  session={session}
108
- fixes={fixes}
109
- skipped={skipped}
110
- findings={findings}
111
- storedFindings={storedFindings}
112
- selectedFindingIds={selectedFindingIds}
113
- fixResults={fixResults}
114
- unresolvedSelectedFindings={unresolvedSelectedFindings}
115
- auditRegressionFindings={auditRegressionFindings}
116
- latestReviewIteration={latestReviewIteration}
117
- codexReviewText={codexReviewText}
118
- tmuxOutput={tmuxOutput}
119
- maxIterations={maxIterations}
120
- currentAgent={currentAgent}
121
- reviewOptions={reviewOptions}
122
65
  isStopping={isStopping}
123
- activeSessionCount={activeSessionCount}
124
66
  focused={focused}
125
67
  />
126
68
  ) : (
@@ -19,19 +19,17 @@ import { Spinner } from "@/lib/tui/shared/Spinner";
19
19
  import type { ProjectStats, SessionStats } from "@/lib/types";
20
20
  import { toSingleLine } from "./session-detail-parts";
21
21
 
22
+ const LAST_RUN_STATUS_DISPLAY: Partial<
23
+ Record<SessionStats["status"], { text: string; color: string }>
24
+ > = {
25
+ completed: { text: "completed", color: TUI_COLORS.status.success },
26
+ failed: { text: "failed", color: TUI_COLORS.status.error },
27
+ interrupted: { text: "interrupted", color: TUI_COLORS.status.warning },
28
+ running: { text: "running", color: TUI_COLORS.status.pending },
29
+ };
30
+
22
31
  function getLastRunStatusDisplay(status: SessionStats["status"]): { text: string; color: string } {
23
- switch (status) {
24
- case "completed":
25
- return { text: "completed", color: TUI_COLORS.status.success };
26
- case "failed":
27
- return { text: "failed", color: TUI_COLORS.status.error };
28
- case "interrupted":
29
- return { text: "interrupted", color: TUI_COLORS.status.warning };
30
- case "running":
31
- return { text: "running", color: TUI_COLORS.status.pending };
32
- default:
33
- return { text: "unknown", color: TUI_COLORS.status.inactive };
34
- }
32
+ return LAST_RUN_STATUS_DISPLAY[status] ?? { text: "unknown", color: TUI_COLORS.status.inactive };
35
33
  }
36
34
 
37
35
  function getLastRunHandoffDisplay(stats: SessionStats): {
@@ -36,7 +36,7 @@ import {
36
36
  toSingleLine,
37
37
  } from "./session-detail-parts";
38
38
 
39
- interface SessionDetailViewProps {
39
+ export interface SessionDetailViewProps {
40
40
  session: SessionState;
41
41
  fixes: FixEntry[];
42
42
  skipped: SkippedEntry[];
@@ -19,6 +19,56 @@ function formatConfidenceScore(value: number): string {
19
19
  return `${Math.round(value * 100)}%`;
20
20
  }
21
21
 
22
+ function EmptyListMessage() {
23
+ return (
24
+ <text fg={TUI_COLORS.text.dim} paddingLeft={2}>
25
+ None yet
26
+ </text>
27
+ );
28
+ }
29
+
30
+ function ScrollableList({
31
+ content,
32
+ focused,
33
+ height,
34
+ scrollable,
35
+ }: {
36
+ content: React.ReactNode;
37
+ focused: boolean;
38
+ height: BoxHeight;
39
+ scrollable: boolean;
40
+ }) {
41
+ if (!scrollable) {
42
+ return <box paddingLeft={2}>{content}</box>;
43
+ }
44
+
45
+ return (
46
+ <scrollbox paddingLeft={2} height={height} focused={focused}>
47
+ {content}
48
+ </scrollbox>
49
+ );
50
+ }
51
+
52
+ function PriorityTitleRow({
53
+ priority,
54
+ title,
55
+ }: {
56
+ priority: Finding["priority"] | FixEntry["priority"];
57
+ title: string;
58
+ }) {
59
+ return (
60
+ <box flexDirection="row">
61
+ <text>
62
+ <PriorityText priority={priority} />
63
+ </text>
64
+ <text fg={TUI_COLORS.text.dim}> ▸ </text>
65
+ <text fg={TUI_COLORS.text.secondary} wrapMode="none">
66
+ {toSingleLine(title)}
67
+ </text>
68
+ </box>
69
+ );
70
+ }
71
+
22
72
  export function SectionHeader({
23
73
  title,
24
74
  count,
@@ -55,11 +105,7 @@ export function FindingsList({
55
105
  showConfidence?: boolean;
56
106
  }) {
57
107
  if (findings.length === 0) {
58
- return (
59
- <text fg={TUI_COLORS.text.dim} paddingLeft={2}>
60
- None yet
61
- </text>
62
- );
108
+ return <EmptyListMessage />;
63
109
  }
64
110
 
65
111
  const content = findings.map((finding, index) => {
@@ -70,15 +116,10 @@ export function FindingsList({
70
116
  return (
71
117
  <box key={key} flexDirection="column">
72
118
  {showBody && index > 0 && <text> </text>}
73
- <box flexDirection="row">
74
- <text>
75
- <PriorityText priority={finding.priority} />
76
- </text>
77
- <text fg={TUI_COLORS.text.dim}> ▸ </text>
78
- <text fg={TUI_COLORS.text.secondary} wrapMode="none">
79
- {toSingleLine(formatFindingTitleForDisplay(finding.title))}
80
- </text>
81
- </box>
119
+ <PriorityTitleRow
120
+ priority={finding.priority}
121
+ title={formatFindingTitleForDisplay(finding.title)}
122
+ />
82
123
  {showBody && (
83
124
  <>
84
125
  <text> </text>
@@ -99,14 +140,8 @@ export function FindingsList({
99
140
  );
100
141
  });
101
142
 
102
- if (!scrollable) {
103
- return <box paddingLeft={2}>{content}</box>;
104
- }
105
-
106
143
  return (
107
- <scrollbox paddingLeft={2} height={height} focused={focused}>
108
- {content}
109
- </scrollbox>
144
+ <ScrollableList content={content} focused={focused} height={height} scrollable={scrollable} />
110
145
  );
111
146
  }
112
147
 
@@ -153,11 +188,7 @@ export function SelectableStoredFindingsList({
153
188
  selectedFirst?: boolean;
154
189
  }) {
155
190
  if (findings.length === 0) {
156
- return (
157
- <text fg={TUI_COLORS.text.dim} paddingLeft={2}>
158
- None yet
159
- </text>
160
- );
191
+ return <EmptyListMessage />;
161
192
  }
162
193
 
163
194
  const selectedIdSet = new Set(selectedFindingIds);
@@ -193,14 +224,8 @@ export function SelectableStoredFindingsList({
193
224
  );
194
225
  });
195
226
 
196
- if (!scrollable) {
197
- return <box paddingLeft={2}>{content}</box>;
198
- }
199
-
200
227
  return (
201
- <scrollbox paddingLeft={2} height={height} focused={focused}>
202
- {content}
203
- </scrollbox>
228
+ <ScrollableList content={content} focused={focused} height={height} scrollable={scrollable} />
204
229
  );
205
230
  }
206
231
 
@@ -218,24 +243,12 @@ export function FixList({
218
243
  scrollable?: boolean;
219
244
  }) {
220
245
  if (fixes.length === 0) {
221
- return (
222
- <text fg={TUI_COLORS.text.dim} paddingLeft={2}>
223
- None yet
224
- </text>
225
- );
246
+ return <EmptyListMessage />;
226
247
  }
227
248
 
228
249
  const content = fixes.map((fix, index) => (
229
250
  <box key={`${index}-${fix.id}`} flexDirection="column">
230
- <box flexDirection="row">
231
- <text>
232
- <PriorityText priority={fix.priority} />
233
- </text>
234
- <text fg={TUI_COLORS.text.dim}> ▸ </text>
235
- <text fg={TUI_COLORS.text.secondary} wrapMode="none">
236
- {toSingleLine(fix.title)}
237
- </text>
238
- </box>
251
+ <PriorityTitleRow priority={fix.priority} title={fix.title} />
239
252
  {showFiles && fix.file && (
240
253
  <text fg={TUI_COLORS.text.dim} paddingLeft={5} wrapMode="none">
241
254
  {toSingleLine(fix.file)}
@@ -244,14 +257,8 @@ export function FixList({
244
257
  </box>
245
258
  ));
246
259
 
247
- if (!scrollable) {
248
- return <box paddingLeft={2}>{content}</box>;
249
- }
250
-
251
260
  return (
252
- <scrollbox paddingLeft={2} height={height} focused={focused}>
253
- {content}
254
- </scrollbox>
261
+ <ScrollableList content={content} focused={focused} height={height} scrollable={scrollable} />
255
262
  );
256
263
  }
257
264
 
@@ -267,38 +274,20 @@ export function SkippedList({
267
274
  scrollable?: boolean;
268
275
  }) {
269
276
  if (skipped.length === 0) {
270
- return (
271
- <text fg={TUI_COLORS.text.dim} paddingLeft={2}>
272
- None yet
273
- </text>
274
- );
277
+ return <EmptyListMessage />;
275
278
  }
276
279
 
277
280
  const content = skipped.map((entry, index) => (
278
281
  <box key={`${index}-${entry.id}`} flexDirection="column">
279
- <box flexDirection="row">
280
- <text>
281
- <PriorityText priority={entry.priority} />
282
- </text>
283
- <text fg={TUI_COLORS.text.dim}> ▸ </text>
284
- <text fg={TUI_COLORS.text.secondary} wrapMode="none">
285
- {toSingleLine(entry.title)}
286
- </text>
287
- </box>
282
+ <PriorityTitleRow priority={entry.priority} title={entry.title} />
288
283
  <text fg={TUI_COLORS.text.dim} paddingLeft={5} wrapMode="none">
289
284
  {toSingleLine(entry.reason)}
290
285
  </text>
291
286
  </box>
292
287
  ));
293
288
 
294
- if (!scrollable) {
295
- return <box paddingLeft={2}>{content}</box>;
296
- }
297
-
298
289
  return (
299
- <scrollbox paddingLeft={2} height={height} focused={focused}>
300
- {content}
301
- </scrollbox>
290
+ <ScrollableList content={content} focused={focused} height={height} scrollable={scrollable} />
302
291
  );
303
292
  }
304
293
 
@@ -327,11 +316,7 @@ export function FindingFixResultList({
327
316
  scrollable?: boolean;
328
317
  }) {
329
318
  if (results.length === 0) {
330
- return (
331
- <text fg={TUI_COLORS.text.dim} paddingLeft={2}>
332
- None yet
333
- </text>
334
- );
319
+ return <EmptyListMessage />;
335
320
  }
336
321
 
337
322
  const content = results.map((result) => {
@@ -359,13 +344,7 @@ export function FindingFixResultList({
359
344
  );
360
345
  });
361
346
 
362
- if (!scrollable) {
363
- return <box paddingLeft={2}>{content}</box>;
364
- }
365
-
366
347
  return (
367
- <scrollbox paddingLeft={2} height={height} focused={focused}>
368
- {content}
369
- </scrollbox>
348
+ <ScrollableList content={content} focused={focused} height={height} scrollable={scrollable} />
370
349
  );
371
350
  }
@@ -2,7 +2,10 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
2
2
  import { useCallback, useMemo, useState } from "react";
3
3
  import type { LogSession } from "@/lib/logger";
4
4
  import { formatProjectNameForDisplay } from "@/lib/tui/sessions/session-display";
5
+ import { CenteredModal } from "@/lib/tui/shared/CenteredModal";
5
6
  import { TUI_COLORS } from "@/lib/tui/shared/colors";
7
+ import { KeyboardShortcutsModal } from "@/lib/tui/shared/KeyboardShortcutsModal";
8
+ import { ShortcutHint } from "@/lib/tui/shared/ShortcutHint";
6
9
  import { SessionDetailPane } from "./SessionListDetailPane";
7
10
  import {
8
11
  buildSessionOverlayOptions,
@@ -27,48 +30,16 @@ function SessionHelpModal({ onClose }: { onClose: () => void }) {
27
30
  }
28
31
  });
29
32
 
30
- return (
31
- <box
32
- position="absolute"
33
- left={0}
34
- top={0}
35
- width="100%"
36
- height="100%"
37
- justifyContent="center"
38
- alignItems="center"
39
- >
40
- <box
41
- border
42
- borderStyle="double"
43
- title="Keyboard Shortcuts"
44
- titleAlignment="left"
45
- padding={2}
46
- width={44}
47
- backgroundColor="#1a1a2e"
48
- >
49
- <box flexDirection="column" gap={1}>
50
- <text>
51
- <span fg={TUI_COLORS.accent.key}>[Tab ←/→]</span>
52
- <span fg={TUI_COLORS.text.muted}> Switch pane focus</span>
53
- </text>
54
- <text>
55
- <span fg={TUI_COLORS.accent.key}>[↑/↓ j/k]</span>
56
- <span fg={TUI_COLORS.text.muted}> Navigate / Scroll</span>
57
- </text>
58
- <text>
59
- <span fg={TUI_COLORS.accent.key}>[d]</span>
60
- <span fg={TUI_COLORS.text.muted}> Delete selected log</span>
61
- </text>
62
- <text>
63
- <span fg={TUI_COLORS.accent.key}>[h/?]</span>
64
- <span fg={TUI_COLORS.text.muted}> Toggle help</span>
65
- </text>
66
- </box>
67
- </box>
68
- </box>
69
- );
33
+ return <KeyboardShortcutsModal shortcuts={SESSION_OVERLAY_SHORTCUTS} />;
70
34
  }
71
35
 
36
+ const SESSION_OVERLAY_SHORTCUTS = [
37
+ { keys: "[Tab ←/→]", label: "Switch pane focus" },
38
+ { keys: "[↑/↓ j/k]", label: "Navigate / Scroll" },
39
+ { keys: "[d]", label: "Delete selected log" },
40
+ { keys: "[h/?]", label: "Toggle help" },
41
+ ] as const;
42
+
72
43
  interface SessionDeleteModalProps {
73
44
  sessionName: string;
74
45
  error: string | null;
@@ -77,26 +48,8 @@ interface SessionDeleteModalProps {
77
48
 
78
49
  function SessionDeleteModal({ sessionName, error, isDeleting }: SessionDeleteModalProps) {
79
50
  return (
80
- <box
81
- position="absolute"
82
- left={0}
83
- top={0}
84
- width="100%"
85
- height="100%"
86
- justifyContent="center"
87
- alignItems="center"
88
- >
89
- <box
90
- border
91
- borderStyle="double"
92
- title="Delete Session Log"
93
- titleAlignment="left"
94
- padding={2}
95
- width={58}
96
- backgroundColor="#1a1a2e"
97
- flexDirection="column"
98
- gap={1}
99
- >
51
+ <CenteredModal title="Delete Session Log" width={58}>
52
+ <box flexDirection="column" gap={1}>
100
53
  <text fg={TUI_COLORS.text.primary}>{sessionName}</text>
101
54
  <text fg={TUI_COLORS.status.error}>This cannot be undone.</text>
102
55
  <text>
@@ -109,7 +62,7 @@ function SessionDeleteModal({ sessionName, error, isDeleting }: SessionDeleteMod
109
62
  {isDeleting && <text fg={TUI_COLORS.text.muted}>Deleting...</text>}
110
63
  {error && <text fg={TUI_COLORS.status.error}>{error}</text>}
111
64
  </box>
112
- </box>
65
+ </CenteredModal>
113
66
  );
114
67
  }
115
68
 
@@ -339,20 +292,9 @@ export function SessionOverlay({ onClose }: SessionOverlayProps) {
339
292
  paddingBottom={1}
340
293
  >
341
294
  <box flexDirection="row" gap={2}>
342
- {isNarrow && (
343
- <text>
344
- <span fg={TUI_COLORS.accent.key}>[Tab]</span>
345
- <span fg={TUI_COLORS.text.muted}> Switch</span>
346
- </text>
347
- )}
348
- <text>
349
- <span fg={TUI_COLORS.accent.key}>[d]</span>
350
- <span fg={TUI_COLORS.text.muted}> Delete</span>
351
- </text>
352
- <text>
353
- <span fg={TUI_COLORS.accent.key}>[h]</span>
354
- <span fg={TUI_COLORS.text.muted}> Help</span>
355
- </text>
295
+ {isNarrow && <ShortcutHint keys="[Tab]" label="Switch" />}
296
+ <ShortcutHint keys="[d]" label="Delete" />
297
+ <ShortcutHint keys="[h]" label="Help" />
356
298
  </box>
357
299
  <text fg={TUI_COLORS.text.dim}>Focus: {focusedPane === "list" ? "List" : "Detail"}</text>
358
300
  </box>