@cueloop/client 0.1.0-alpha.84 → 0.1.0-alpha.86

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": "@cueloop/client",
3
- "version": "0.1.0-alpha.84",
3
+ "version": "0.1.0-alpha.86",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -27,10 +27,10 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.84",
31
- "@cueloop/extension-api": "0.1.0-alpha.84",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.84",
33
- "@cueloop/schema": "0.1.0-alpha.84",
30
+ "@cueloop/daemon": "0.1.0-alpha.86",
31
+ "@cueloop/extension-api": "0.1.0-alpha.86",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.86",
33
+ "@cueloop/schema": "0.1.0-alpha.86",
34
34
  "@opentui/core": "^0.5.11",
35
35
  "@opentui/keymap": "0.5.11",
36
36
  "@opentui/react": "^0.5.11",
@@ -8,6 +8,8 @@ import React from "react";
8
8
  import { DaemonServer } from "@cueloop/daemon";
9
9
  import type { Thread } from "@cueloop/schema";
10
10
  import { App } from "./App";
11
+ import { pullRequestReviewLayout } from "./launch-layout";
12
+ import { DARK } from "./theme";
11
13
  import { NERD } from "./components/primitives/icons";
12
14
  import {
13
15
  clickText,
@@ -66,6 +68,67 @@ async function renderApp(sessionId = session.id) {
66
68
  }
67
69
 
68
70
  describe("diff review", () => {
71
+ test("pull request review shows its brief beside Changes and signals a moved head", async () => {
72
+ const pullRequest = server.core.sessionCreate({
73
+ workspace: { repoRoot: "/repo", branch: "detached" },
74
+ artifact: {
75
+ type: "diff",
76
+ content: PATCH,
77
+ meta: {
78
+ title: "Fix store",
79
+ pr: "org/repo#42",
80
+ prBrief: "# Fix store\n\n## PR description\n\nUse a map.",
81
+ prHeadSha: "head-1",
82
+ prRefreshHeadSha: "head-2",
83
+ },
84
+ },
85
+ });
86
+ const setup = await renderReadyApp(
87
+ <App home={home} sessionId={pullRequest.id} layout={pullRequestReviewLayout()} />,
88
+ { width: 160, height: 30 },
89
+ );
90
+
91
+ await waitForText(setup, "Use a map.");
92
+ await waitForText(setup, "new Map()");
93
+ await waitForText(setup, "refresh");
94
+ const refreshSpan = setup
95
+ .captureSpans()
96
+ .lines.flatMap((line) => line.spans)
97
+ .find((span) => span.text.includes("refresh"));
98
+ const refreshColor = refreshSpan?.fg
99
+ .toInts()
100
+ .slice(0, 3)
101
+ .map((part) => part.toString(16).padStart(2, "0"))
102
+ .join("");
103
+
104
+ expect(`#${refreshColor}`).toBe(DARK.warning);
105
+ });
106
+
107
+ test("a resolved pull request review does not offer refresh", async () => {
108
+ const pullRequest = server.core.sessionCreate({
109
+ workspace: { repoRoot: "/repo", branch: "detached" },
110
+ artifact: {
111
+ type: "diff",
112
+ content: PATCH,
113
+ meta: {
114
+ title: "Fix store",
115
+ pr: "org/repo#42",
116
+ prHeadSha: "head-1",
117
+ prRefreshHeadSha: "head-2",
118
+ },
119
+ },
120
+ });
121
+
122
+ server.core.sessionSendMessage(pullRequest.id, "approved", "Ready to merge.");
123
+ const setup = await renderReadyApp(
124
+ <App home={home} sessionId={pullRequest.id} layout={pullRequestReviewLayout()} />,
125
+ { width: 160, height: 30 },
126
+ );
127
+
128
+ await waitForText(setup, "new Map()");
129
+ expect(setup.captureCharFrame()).not.toContain("refresh");
130
+ });
131
+
69
132
  test("renders file header, hunks, and signed lines", async () => {
70
133
  // Arrange
71
134
  const setup = await renderApp();
package/src/App.tsx CHANGED
@@ -8,7 +8,7 @@ import React, {
8
8
  } from "react";
9
9
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
10
10
  import type { Clock, KeyEvent } from "@opentui/core";
11
- import { marksByDisplay, type Mark } from "./view-plan";
11
+ import { buildDisplay, marksByDisplay, type Mark } from "./view-plan";
12
12
  import {
13
13
  DEFAULT_KEYS,
14
14
  DEFAULT_QUICK_ACTIONS,
@@ -21,6 +21,7 @@ import {
21
21
  type DiffViewMode,
22
22
  type IdentityConfig,
23
23
  type QuickAction,
24
+ type ReviewWorkspaceMode,
24
25
  } from "./config";
25
26
  import { resolveDisplayName } from "./attribution";
26
27
  import { resolveGithubIdentity } from "./github-identity";
@@ -252,6 +253,30 @@ function ownerThreadActions(actions: {
252
253
  );
253
254
  }
254
255
 
256
+ function refreshPullRequestAction(onRefresh: () => void, theme: Theme): React.ReactNode {
257
+ return (
258
+ <Toolbar>
259
+ <Button onPress={onRefresh} foreground={theme.warning} theme={theme}>
260
+ {" refresh "}
261
+ </Button>
262
+ </Toolbar>
263
+ );
264
+ }
265
+
266
+ /** Present pull request context with the ordinary read-only Thread renderer. */
267
+ function pullRequestBriefThread(session: Thread): Thread {
268
+ return {
269
+ ...session,
270
+ artifact: {
271
+ ...session.artifact,
272
+ type: "reply",
273
+ content: session.artifact.meta.prBrief ?? "",
274
+ },
275
+ workingCopy: undefined,
276
+ annotations: [],
277
+ };
278
+ }
279
+
255
280
  /** Pick the thread pane's body: the pixel prototype, the diff placeholder, the inline editor, or the read-only view. */
256
281
  function chooseThreadBody(choice: {
257
282
  isPixelPrototype: boolean;
@@ -603,6 +628,12 @@ export function App({
603
628
  const [identity, setIdentity] = useState<IdentityConfig>({ provider: "typed" });
604
629
  const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
605
630
  const [skills, setSkills] = useState<SlashItem[]>([]);
631
+ const [reviewSkill, setReviewSkill] = useState("code-review");
632
+ const [reviewWorkspace, setReviewWorkspace] = useState<ReviewWorkspaceMode>("worktree");
633
+ const reviewSkillOptions = useMemo(
634
+ () => [...new Set([reviewSkill, "code-review", ...skills.map((skill) => skill.name)])],
635
+ [reviewSkill, skills],
636
+ );
606
637
  const paletteNames = useMemo(
607
638
  () => new Set(mergeSlashItems(slashItemsFrom(quickActions), skills).map((item) => item.name)),
608
639
  [quickActions, skills],
@@ -623,6 +654,8 @@ export function App({
623
654
  setIdentity(config.identity);
624
655
  setQuickActions(config.actions);
625
656
  setSkills(loadSkills(config.skillsPath));
657
+ setReviewSkill(config.review.skill);
658
+ setReviewWorkspace(config.review.workspace);
626
659
  setAutoClose(config.ui.autoClose);
627
660
  setDiffView(config.ui.diffView);
628
661
  setDefaultMessage(config.ui.defaultMessage);
@@ -707,6 +740,11 @@ export function App({
707
740
  setMenuDialog(null);
708
741
  setMode({ type: "renameSelf", text: identity.name ?? "" });
709
742
  },
743
+ reviewSkill,
744
+ reviewSkillOptions,
745
+ setReviewSkill,
746
+ reviewWorkspace,
747
+ setReviewWorkspace,
710
748
  });
711
749
 
712
750
  // ── derived view model ──────────────────────
@@ -1099,7 +1137,13 @@ export function App({
1099
1137
  setMode,
1100
1138
  dispatch,
1101
1139
  });
1102
- const { showOwnerActions, prototypeCanComment, chromeHidden, prototypePath } = buildRenderFlags({
1140
+ const {
1141
+ showOwnerActions,
1142
+ showPullRequestRefresh,
1143
+ prototypeCanComment,
1144
+ chromeHidden,
1145
+ prototypePath,
1146
+ } = buildRenderFlags({
1103
1147
  session: activeSession,
1104
1148
  isOwner,
1105
1149
  isDiff,
@@ -1181,7 +1225,9 @@ export function App({
1181
1225
  onShare: () => dispatch({ type: "share" }),
1182
1226
  theme,
1183
1227
  })
1184
- : undefined
1228
+ : showPullRequestRefresh
1229
+ ? refreshPullRequestAction(() => void controller.refreshPullRequest(), theme)
1230
+ : undefined
1185
1231
  }
1186
1232
  threadPanel={
1187
1233
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
@@ -1199,7 +1245,25 @@ export function App({
1199
1245
  hidden={chromeHidden}
1200
1246
  />
1201
1247
  ),
1202
- diffPlaceholder: (
1248
+ diffPlaceholder: activeSession.artifact.meta.prBrief ? (
1249
+ <ThreadView
1250
+ session={pullRequestBriefThread(activeSession)}
1251
+ display={buildDisplay(activeSession.artifact.meta.prBrief)}
1252
+ marks={new Map()}
1253
+ quickActions={quickActions}
1254
+ suspended={threadViewSuspended}
1255
+ resolved
1256
+ observer
1257
+ onComposingChange={() => {}}
1258
+ onObserverBlocked={() => {}}
1259
+ onCursorChange={() => {}}
1260
+ onAnnotate={() => {}}
1261
+ onReply={() => {}}
1262
+ onUpdateAnnotation={() => {}}
1263
+ resolveAuthorLabel={() => undefined}
1264
+ onExit={() => onExit?.(0)}
1265
+ />
1266
+ ) : (
1203
1267
  <box
1204
1268
  style={{
1205
1269
  flexGrow: 1,
@@ -319,7 +319,6 @@ export function NoThreadShell(props: {
319
319
  renderTab={(tab) =>
320
320
  tab.kind === "welcome" ? (
321
321
  <WelcomePlayground
322
- version={CLIENT_VERSION}
323
322
  quickActions={quickActions}
324
323
  onComposingChange={onWelcomeComposingChange}
325
324
  suspended={focusedPane !== "changes" || menuControl.openMenuId !== null}
@@ -115,6 +115,7 @@ export function buildRenderFlags(params: {
115
115
 
116
116
  return {
117
117
  showOwnerActions: isOwner && !isDiff && !resolved,
118
+ showPullRequestRefresh: !resolved && session.artifact.meta.prRefreshHeadSha !== undefined,
118
119
  prototypeCanComment: isOwner && !resolved,
119
120
  chromeHidden: menuDialog !== null,
120
121
  prototypePath: session.artifact.meta.prototypePath ?? "",
@@ -313,7 +313,7 @@ export function CommentRow({
313
313
  // own comments (no author) wear the filled dot, collaborators the outline
314
314
  const own = annotation.author === undefined;
315
315
  const glyph = own ? "●" : "○";
316
- const glyphColor = own ? tokens.text : tokens.textMuted;
316
+ const glyphColor = annotation.orphan ? tokens.warning : own ? tokens.text : tokens.textMuted;
317
317
  const { showTooltip, hideTooltip } = useTooltip();
318
318
  const dotHover = authorLabel
319
319
  ? {
@@ -9,7 +9,7 @@ import { DiffContentView } from "./DiffContentView";
9
9
  import { diffRows, marksByRows } from "../view-diff";
10
10
  import { DARK } from "../theme";
11
11
  import { annotationPaletteFor } from "../annotation-palette";
12
- import { press } from "../test-support";
12
+ import { press, settle, typeText } from "../test-support";
13
13
  import { fixtureDiffSession } from "./story-fixtures";
14
14
 
15
15
  const noop = (): void => {};
@@ -119,3 +119,31 @@ test("walking the caret down past wrapped discussion cards keeps it on screen an
119
119
 
120
120
  setup.renderer.destroy();
121
121
  }, 25000);
122
+
123
+ test("opening a comment on the diff's last line reveals its composer", async () => {
124
+ const rows = diffRows(tallPatch(24));
125
+ const setup = await testRender(
126
+ <DiffContentView
127
+ rows={rows}
128
+ session={fixtureDiffSession()}
129
+ marks={marksByRows([], rows)}
130
+ quickActions={[]}
131
+ observer={false}
132
+ onAnnotate={noop}
133
+ onReply={noop}
134
+ onUpdateAnnotation={noop}
135
+ onExit={noop}
136
+ />,
137
+ { width: 60, height: 10 },
138
+ );
139
+
140
+ await settle(setup);
141
+ for (let step = 0; step < rows.length + 2; step++) {
142
+ // eslint-disable-next-line no-await-in-loop
143
+ await press(setup, "down");
144
+ }
145
+ await typeText(setup, "bottom comment");
146
+
147
+ expect(setup.captureCharFrame()).toContain("● bottom comment");
148
+ setup.renderer.destroy();
149
+ }, 25000);
@@ -509,9 +509,32 @@ export function DiffContentView({
509
509
  textAt: (rowIndex) => diffRowText(rows[rowIndex]!),
510
510
  annotatable: (rowIndex) => isCodeRow(rows[rowIndex]),
511
511
  };
512
+ const outdatedIds = useMemo(
513
+ () =>
514
+ new Set(
515
+ [...marks.values()]
516
+ .flat()
517
+ .flatMap((mark) =>
518
+ mark.outdated && mark.annotationId !== undefined ? [mark.annotationId] : [],
519
+ ),
520
+ ),
521
+ [marks],
522
+ );
523
+ const displaySession = useMemo(
524
+ () =>
525
+ outdatedIds.size === 0
526
+ ? session
527
+ : {
528
+ ...session,
529
+ annotations: session.annotations.map((annotation) =>
530
+ outdatedIds.has(annotation.id) ? { ...annotation, orphan: true } : annotation,
531
+ ),
532
+ },
533
+ [outdatedIds, session],
534
+ );
512
535
  const surface = useAnnotationSurface({
513
536
  source,
514
- session,
537
+ session: displaySession,
515
538
  marks,
516
539
  quickActions,
517
540
  tokens,
@@ -528,7 +551,11 @@ export function DiffContentView({
528
551
  onReply,
529
552
  onUpdateAnnotation,
530
553
  dragViewport: () => scrollBoxDragViewport(scrollRef.current),
531
- resolveAuthorLabel,
554
+ resolveAuthorLabel: (annotation) => {
555
+ const label = resolveAuthorLabel?.(annotation);
556
+
557
+ return annotation.orphan ? `outdated${label ? ` - ${label}` : ""}` : label;
558
+ },
532
559
  onNavCommand,
533
560
  onExit,
534
561
  });
@@ -573,9 +600,11 @@ export function DiffContentView({
573
600
  const revealItem = layout.itemOfRow[surface.revealBlockIndex];
574
601
 
575
602
  useEffect(() => {
576
- if (revealItem !== undefined) virtual.scrollToIndex(revealItem);
603
+ if (revealItem !== undefined) {
604
+ virtual.scrollToIndex(revealItem, surface.compose ? "end" : "auto");
605
+ }
577
606
  // eslint-disable-next-line react-hooks/exhaustive-deps
578
- }, [surface.revealBlockIndex]);
607
+ }, [surface.revealBlockIndex, surface.compose]);
579
608
 
580
609
  /**
581
610
  * The visual lines of one code row painted with gutter, colors, and marks; cards collected after.
@@ -492,6 +492,29 @@ describe("marking across rows", () => {
492
492
  expect(setup.captureCharFrame()).not.toContain("# Plan");
493
493
  expect(highlightedText().length).toBeGreaterThan("daemon".length);
494
494
  });
495
+
496
+ test("opening a comment on the document's last line reveals its composer", async () => {
497
+ setup.renderer.destroy();
498
+ await mount([], 10);
499
+
500
+ for (let step = 0; step < 20; step++) {
501
+ setup.mockInput.pressKey("ARROW_DOWN");
502
+ // eslint-disable-next-line no-await-in-loop
503
+ await settle(setup);
504
+ }
505
+ const lastLine = locate(BULLETS[1]!);
506
+
507
+ await setup.mockMouse.drag(
508
+ lastLine.column,
509
+ lastLine.row,
510
+ lastLine.column + "schema.ts".length,
511
+ lastLine.row,
512
+ );
513
+ await settle(setup);
514
+ await typeText(setup, "bottom comment");
515
+
516
+ expect(setup.captureCharFrame()).toContain("● bottom comment");
517
+ });
495
518
  });
496
519
 
497
520
  describe("marking across blocks", () => {
@@ -244,9 +244,9 @@ export function ThreadView({
244
244
  });
245
245
 
246
246
  useEffect(() => {
247
- virtual.scrollToIndex(surface.revealBlockIndex);
247
+ virtual.scrollToIndex(surface.revealBlockIndex, surface.compose ? "end" : "auto");
248
248
  // eslint-disable-next-line react-hooks/exhaustive-deps
249
- }, [surface.revealBlockIndex]);
249
+ }, [surface.revealBlockIndex, surface.compose]);
250
250
 
251
251
  interface LineContext {
252
252
  blockIndex: number;
@@ -6,7 +6,7 @@ import { WelcomePlayground } from "./WelcomePlayground";
6
6
  export const meta: StoryMeta = { title: "Surfaces/WelcomePlayground" };
7
7
 
8
8
  export const Welcome: Story = {
9
- render: () => <WelcomePlayground version="0.1.0-alpha.66" quickActions={[]} theme={DARK} />,
9
+ render: () => <WelcomePlayground quickActions={[]} theme={DARK} />,
10
10
  expectedColors: [DARK.textDim],
11
11
  size: { width: 72, height: 20 },
12
12
  };
@@ -9,29 +9,31 @@ import { testRender } from "@opentui/react/test-utils";
9
9
  import React from "react";
10
10
  import { WelcomePlayground } from "./WelcomePlayground";
11
11
  import { DARK } from "../theme";
12
+ import { CLIENT_VERSION } from "../version";
12
13
  import { dragText, typeText, pressKey, waitForText } from "../test-support";
13
14
 
14
15
  describe("WelcomePlayground", () => {
15
16
  test("the copy prompts the select-and-type and the slash gestures", async () => {
16
- const setup = await testRender(
17
- <WelcomePlayground version="1.2.3" quickActions={[]} theme={DARK} />,
18
- { width: 80, height: 24 },
19
- );
17
+ const setup = await testRender(<WelcomePlayground quickActions={[]} theme={DARK} />, {
18
+ width: 80,
19
+ height: 24,
20
+ });
20
21
 
21
22
  await waitForText(setup, "Getting started");
22
23
  const frame = setup.captureCharFrame();
23
24
 
24
25
  expect(frame).toContain("start typing to leave your first comment");
25
26
  expect(frame).toContain('type "/"');
27
+ expect(frame).toContain(`cueloop v${CLIENT_VERSION}`);
26
28
 
27
29
  setup.renderer.destroy();
28
30
  });
29
31
 
30
32
  test("selecting the copy and typing leaves a comment inline, exactly like a file", async () => {
31
- const setup = await testRender(
32
- <WelcomePlayground version="1.2.3" quickActions={[]} theme={DARK} />,
33
- { width: 80, height: 24 },
34
- );
33
+ const setup = await testRender(<WelcomePlayground quickActions={[]} theme={DARK} />, {
34
+ width: 80,
35
+ height: 24,
36
+ });
35
37
 
36
38
  // Act - select the practice line, type a comment, send it
37
39
  await waitForText(setup, "quick brown fox");
@@ -14,11 +14,11 @@ import {
14
14
  } from "@cueloop/schema";
15
15
  import type { Theme } from "../theme";
16
16
  import type { QuickAction } from "../config";
17
+ import { CLIENT_VERSION } from "../version";
17
18
  import { DiffContentView } from "./DiffContentView";
18
19
  import { diffRowBlocks, fileContentsRows, marksByRows } from "../view-diff";
19
20
 
20
21
  export interface WelcomePlaygroundProps {
21
- version: string;
22
22
  quickActions: QuickAction[];
23
23
  /** Reports whether the playground composer is open, so the shell suspends its inbox keys while typing. */
24
24
  onComposingChange?: (composing: boolean) => void;
@@ -72,10 +72,7 @@ function playgroundThread(annotations: Annotation[]): Thread {
72
72
 
73
73
  export function WelcomePlayground(props: WelcomePlaygroundProps): React.ReactNode {
74
74
  const [annotations, setAnnotations] = useState<Annotation[]>([]);
75
- const rows = useMemo(
76
- () => fileContentsRows("welcome", welcomeCopy(props.version)),
77
- [props.version],
78
- );
75
+ const rows = useMemo(() => fileContentsRows("welcome", welcomeCopy(CLIENT_VERSION)), []);
79
76
  const session = playgroundThread(annotations);
80
77
 
81
78
  return (
@@ -15,6 +15,7 @@ export interface ButtonProps {
15
15
  isDisabled?: boolean;
16
16
  /** solid = accent-filled call to action; plain = quiet word-button. */
17
17
  variant?: "solid" | "plain" | "accent-text";
18
+ foreground?: string;
18
19
  marginRight?: number;
19
20
  theme?: Theme;
20
21
  children: string;
@@ -24,6 +25,7 @@ export function Button({
24
25
  onPress,
25
26
  isDisabled = false,
26
27
  variant = "plain",
28
+ foreground: foregroundOverride,
27
29
  marginRight,
28
30
  theme,
29
31
  children,
@@ -33,13 +35,14 @@ export function Button({
33
35
  const backgroundColor =
34
36
  variant === "solid" ? (isDisabled ? tokens.border : tokens.accent) : undefined;
35
37
  const foreground =
36
- variant === "solid"
38
+ foregroundOverride ??
39
+ (variant === "solid"
37
40
  ? isDisabled
38
41
  ? tokens.textMuted
39
42
  : tokens.accentInk
40
43
  : variant === "accent-text"
41
44
  ? tokens.accent
42
- : tokens.textDim;
45
+ : tokens.textDim);
43
46
 
44
47
  return (
45
48
  <box
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
@@ -11,6 +11,8 @@ import {
11
11
  persistActions,
12
12
  persistLayout,
13
13
  persistPins,
14
+ persistReviewSkill,
15
+ persistReviewWorkspace,
14
16
  persistTheme,
15
17
  quickActionBody,
16
18
  resolveQuickAction,
@@ -19,6 +21,65 @@ import { DARK } from "./theme";
19
21
  import { themeForName } from "./theme-presets";
20
22
 
21
23
  describe("loadConfig", () => {
24
+ test("review defaults use the bundled skill in an isolated worktree", () => {
25
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
26
+
27
+ expect(config.review).toEqual({ skill: "code-review", workspace: "worktree" });
28
+ });
29
+
30
+ test("review settings load and persist without changing other tables", () => {
31
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-review-config-"));
32
+ const path = join(dir, "config.toml");
33
+
34
+ writeFileSync(path, '[ui]\ntheme = "cueloop"\n\n[review]\nskill = "my-review"\n');
35
+ try {
36
+ persistReviewSkill("code-review", path);
37
+ persistReviewWorkspace("current", path);
38
+
39
+ expect(loadConfig({ userConfigPath: path }).review).toEqual({
40
+ skill: "code-review",
41
+ workspace: "current",
42
+ });
43
+ expect(readFileSync(path, "utf8")).toContain('[ui]\ntheme = "cueloop"');
44
+ } finally {
45
+ rmSync(dir, { recursive: true, force: true });
46
+ }
47
+ });
48
+
49
+ test("review settings update a table whose header has a comment", () => {
50
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-review-commented-config-"));
51
+ const path = join(dir, "config.toml");
52
+
53
+ writeFileSync(path, '[review] # preferred settings\nskill = "my-review"\n');
54
+ try {
55
+ persistReviewSkill("code-review", path);
56
+
57
+ const text = readFileSync(path, "utf8");
58
+
59
+ expect(loadConfig({ userConfigPath: path }).review.skill).toBe("code-review");
60
+ expect(text.match(/^\[review\]/gm)).toHaveLength(1);
61
+ expect(text).toContain("[review] # preferred settings");
62
+ } finally {
63
+ rmSync(dir, { recursive: true, force: true });
64
+ }
65
+ });
66
+
67
+ test("review settings update a CRLF table without duplicating it", () => {
68
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-review-crlf-config-"));
69
+ const path = join(dir, "config.toml");
70
+
71
+ writeFileSync(path, '[review]\r\nskill = "code-review"\r\nworkspace = "worktree"\r\n');
72
+ try {
73
+ persistReviewWorkspace("current", path);
74
+
75
+ const text = readFileSync(path, "utf8");
76
+
77
+ expect(loadConfig({ userConfigPath: path }).review.workspace).toBe("current");
78
+ expect(text.match(/^\[review\]/gm)).toHaveLength(1);
79
+ } finally {
80
+ rmSync(dir, { recursive: true, force: true });
81
+ }
82
+ });
22
83
  test("defaults when no file exists", () => {
23
84
  // Act
24
85
  const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });