ralph-review 0.1.3 → 0.1.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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Version](https://img.shields.io/github/v/tag/kenryu42/ralph-review?label=version&color=blue)](https://github.com/kenryu42/ralph-review)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-red.svg)](https://opensource.org/licenses/MIT)
7
7
 
8
- A CLI tool that orchestrates agentic review-fix cycles until your code is clean.
8
+ Orchestrating coding agents for code review, verification and fixing via the ralph loop.
9
9
 
10
10
  ---
11
11
 
@@ -131,6 +131,10 @@ Treats review findings as untrusted input — verifies every claim against actua
131
131
  ## Installation
132
132
 
133
133
  ```bash
134
+ # Homebrew
135
+ brew install kenryu42/tap/ralph-review
136
+
137
+ # npm
134
138
  npm install -g ralph-review
135
139
  ```
136
140
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ralph-review",
3
- "version": "0.1.3",
4
- "description": "A CLI tool that orchestrates agentic review-fix cycles until your code is clean.",
3
+ "version": "0.1.5",
4
+ "description": "Orchestrating coding agents for code review, verification and fixing via the ralph loop.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "author": {
@@ -754,8 +754,8 @@ export async function buildAutoInitInput(
754
754
  iterationTimeoutMinutes,
755
755
  defaultReviewType: "uncommitted",
756
756
  runSimplifierByDefault: false,
757
- runWatchByDefault: DEFAULT_CONFIG.run?.watch ?? true,
758
- soundNotificationsEnabled: DEFAULT_CONFIG.notifications?.sound.enabled ?? true,
757
+ runWatchByDefault: true,
758
+ soundNotificationsEnabled: true,
759
759
  },
760
760
  skippedAgents,
761
761
  };
@@ -1043,14 +1043,21 @@ export async function runInitWithRuntime(
1043
1043
  }
1044
1044
 
1045
1045
  const resolvedInput = requireInitInput(runtime, input);
1046
- const inputWithPreferences: InitInput = {
1047
- ...resolvedInput,
1048
- runWatchByDefault: await promptForRunWatch(runtime, resolvedInput.runWatchByDefault),
1049
- soundNotificationsEnabled: await promptForSoundNotifications(
1050
- runtime,
1051
- resolvedInput.soundNotificationsEnabled
1052
- ),
1053
- };
1046
+ const inputWithPreferences: InitInput =
1047
+ setupMode === "auto"
1048
+ ? {
1049
+ ...resolvedInput,
1050
+ runWatchByDefault: true,
1051
+ soundNotificationsEnabled: true,
1052
+ }
1053
+ : {
1054
+ ...resolvedInput,
1055
+ runWatchByDefault: await promptForRunWatch(runtime, resolvedInput.runWatchByDefault),
1056
+ soundNotificationsEnabled: await promptForSoundNotifications(
1057
+ runtime,
1058
+ resolvedInput.soundNotificationsEnabled
1059
+ ),
1060
+ };
1054
1061
 
1055
1062
  const config = buildConfig(inputWithPreferences);
1056
1063
  runtime.prompt.log.info(`Proposed configuration:\n${formatConfigDisplay(config)}`);
@@ -618,15 +618,26 @@ export async function startReview(
618
618
  }
619
619
  }
620
620
 
621
- const modeOptions = [
622
- options.base !== undefined && "--base",
623
- options.uncommitted && "--uncommitted",
624
- options.commit !== undefined && "--commit",
625
- options.custom !== undefined && "--custom",
626
- ].filter(Boolean);
627
-
628
- if (modeOptions.length > 1) {
629
- runtime.prompt.log.error(`Cannot use ${modeOptions.join(" and ")} together`);
621
+ if (options.base !== undefined && options.commit !== undefined) {
622
+ runtime.prompt.log.error("Cannot use --base and --commit together");
623
+ runtime.process.exit(1);
624
+ return;
625
+ }
626
+
627
+ if (options.uncommitted && options.base !== undefined) {
628
+ runtime.prompt.log.error("Cannot use --uncommitted and --base together");
629
+ runtime.process.exit(1);
630
+ return;
631
+ }
632
+
633
+ if (options.uncommitted && options.commit !== undefined) {
634
+ runtime.prompt.log.error("Cannot use --uncommitted and --commit together");
635
+ runtime.process.exit(1);
636
+ return;
637
+ }
638
+
639
+ if (options.uncommitted && options.custom !== undefined) {
640
+ runtime.prompt.log.error("Cannot use --uncommitted and --custom together");
630
641
  runtime.process.exit(1);
631
642
  return;
632
643
  }
@@ -52,6 +52,12 @@ export const codexConfig: AgentConfig = {
52
52
 
53
53
  const baseReviewArgs = withReasoningEffort(["exec", "--json"], reasoning);
54
54
 
55
+ if (reviewOptions?.customInstructions) {
56
+ const fullPrompt = prompt ? `review ${prompt}` : "review";
57
+ const customArgs = withReasoningEffort(["exec", "--full-auto", "--json"], reasoning);
58
+ return withModel([...customArgs, fullPrompt], model);
59
+ }
60
+
55
61
  if (reviewOptions?.commitSha) {
56
62
  return withModel([...baseReviewArgs, "review", "--commit", reviewOptions.commitSha], model);
57
63
  }
@@ -60,12 +66,6 @@ export const codexConfig: AgentConfig = {
60
66
  return withModel([...baseReviewArgs, "review", "--base", reviewOptions.baseBranch], model);
61
67
  }
62
68
 
63
- if (reviewOptions?.customInstructions) {
64
- const fullPrompt = prompt ? `review ${prompt}` : "review";
65
- const customArgs = withReasoningEffort(["exec", "--full-auto", "--json"], reasoning);
66
- return withModel([...customArgs, fullPrompt], model);
67
- }
68
-
69
69
  return withModel([...baseReviewArgs, "review", "--uncommitted"], model);
70
70
  },
71
71
  buildEnv: defaultBuildEnv,
@@ -508,13 +508,7 @@ export async function runDiagnostics(
508
508
  }
509
509
  }
510
510
 
511
- if (
512
- !options.baseBranch &&
513
- !options.commitSha &&
514
- !options.customInstructions &&
515
- insideGitRepo &&
516
- !gitRepoError
517
- ) {
511
+ if (!options.baseBranch && !options.commitSha && insideGitRepo && !gitRepoError) {
518
512
  let hasChanges = false;
519
513
  let hasChangesError: string | null = null;
520
514
  try {
package/src/lib/format.ts CHANGED
@@ -1,23 +1,31 @@
1
1
  import type { ReviewOptions } from "@/lib/types";
2
2
 
3
+ function formatCustomReviewType(customInstructions: string): string {
4
+ const instruction = customInstructions.slice(0, 40);
5
+ return customInstructions.length > 40 ? `custom (${instruction}...)` : `custom (${instruction})`;
6
+ }
7
+
3
8
  export function formatReviewType(reviewOptions: ReviewOptions | undefined): string {
4
9
  if (!reviewOptions) return "uncommitted changes";
5
10
 
6
- if (reviewOptions.customInstructions) {
7
- const instruction = reviewOptions.customInstructions.slice(0, 40);
8
- return reviewOptions.customInstructions.length > 40
9
- ? `custom (${instruction}...)`
10
- : `custom (${instruction})`;
11
- }
12
-
13
11
  if (reviewOptions.commitSha) {
14
12
  const shortSha = reviewOptions.commitSha.slice(0, 7);
13
+ if (reviewOptions.customInstructions) {
14
+ return `commit (${shortSha}) + ${formatCustomReviewType(reviewOptions.customInstructions)}`;
15
+ }
15
16
  return `commit (${shortSha})`;
16
17
  }
17
18
 
18
19
  if (reviewOptions.baseBranch) {
20
+ if (reviewOptions.customInstructions) {
21
+ return `base (${reviewOptions.baseBranch}) + ${formatCustomReviewType(reviewOptions.customInstructions)}`;
22
+ }
19
23
  return `base (${reviewOptions.baseBranch})`;
20
24
  }
21
25
 
26
+ if (reviewOptions.customInstructions) {
27
+ return formatCustomReviewType(reviewOptions.customInstructions);
28
+ }
29
+
22
30
  return "uncommitted changes";
23
31
  }
@@ -163,6 +163,48 @@ const DASHBOARD_SCRIPT_TEMPLATE = `
163
163
  const sortByPriority = (fixes) =>
164
164
  [...fixes].sort((a, b) => getPriorityRank(a.priority) - getPriorityRank(b.priority));
165
165
 
166
+ const formatFixRangeHunk = (lineStart, lineEnd) => {
167
+ const count = lineEnd - lineStart + 1;
168
+ if (count <= 1) {
169
+ return \`@@ -\${lineStart} +\${lineStart} @@\`;
170
+ }
171
+ return \`@@ -\${lineStart},\${count} +\${lineStart},\${count} @@\`;
172
+ };
173
+
174
+ const normalizeFixCodeLocation = (fix) => {
175
+ const vmLocation = fix?.codeLocation;
176
+ if (
177
+ vmLocation &&
178
+ typeof vmLocation.absoluteFilePath === "string" &&
179
+ Number.isInteger(vmLocation.lineStart) &&
180
+ Number.isInteger(vmLocation.lineEnd) &&
181
+ vmLocation.lineStart > 0 &&
182
+ vmLocation.lineEnd >= vmLocation.lineStart
183
+ ) {
184
+ return vmLocation;
185
+ }
186
+
187
+ const rawLocation = fix?.code_location;
188
+ const start = rawLocation?.line_range?.start;
189
+ const end = rawLocation?.line_range?.end;
190
+ if (
191
+ rawLocation &&
192
+ typeof rawLocation.absolute_file_path === "string" &&
193
+ Number.isInteger(start) &&
194
+ Number.isInteger(end) &&
195
+ start > 0 &&
196
+ end >= start
197
+ ) {
198
+ return {
199
+ absoluteFilePath: rawLocation.absolute_file_path,
200
+ lineStart: start,
201
+ lineEnd: end,
202
+ };
203
+ }
204
+
205
+ return null;
206
+ };
207
+
166
208
  const extractFixes = (entries) => {
167
209
  const fixes = [];
168
210
  const skipped = [];
@@ -298,15 +340,24 @@ const DASHBOARD_SCRIPT_TEMPLATE = `
298
340
  <div class="panel-title">Fixes Applied</div>
299
341
  \${fixes.length
300
342
  ? \`<ul class="fix-list">\${fixes
301
- .map((fix) => \`
343
+ .map((fix) => {
344
+ const location = normalizeFixCodeLocation(fix);
345
+ const filePath = fix.file || location?.absoluteFilePath || "";
346
+ const range = location
347
+ ? \`<div class="fix-range mono">\${escapeHtml(formatFixRangeHunk(location.lineStart, location.lineEnd))}</div>\`
348
+ : "";
349
+
350
+ return \`
302
351
  <li class="fix-item">
303
352
  <div class="fix-pill \${getPriorityPillClass(fix.priority)}">\${escapeHtml(fix.priority)}</div>
304
353
  <div>
305
354
  <div class="fix-title">\${escapeHtml(fix.title)}</div>
306
- <div class="fix-meta muted">\${escapeHtml(fix.file || "")}</div>
355
+ <div class="fix-meta muted">\${escapeHtml(filePath)}</div>
356
+ \${range}
307
357
  </div>
308
358
  </li>
309
- \`)
359
+ \`;
360
+ })
310
361
  .join("")}</ul>\`
311
362
  : '<div class="muted">No fixes recorded for this session.</div>'}
312
363
  </div>
@@ -359,6 +359,11 @@ export const DASHBOARD_CSS = `
359
359
  .fix-pill-default { background: var(--accent); }
360
360
  .fix-title, .skip-title { font-weight: 600; font-size: 13px; }
361
361
  .fix-meta, .skip-reason { font-size: 11px; margin-top: 4px; }
362
+ .fix-range {
363
+ margin-top: 6px;
364
+ color: rgba(237, 242, 255, 0.84);
365
+ font-size: 11px;
366
+ }
362
367
  .muted { color: var(--muted); }
363
368
  .empty { padding: 24px; text-align: center; color: var(--muted); border: 1px dashed var(--border); border-radius: 16px; background: rgba(9, 14, 23, 0.6); }
364
369
  .empty.tiny { padding: 12px; font-size: 12px; }
@@ -17,6 +17,11 @@ interface FixViewModel {
17
17
  priority: FixEntry["priority"];
18
18
  title: string;
19
19
  file: string;
20
+ codeLocation?: {
21
+ absoluteFilePath: string;
22
+ lineStart: number;
23
+ lineEnd: number;
24
+ };
20
25
  }
21
26
 
22
27
  interface SkippedViewModel {
@@ -99,16 +104,45 @@ function formatRoleDisplay(name: string, model: string, reasoning: string): stri
99
104
  return `${name} (${details.join(", ")})`;
100
105
  }
101
106
 
107
+ function toCodeLocationViewModel(fix: FixEntry): FixViewModel["codeLocation"] {
108
+ const location = fix.code_location;
109
+ if (!location) {
110
+ return undefined;
111
+ }
112
+
113
+ const lineStart = location.line_range?.start;
114
+ const lineEnd = location.line_range?.end;
115
+ if (
116
+ typeof location.absolute_file_path !== "string" ||
117
+ !Number.isInteger(lineStart) ||
118
+ !Number.isInteger(lineEnd) ||
119
+ lineStart < 1 ||
120
+ lineEnd < lineStart
121
+ ) {
122
+ return undefined;
123
+ }
124
+
125
+ return {
126
+ absoluteFilePath: location.absolute_file_path,
127
+ lineStart,
128
+ lineEnd,
129
+ };
130
+ }
131
+
102
132
  function buildSessionViewModel(session: SessionStats): SessionViewModel {
103
133
  const { fixes, skipped } = extractFixes(session.entries ?? []);
104
134
  const priorities = new Set(fixes.map((fix) => fix.priority));
105
135
  const sortedFixes = [...fixes]
106
136
  .sort((a, b) => getPriorityRank(a.priority) - getPriorityRank(b.priority))
107
- .map((fix) => ({
108
- priority: fix.priority,
109
- title: fix.title,
110
- file: fix.file ?? "",
111
- }));
137
+ .map((fix) => {
138
+ const codeLocation = toCodeLocationViewModel(fix);
139
+ return {
140
+ priority: fix.priority,
141
+ title: fix.title,
142
+ file: fix.file ?? "",
143
+ ...(codeLocation ? { codeLocation } : {}),
144
+ };
145
+ });
112
146
 
113
147
  const sortedSkipped = [...skipped]
114
148
  .sort((a, b) => getPriorityRank(a.priority) - getPriorityRank(b.priority))
@@ -3,8 +3,10 @@ import { getPriorityPillClass } from "@/lib/html/priority";
3
3
  import { escapeHtml, formatDate, formatDuration } from "@/lib/html/shared";
4
4
  import type {
5
5
  AgentSettings,
6
+ CodeLocation,
6
7
  FixEntry,
7
8
  IterationEntry,
9
+ LineRange,
8
10
  LogEntry,
9
11
  SkippedEntry,
10
12
  SystemEntry,
@@ -20,8 +22,64 @@ function isCodeSimplified(systemEntry: SystemEntry | undefined): boolean {
20
22
  return Boolean(systemEntry.codeSimplifier || systemEntry.reviewOptions?.simplifier);
21
23
  }
22
24
 
25
+ function isValidLineRange(lineRange: LineRange | undefined): lineRange is LineRange {
26
+ if (!lineRange) {
27
+ return false;
28
+ }
29
+
30
+ return (
31
+ Number.isInteger(lineRange.start) &&
32
+ Number.isInteger(lineRange.end) &&
33
+ lineRange.start > 0 &&
34
+ lineRange.end >= lineRange.start
35
+ );
36
+ }
37
+
38
+ function isValidCodeLocation(location: CodeLocation | null | undefined): location is CodeLocation {
39
+ if (!location || typeof location.absolute_file_path !== "string") {
40
+ return false;
41
+ }
42
+
43
+ return isValidLineRange(location.line_range);
44
+ }
45
+
46
+ function getFixDisplayFile(fix: FixEntry): string {
47
+ if (fix.file) {
48
+ return fix.file;
49
+ }
50
+
51
+ const location = fix.code_location;
52
+ if (isValidCodeLocation(location)) {
53
+ return location.absolute_file_path;
54
+ }
55
+
56
+ return "";
57
+ }
58
+
59
+ function formatFixRangeHunk(start: number, end: number): string {
60
+ const count = end - start + 1;
61
+ if (count <= 1) {
62
+ return `@@ -${start} +${start} @@`;
63
+ }
64
+ return `@@ -${start},${count} +${start},${count} @@`;
65
+ }
66
+
67
+ function renderFixRange(fix: FixEntry): string {
68
+ const location = fix.code_location;
69
+ if (!isValidCodeLocation(location)) {
70
+ return "";
71
+ }
72
+
73
+ const lineStart = location.line_range.start;
74
+ const lineEnd = location.line_range.end;
75
+
76
+ return `<div class="fix-range mono">${escapeHtml(formatFixRangeHunk(lineStart, lineEnd))}</div>`;
77
+ }
78
+
23
79
  function renderFixEntry(fix: FixEntry): string {
24
- const file = fix.file ? `<span class="muted">${escapeHtml(fix.file)}</span>` : "";
80
+ const filePath = getFixDisplayFile(fix);
81
+ const file = filePath ? `<span class="muted">${escapeHtml(filePath)}</span>` : "";
82
+ const range = renderFixRange(fix);
25
83
  const pillClass = getPriorityPillClass(fix.priority);
26
84
  return `
27
85
  <li class="fix-item">
@@ -29,6 +87,7 @@ function renderFixEntry(fix: FixEntry): string {
29
87
  <div>
30
88
  <div class="fix-title">${escapeHtml(fix.title)}</div>
31
89
  <div class="fix-meta">${file}</div>
90
+ ${range}
32
91
  </div>
33
92
  </li>
34
93
  `;
@@ -84,9 +84,15 @@ export const LOG_CSS = `
84
84
  .fix-pill-default { background: var(--accent); }
85
85
  .fix-title { font-weight: 600; }
86
86
  .fix-meta { font-size: 12px; margin-top: 4px; }
87
+ .fix-range {
88
+ margin-top: 6px;
89
+ color: rgba(237, 242, 255, 0.85);
90
+ font-size: 12px;
91
+ }
87
92
  .skip-title { font-weight: 600; }
88
93
  .skip-reason { font-size: 12px; }
89
94
  .muted { color: var(--muted); }
95
+ .mono { font-family: "Space Grotesk", monospace; }
90
96
  .callout {
91
97
  margin-top: 12px;
92
98
  padding: 12px 16px;
@@ -36,6 +36,7 @@ ${reviewOutput}
36
36
  - Prefer one aggregate command; otherwise run available lint -> typecheck -> test -> build.
37
37
  - Treat warnings as blocking.
38
38
  - Iterate fix + rerun until clean.
39
+ 6) For each APPLY item, include the applied code location when available (absolute path + line range).
39
40
 
40
41
  ## Special rule: tracking-status claims
41
42
  Claims like "file is untracked/not committed/missing from git" are SKIP in this pre-commit workflow.
@@ -69,6 +70,10 @@ ${FIX_SUMMARY_START_TOKEN}
69
70
  "title": "<one-line title>",
70
71
  "priority": "<P0 | P1 | P2 | P3>",
71
72
  "file": "<path or null>",
73
+ "code_location": {
74
+ "absolute_file_path": "<absolute path>",
75
+ "line_range": {"start": <int>, "end": <int>}
76
+ },
72
77
  "claim": "<issue claim>",
73
78
  "evidence": "<file:line / behavior>",
74
79
  "fix": "<what changed>"
@@ -95,6 +100,7 @@ JSON rules:
95
100
  - fixes MUST be []
96
101
  - skipped MUST contain only SKIP items
97
102
  - Include all APPLY items in fixes.
103
+ - For each APPLY item, include code_location when available; otherwise set code_location to null or omit it.
98
104
  - Include all SKIP items in skipped with required reason prefix.
99
105
  - Use [] when empty.
100
106
  - Priority must be exactly P0/P1/P2/P3.
@@ -19,6 +19,16 @@ const BASE_BRANCH_PROMPT_BACKUP = (branch: string) =>
19
19
  const COMMIT_PROMPT = (commitHash: string) =>
20
20
  `Review the code changes for the commit ${commitHash}. Provide prioritized, actionable findings.`;
21
21
 
22
+ const CUSTOM_FOCUS_PROMPT = (customInstructions: string) =>
23
+ `Additional review focus from user instructions:\n${customInstructions}`;
24
+
25
+ function withCustomFocus(instruction: string, customInstructions?: string): string {
26
+ if (!customInstructions) {
27
+ return instruction;
28
+ }
29
+ return `${instruction}\n\n${CUSTOM_FOCUS_PROMPT(customInstructions)}`;
30
+ }
31
+
22
32
  export interface ReviewerPromptOptions {
23
33
  repoPath: string;
24
34
  baseBranch?: string;
@@ -26,19 +36,20 @@ export interface ReviewerPromptOptions {
26
36
  customInstructions?: string;
27
37
  }
28
38
 
29
- /** Priority: commitSha > baseBranch > customInstructions > uncommitted (default) */
39
+ /** Target priority: commitSha > baseBranch > uncommitted (default), with custom focus overlay. */
30
40
  export function createReviewerPrompt(options: ReviewerPromptOptions): string {
31
41
  const { repoPath, baseBranch, commitSha, customInstructions } = options;
32
42
 
33
43
  let instruction: string;
34
44
 
35
45
  if (commitSha) {
36
- instruction = COMMIT_PROMPT(commitSha);
46
+ instruction = withCustomFocus(COMMIT_PROMPT(commitSha), customInstructions);
37
47
  } else if (baseBranch) {
38
48
  const mergeBaseSha = mergeBaseWithHead(repoPath, baseBranch);
39
- instruction = mergeBaseSha
49
+ const baseInstruction = mergeBaseSha
40
50
  ? BASE_BRANCH_PROMPT(baseBranch, mergeBaseSha)
41
51
  : BASE_BRANCH_PROMPT_BACKUP(baseBranch);
52
+ instruction = withCustomFocus(baseInstruction, customInstructions);
42
53
  } else if (customInstructions) {
43
54
  instruction = customInstructions;
44
55
  } else {
@@ -80,18 +80,21 @@ export function Header({ branch, elapsed, session, projectPath, config }: Header
80
80
  flexShrink={0}
81
81
  >
82
82
  <box flexDirection="row">
83
- <box flexDirection="column" width={12}>
83
+ <box flexDirection="column" width={20}>
84
84
  <text>
85
- <span fg={TUI_COLORS.brand.logo}> {" "}</span>
85
+ <span fg={TUI_COLORS.brand.title}>{" ██████╗ ██████╗ "}</span>
86
86
  </text>
87
87
  <text>
88
- <span fg={TUI_COLORS.brand.logo}> ███████ </span>
88
+ <span fg={TUI_COLORS.brand.title}>{" ██╔══██╗██╔══██╗"}</span>
89
89
  </text>
90
90
  <text>
91
- <span fg={TUI_COLORS.brand.logo}>▐██▃█▃██▌</span>
91
+ <span fg={TUI_COLORS.brand.title}>{" ██████╔╝██████╔╝"}</span>
92
92
  </text>
93
93
  <text>
94
- <span fg={TUI_COLORS.brand.logo}> ██▅▅▅██ </span>
94
+ <span fg={TUI_COLORS.brand.title}>{" ██║ ██║██║ ██║"}</span>
95
+ </text>
96
+ <text>
97
+ <span fg={TUI_COLORS.brand.title}>{" ╚═╝ ╚═╝╚═╝ ╚═╝"}</span>
95
98
  </text>
96
99
  </box>
97
100
 
@@ -1,5 +1,6 @@
1
1
  import { useTerminalDimensions } from "@opentui/react";
2
2
  import { useEffect, useMemo, useRef } from "react";
3
+ import { formatReviewType } from "@/lib/format";
3
4
  import type { LockData } from "@/lib/lockfile";
4
5
  import { TUI_COLORS } from "@/lib/tui/colors";
5
6
  import type {
@@ -81,24 +82,6 @@ function toSingleLine(value: string): string {
81
82
  return value.replace(/\s+/g, " ").trim();
82
83
  }
83
84
 
84
- function formatReviewType(reviewOptions: ReviewOptions | undefined): string {
85
- if (!reviewOptions) return "uncommitted changes";
86
-
87
- if (reviewOptions.customInstructions) {
88
- return `custom (${toSingleLine(reviewOptions.customInstructions)})`;
89
- }
90
-
91
- if (reviewOptions.commitSha) {
92
- return `commit (${toSingleLine(reviewOptions.commitSha)})`;
93
- }
94
-
95
- if (reviewOptions.baseBranch) {
96
- return `base (${toSingleLine(reviewOptions.baseBranch)})`;
97
- }
98
-
99
- return "uncommitted changes";
100
- }
101
-
102
85
  interface FixListProps {
103
86
  fixes: FixEntry[];
104
87
  showFiles: boolean;
@@ -566,7 +549,7 @@ export function SessionPanel({
566
549
  <box flexDirection="row" gap={1}>
567
550
  <text fg={TUI_COLORS.text.muted}>Review Type:</text>
568
551
  <text fg={TUI_COLORS.text.primary} wrapMode="none">
569
- {formatReviewType(reviewOptions)}
552
+ {toSingleLine(formatReviewType(reviewOptions))}
570
553
  </text>
571
554
  </box>
572
555
 
@@ -1,11 +1,13 @@
1
1
  import type { FixDecision, Priority } from "./domain";
2
2
  import { VALID_FIX_DECISIONS, VALID_PRIORITIES } from "./domain";
3
+ import type { CodeLocation } from "./review";
3
4
 
4
5
  export interface FixEntry {
5
6
  id: number;
6
7
  title: string;
7
8
  priority: Priority;
8
9
  file?: string | null;
10
+ code_location?: CodeLocation | null;
9
11
  claim: string;
10
12
  evidence: string;
11
13
  fix: string;
@@ -25,6 +27,37 @@ export interface FixSummary {
25
27
  skipped: SkippedEntry[];
26
28
  }
27
29
 
30
+ function isLineRange(value: unknown): value is CodeLocation["line_range"] {
31
+ if (typeof value !== "object" || value === null) {
32
+ return false;
33
+ }
34
+
35
+ const obj = value as Record<string, unknown>;
36
+
37
+ return (
38
+ typeof obj.start === "number" &&
39
+ Number.isInteger(obj.start) &&
40
+ obj.start > 0 &&
41
+ typeof obj.end === "number" &&
42
+ Number.isInteger(obj.end) &&
43
+ obj.end >= obj.start
44
+ );
45
+ }
46
+
47
+ function isCodeLocation(value: unknown): value is CodeLocation {
48
+ if (typeof value !== "object" || value === null) {
49
+ return false;
50
+ }
51
+
52
+ const obj = value as Record<string, unknown>;
53
+
54
+ if (typeof obj.absolute_file_path !== "string") {
55
+ return false;
56
+ }
57
+
58
+ return isLineRange(obj.line_range);
59
+ }
60
+
28
61
  function isFixEntry(value: unknown): value is FixEntry {
29
62
  if (typeof value !== "object" || value === null) {
30
63
  return false;
@@ -38,6 +71,9 @@ function isFixEntry(value: unknown): value is FixEntry {
38
71
  typeof obj.priority === "string" &&
39
72
  VALID_PRIORITIES.includes(obj.priority as Priority) &&
40
73
  (obj.file === undefined || obj.file === null || typeof obj.file === "string") &&
74
+ (obj.code_location === undefined ||
75
+ obj.code_location === null ||
76
+ isCodeLocation(obj.code_location)) &&
41
77
  typeof obj.claim === "string" &&
42
78
  typeof obj.evidence === "string" &&
43
79
  typeof obj.fix === "string"
@@ -31,9 +31,11 @@ export type {
31
31
  SystemEntry,
32
32
  } from "./log";
33
33
  export {
34
+ type CodeLocation,
34
35
  type CodexReviewSummary,
35
36
  type Finding,
36
37
  isReviewSummary,
38
+ type LineRange,
37
39
  parseCodexReviewText,
38
40
  type ReviewSummary,
39
41
  } from "./review";
@@ -1,12 +1,12 @@
1
1
  import type { OverallCorrectness } from "./domain";
2
2
  import { VALID_OVERALL_CORRECTNESS } from "./domain";
3
3
 
4
- interface LineRange {
4
+ export interface LineRange {
5
5
  start: number;
6
6
  end: number;
7
7
  }
8
8
 
9
- interface CodeLocation {
9
+ export interface CodeLocation {
10
10
  absolute_file_path: string;
11
11
  line_range: LineRange;
12
12
  }