@cueloop/client 0.1.0-alpha.66 → 0.1.0-alpha.68

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 (117) hide show
  1. package/package.json +6 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +71 -39
  4. package/src/App.plan-review.test.tsx +20 -21
  5. package/src/App.readonly.test.tsx +7 -3
  6. package/src/App.test.tsx +73 -14
  7. package/src/App.tsx +375 -129
  8. package/src/App.walk.test.tsx +14 -15
  9. package/src/App.workbench.test.tsx +390 -0
  10. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  11. package/src/annotation-palette.ts +61 -0
  12. package/src/app-screens.tsx +176 -45
  13. package/src/app-view-model.ts +7 -65
  14. package/src/components/AnnotationCard.tsx +1 -1
  15. package/src/components/AnnotationCards.stories.tsx +87 -0
  16. package/src/components/AnnotationCards.tsx +303 -0
  17. package/src/components/AppHeader.stories.tsx +50 -0
  18. package/src/components/AppHeader.tsx +83 -0
  19. package/src/components/AppShell.stories.tsx +47 -42
  20. package/src/components/AppShell.tsx +212 -100
  21. package/src/components/AppShell.zoom.test.tsx +94 -0
  22. package/src/components/ChangesColumn.tsx +49 -26
  23. package/src/components/DiffSheet.scroll.test.tsx +38 -55
  24. package/src/components/DiffSheet.split.test.tsx +140 -0
  25. package/src/components/DiffSheet.stories.tsx +55 -53
  26. package/src/components/DiffSheet.syntax.test.tsx +24 -7
  27. package/src/components/DiffSheet.tsx +607 -276
  28. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  29. package/src/components/DiscussionMarkerRail.tsx +181 -0
  30. package/src/components/EditorGrid.stories.tsx +59 -0
  31. package/src/components/EditorGrid.tabs.test.tsx +105 -0
  32. package/src/components/EditorGrid.tsx +338 -0
  33. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  34. package/src/components/FileContentsView.stories.tsx +16 -0
  35. package/src/components/FileContentsView.test.tsx +29 -0
  36. package/src/components/FileContentsView.tsx +78 -0
  37. package/src/components/InboxList.tsx +159 -30
  38. package/src/components/PaneGrid.stories.tsx +84 -0
  39. package/src/components/PaneGrid.tsx +128 -0
  40. package/src/components/PanelColumn.stories.tsx +25 -0
  41. package/src/components/PanelColumn.tsx +95 -0
  42. package/src/components/ProjectTreeView.stories.tsx +25 -0
  43. package/src/components/ProjectTreeView.test.tsx +32 -0
  44. package/src/components/ProjectTreeView.tsx +82 -0
  45. package/src/components/PromptDialog.tsx +8 -1
  46. package/src/components/RootOverlay.stories.tsx +44 -0
  47. package/src/components/RootOverlay.tsx +35 -0
  48. package/src/components/SettingsDialog.tsx +2 -1
  49. package/src/components/SurfaceScrollbar.stories.tsx +34 -0
  50. package/src/components/SurfaceScrollbar.test.tsx +111 -0
  51. package/src/components/SurfaceScrollbar.tsx +93 -0
  52. package/src/components/ThreadFooter.stories.tsx +2 -2
  53. package/src/components/ThreadFooter.test.tsx +31 -0
  54. package/src/components/ThreadFooter.tsx +12 -7
  55. package/src/components/ThreadView.tsx +62 -1440
  56. package/src/components/ThreadsSidebar.tsx +14 -1
  57. package/src/components/Tooltip.stories.tsx +26 -0
  58. package/src/components/Tooltip.tsx +72 -0
  59. package/src/components/WelcomeSurface.stories.tsx +12 -0
  60. package/src/components/WelcomeSurface.tsx +52 -0
  61. package/src/components/__snapshots__/stories.test.tsx.snap +509 -559
  62. package/src/components/diff-sheet-layout.test.ts +125 -55
  63. package/src/components/diff-sheet-layout.ts +3 -103
  64. package/src/components/editor-grid.test.ts +86 -0
  65. package/src/components/editor-grid.ts +138 -0
  66. package/src/components/fade-title.test.ts +49 -0
  67. package/src/components/fade-title.ts +61 -0
  68. package/src/components/file-tree.test.ts +15 -0
  69. package/src/components/file-tree.ts +59 -4
  70. package/src/components/pages/Workspace.stories.tsx +2 -2
  71. package/src/components/primitives/IconButton.tsx +21 -2
  72. package/src/components/primitives/Tree.tsx +13 -4
  73. package/src/components/primitives/icons.ts +27 -4
  74. package/src/components/session-tree.test.ts +20 -0
  75. package/src/components/session-tree.ts +16 -2
  76. package/src/components/stories-app.tsx +2 -2
  77. package/src/config.test.ts +20 -49
  78. package/src/config.ts +26 -28
  79. package/src/diff-fold.test.ts +78 -0
  80. package/src/diff-fold.ts +126 -0
  81. package/src/diff-syntax.test.ts +16 -0
  82. package/src/diff-syntax.ts +6 -3
  83. package/src/discussions.ts +79 -0
  84. package/src/ghostty-terminal.test.ts +2 -1
  85. package/src/ghostty-terminal.ts +27 -4
  86. package/src/intent-dispatch.test.ts +31 -11
  87. package/src/intent-dispatch.ts +93 -48
  88. package/src/key-bindings.test.ts +1 -1
  89. package/src/keymap.test.ts +3 -25
  90. package/src/keymap.ts +17 -18
  91. package/src/mark-runs.ts +120 -0
  92. package/src/pty.ts +10 -2
  93. package/src/run.ts +17 -7
  94. package/src/serve.test.ts +4 -8
  95. package/src/session-controller.curate.test.ts +75 -0
  96. package/src/session-controller.share.test.ts +1 -0
  97. package/src/session-controller.tree.test.ts +26 -0
  98. package/src/session-controller.ts +153 -29
  99. package/src/slash-palette.ts +83 -0
  100. package/src/split-diff.test.ts +110 -0
  101. package/src/split-diff.ts +149 -0
  102. package/src/test-support.test.ts +15 -3
  103. package/src/test-support.ts +66 -15
  104. package/src/thread-chords.test.ts +25 -9
  105. package/src/thread-chords.ts +33 -8
  106. package/src/use-annotation-surface.tsx +837 -0
  107. package/src/use-changes-workbench.ts +151 -0
  108. package/src/use-frame-measure.ts +38 -0
  109. package/src/use-settings-dialog.tsx +14 -24
  110. package/src/use-terminal-virtualizer.ts +233 -0
  111. package/src/view-diff.ts +114 -0
  112. package/src/components/ReviewPanel.stories.tsx +0 -100
  113. package/src/components/ReviewPanel.tsx +0 -103
  114. package/src/components/ReviewRail.stories.tsx +0 -232
  115. package/src/components/ReviewRail.tsx +0 -327
  116. package/src/review-panel.test.ts +0 -113
  117. package/src/review-panel.ts +0 -83
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Fits a thread title onto one line, fading the last few characters toward the
3
+ * background instead of cutting hard or wrapping - a gradient mask on the right.
4
+ * Returns colored segments the caller renders as spans.
5
+ */
6
+
7
+ export interface TitleSegment {
8
+ text: string;
9
+ fg: string;
10
+ }
11
+
12
+ function channel(hex: string, start: number): number {
13
+ return Number.parseInt(hex.slice(start, start + 2), 16);
14
+ }
15
+
16
+ /** Blend two #rrggbb colors; t=0 is `from`, t=1 is `to`. */
17
+ export function mixHex(from: string, to: string, t: number): string {
18
+ const clamp = Math.max(0, Math.min(1, t));
19
+ const parts = [1, 3, 5].map((start) => {
20
+ const value = Math.round(
21
+ channel(from, start) + (channel(to, start) - channel(from, start)) * clamp,
22
+ );
23
+
24
+ return value.toString(16).padStart(2, "0");
25
+ });
26
+
27
+ return `#${parts.join("")}`;
28
+ }
29
+
30
+ /** The tail length that fades out; kept short so most of the title stays legible. */
31
+ const FADE_WIDTH = 5;
32
+
33
+ export function fadeTitle(
34
+ title: string,
35
+ maxWidth: number,
36
+ baseColor: string,
37
+ fadeColor: string,
38
+ ): TitleSegment[] {
39
+ const oneLine = title.replace(/\n/g, " ");
40
+
41
+ if (maxWidth <= 0) return [];
42
+ if (oneLine.length <= maxWidth) return [{ text: oneLine, fg: baseColor }];
43
+
44
+ const clipped = oneLine.slice(0, maxWidth);
45
+ const fadeLength = Math.min(FADE_WIDTH, maxWidth);
46
+ const head = clipped.slice(0, maxWidth - fadeLength);
47
+ const segments: TitleSegment[] = head.length > 0 ? [{ text: head, fg: baseColor }] : [];
48
+
49
+ for (let index = 0; index < fadeLength; index++) {
50
+ // the last character reaches the background fully, so the tail masks out
51
+ // rather than leaving a legible word fragment
52
+ const progress = (index + 1) / fadeLength;
53
+
54
+ segments.push({
55
+ text: clipped[maxWidth - fadeLength + index]!,
56
+ fg: mixHex(baseColor, fadeColor, progress),
57
+ });
58
+ }
59
+
60
+ return segments;
61
+ }
@@ -42,4 +42,19 @@ describe("buildFileTree", () => {
42
42
  test("returns an empty tree for no changes", () => {
43
43
  expect(buildFileTree([])).toEqual([]);
44
44
  });
45
+
46
+ test("badges a leaf with its comment count, and leaves an uncommented file bare", () => {
47
+ // Act
48
+ const tree = buildFileTree(
49
+ [file("a.ts"), file("b.ts")],
50
+ new Map([
51
+ ["a.ts", 3],
52
+ ["b.ts", 0],
53
+ ]),
54
+ );
55
+
56
+ // Assert - a.ts shows the dot-and-count, b.ts (0) shows nothing
57
+ expect(tree[0]!.badge).toBe("● 3");
58
+ expect(tree[1]!.badge).toBeUndefined();
59
+ });
45
60
  });
@@ -16,11 +16,18 @@ interface MutableTreeNode {
16
16
  id: string;
17
17
  label: string;
18
18
  status?: GitStatus;
19
+ badge?: string;
19
20
  children?: Map<string, MutableTreeNode>;
20
21
  }
21
22
 
22
23
  function freeze(node: MutableTreeNode): TreeNode {
23
- if (node.children === undefined) return { id: node.id, label: node.label, status: node.status };
24
+ if (node.children === undefined) {
25
+ const leaf: TreeNode = { id: node.id, label: node.label, status: node.status };
26
+
27
+ if (node.badge !== undefined) leaf.badge = node.badge;
28
+
29
+ return leaf;
30
+ }
24
31
 
25
32
  return {
26
33
  id: node.id,
@@ -29,8 +36,24 @@ function freeze(node: MutableTreeNode): TreeNode {
29
36
  };
30
37
  }
31
38
 
32
- /** Group changed files into a directory tree; leaves are keyed by their full path. */
33
- export function buildFileTree(files: readonly DiffFileContents[]): TreeNode[] {
39
+ /** A file's comment count as a leaf badge, or undefined when it carries none. */
40
+ function commentBadge(
41
+ counts: ReadonlyMap<string, number> | undefined,
42
+ path: string,
43
+ ): string | undefined {
44
+ const count = counts?.get(path);
45
+
46
+ return count ? `● ${count}` : undefined;
47
+ }
48
+
49
+ /**
50
+ * Group changed files into a directory tree; leaves are keyed by their full path. A leaf whose
51
+ * file carries comments shows a dot-and-count badge, so feedback is visible even with its tab closed.
52
+ */
53
+ export function buildFileTree(
54
+ files: readonly DiffFileContents[],
55
+ commentCounts?: ReadonlyMap<string, number>,
56
+ ): TreeNode[] {
34
57
  const roots = new Map<string, MutableTreeNode>();
35
58
 
36
59
  for (const file of files) {
@@ -45,7 +68,39 @@ export function buildFileTree(files: readonly DiffFileContents[]): TreeNode[] {
45
68
 
46
69
  if (node === undefined) {
47
70
  node = isLeaf
48
- ? { id: file.path, label: segment, status: gitStatusOf(file.status) }
71
+ ? {
72
+ id: file.path,
73
+ label: segment,
74
+ status: gitStatusOf(file.status),
75
+ badge: commentBadge(commentCounts, file.path),
76
+ }
77
+ : { id: prefix, label: segment, children: new Map() };
78
+ level.set(segment, node);
79
+ }
80
+ if (!isLeaf) level = node.children!;
81
+ });
82
+ }
83
+
84
+ return [...roots.values()].map(freeze);
85
+ }
86
+
87
+ /** Group plain repo-relative paths (git ls-files) into a directory tree; leaves keep their full path. */
88
+ export function buildPathTree(paths: readonly string[]): TreeNode[] {
89
+ const roots = new Map<string, MutableTreeNode>();
90
+
91
+ for (const path of paths) {
92
+ const segments = path.split("/").filter((segment) => segment.length > 0);
93
+ let level = roots;
94
+ let prefix = "";
95
+
96
+ segments.forEach((segment, index) => {
97
+ prefix = prefix.length > 0 ? `${prefix}/${segment}` : segment;
98
+ const isLeaf = index === segments.length - 1;
99
+ let node = level.get(segment);
100
+
101
+ if (node === undefined) {
102
+ node = isLeaf
103
+ ? { id: path, label: segment }
49
104
  : { id: prefix, label: segment, children: new Map() };
50
105
  level.set(segment, node);
51
106
  }
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
  import { DARK } from "../../theme";
3
3
  import type { Story, StoryMeta } from "../story";
4
- import { AppShell } from "../AppShell";
4
+ import { PaneGrid } from "../PaneGrid";
5
5
  import { Tree } from "../primitives/Tree";
6
6
  import type { TreeNode } from "../primitives/tree-model";
7
7
 
@@ -168,7 +168,7 @@ function Footer(): React.ReactNode {
168
168
 
169
169
  export const ThreePane: Story = {
170
170
  render: () => (
171
- <AppShell
171
+ <PaneGrid
172
172
  sidebar={<Sidebar />}
173
173
  main={<Main />}
174
174
  mainHeader={<Header />}
@@ -3,7 +3,9 @@
3
3
  // center keeps it on the same row as sibling text or icons.
4
4
 
5
5
  import React from "react";
6
+ import type { MouseEvent as TerminalMouseEvent } from "@opentui/core";
6
7
  import { DARK, type Theme } from "../../theme";
8
+ import { useTooltip } from "../Tooltip";
7
9
 
8
10
  export interface IconButtonProps {
9
11
  glyph: string;
@@ -14,6 +16,8 @@ export interface IconButtonProps {
14
16
  disabled?: boolean;
15
17
  /** Override the resolved foreground (wins over active/disabled colouring). */
16
18
  color?: string;
19
+ /** Label shown on hover, surfaced at the screen root so a tiny header cell cannot clip it. */
20
+ tip?: string;
17
21
  marginLeft?: number;
18
22
  marginRight?: number;
19
23
  theme?: Theme;
@@ -25,17 +29,32 @@ export function IconButton({
25
29
  active,
26
30
  disabled,
27
31
  color,
32
+ tip,
28
33
  marginLeft,
29
34
  marginRight,
30
35
  theme,
31
36
  }: IconButtonProps): React.ReactNode {
32
37
  const tokens = theme ?? DARK;
33
38
  const resolved = color ?? (disabled ? tokens.textDim : active ? tokens.accent : tokens.textMuted);
39
+ const { showTooltip, hideTooltip } = useTooltip();
40
+ // a button press is its own action - never let it bubble to a clickable row behind it
41
+ const handleMouseUp = (event: TerminalMouseEvent): void => {
42
+ event.stopPropagation();
43
+ hideTooltip();
44
+ onPress?.();
45
+ };
34
46
 
35
47
  return (
36
48
  <box
37
- onMouseUp={disabled ? undefined : onPress}
38
- style={{ alignSelf: "center", marginLeft, marginRight }}
49
+ onMouseUp={disabled ? undefined : handleMouseUp}
50
+ onMouseOver={tip ? (event) => showTooltip(tip, event.x, event.y) : undefined}
51
+ onMouseOut={tip ? hideTooltip : undefined}
52
+ style={{
53
+ flexShrink: 0,
54
+ alignSelf: "center",
55
+ marginLeft,
56
+ marginRight,
57
+ }}
39
58
  >
40
59
  <text fg={resolved}>{glyph}</text>
41
60
  </box>
@@ -11,6 +11,8 @@ export interface TreeProps {
11
11
  selectedId?: string;
12
12
  flattenEmptyDirectories?: boolean;
13
13
  showStatus?: boolean;
14
+ /** Drop the folder/file glyph prefix, for a plain text tree (e.g. the settings nav). */
15
+ hideIcons?: boolean;
14
16
  indentWidth?: number;
15
17
  icons?: TreeIcons;
16
18
  onSelect?: (id: string) => void;
@@ -38,6 +40,7 @@ export function Tree({
38
40
  selectedId,
39
41
  flattenEmptyDirectories,
40
42
  showStatus,
43
+ hideIcons,
41
44
  indentWidth = 2,
42
45
  icons = NERD_TREE_ICONS,
43
46
  onSelect,
@@ -72,10 +75,16 @@ export function Tree({
72
75
  }}
73
76
  onMouseUp={() => (row.isFolder ? onToggle?.(row.id) : onSelect?.(row.id))}
74
77
  >
75
- <text fg={row.isFolder ? tokens.blue : tokens.textDim}>
76
- {row.icon ?? rowGlyph(row.isFolder, row.expanded, icons)}{" "}
77
- </text>
78
- <text fg={selected ? tokens.accent : labelColor}>{row.label}</text>
78
+ {hideIcons ? null : (
79
+ <text fg={row.isFolder ? tokens.blue : tokens.textDim}>
80
+ {row.icon ?? rowGlyph(row.isFolder, row.expanded, icons)}{" "}
81
+ </text>
82
+ )}
83
+ <box style={{ flexShrink: 1, minWidth: 0 }}>
84
+ <text fg={selected ? tokens.accent : labelColor} truncate>
85
+ {row.label}
86
+ </text>
87
+ </box>
79
88
  <box style={{ flexGrow: 1 }} />
80
89
  {row.badge !== undefined ? <text fg={tokens.textDim}>{row.badge}</text> : null}
81
90
  {status ? <text fg={toneColor(status.tone, tokens)}> {status.letter}</text> : null}
@@ -1,21 +1,44 @@
1
1
  // Nerd Font glyphs (Private Use Area / codicons) plus the plus-minus diff mark; render as icons under a Nerd Font, tofu without one.
2
2
 
3
3
  export const NERD = {
4
- folderClosed: "",
5
- folderOpen: "",
6
- file: "",
4
+ folderClosed: "",
5
+ folderOpen: "",
6
+ file: "",
7
7
  chevronRight: "",
8
8
  chevronDown: "",
9
9
  settings: "",
10
10
  search: "",
11
11
  expand: "",
12
- diff: "±",
12
+ diff: "",
13
+ copy: "",
14
+ unfold: "",
15
+ kebab: "⋮",
16
+ pin: "⚑",
17
+ close: "✕",
13
18
  listTree: "",
14
19
  sidebarLeft: "",
15
20
  sidebarLeftOff: "",
16
21
  sidebarRight: "",
17
22
  sidebarRightOff: "",
18
23
  submit: "",
24
+ zoom: "⛶",
25
+ split: "",
26
+ } as const;
27
+
28
+ // The thin rule under each pane header: an overline sits at the top edge of its cell so it hugs the
29
+ // header text (unlike the default light-horizontal, which floats mid-cell); painted in divider gray upstream.
30
+ export const HEADER_UNDERLINE_CHARS = {
31
+ topLeft: "\u250c",
32
+ topRight: "\u2510",
33
+ bottomLeft: "\u2514",
34
+ bottomRight: "\u2518",
35
+ horizontal: "\u203e",
36
+ vertical: "\u2502",
37
+ topT: "\u252c",
38
+ bottomT: "\u2534",
39
+ leftT: "\u251c",
40
+ rightT: "\u2524",
41
+ cross: "\u253c",
19
42
  } as const;
20
43
 
21
44
  export interface TreeIcons {
@@ -111,4 +111,24 @@ describe("groupInbox", () => {
111
111
  expect(rows.some((row) => row.kind === "section" && row.label === "Projects")).toBe(false);
112
112
  expect(rows.some((row) => row.kind === "section" && row.label === "Threads")).toBe(true);
113
113
  });
114
+
115
+ test("a pinned thread lifts into a Pinned section at the top, out of its normal group", () => {
116
+ // Arrange - one repo-bound thread, one standalone; pin the repo-bound one
117
+ const sessions = [session("a", "Pinned one", "root-1"), session("b", "Loose two")];
118
+
119
+ // Act
120
+ const { rows, ordered } = groupInbox(sessions, new Set(["a"]));
121
+
122
+ // Assert - Pinned is the first section and holds thread a; a is gone from Projects
123
+ const sections = rows
124
+ .filter((row) => row.kind === "section")
125
+ .map((row) => (row.kind === "section" ? row.label : ""));
126
+
127
+ expect(sections[0]).toBe("Pinned");
128
+ expect(sections).not.toContain("Projects"); // its only member was pinned away
129
+ expect(ordered[0]!.id).toBe("a"); // the cursor walks pinned threads first
130
+ const pinnedThreadIds = rows.filter((row) => row.kind === "thread").map((row) => row.id);
131
+
132
+ expect(pinnedThreadIds).toEqual(["a", "b"]);
133
+ });
114
134
  });
@@ -50,12 +50,21 @@ interface ProjectGroup {
50
50
  sessions: ReviewSession[];
51
51
  }
52
52
 
53
- /** Group pending sessions into Projects (by root commit) then standalone Threads. */
54
- export function groupInbox(sessions: readonly ReviewSession[]): GroupedInbox {
53
+ /** Group pending sessions into Pinned, then Projects (by root commit), then standalone Threads. */
54
+ export function groupInbox(
55
+ sessions: readonly ReviewSession[],
56
+ pinnedIds?: ReadonlySet<string>,
57
+ ): GroupedInbox {
58
+ const pinned: ReviewSession[] = [];
55
59
  const projects = new Map<string, ProjectGroup>();
56
60
  const standalone: ReviewSession[] = [];
57
61
 
58
62
  for (const session of sessions) {
63
+ if (pinnedIds?.has(session.id) === true) {
64
+ pinned.push(session);
65
+ continue;
66
+ }
67
+
59
68
  const key = session.workspace.rootCommit;
60
69
  if (key === undefined) {
61
70
  standalone.push(session);
@@ -74,6 +83,11 @@ export function groupInbox(sessions: readonly ReviewSession[]): GroupedInbox {
74
83
  ordered.push(session);
75
84
  };
76
85
 
86
+ if (pinned.length > 0) {
87
+ rows.push({ kind: "section", id: "section:pinned", label: "Pinned" });
88
+ for (const session of pinned) pushThread(session);
89
+ }
90
+
77
91
  const projectEntries = [...projects.entries()].sort((a, b) => a[1].name.localeCompare(b[1].name));
78
92
  if (projectEntries.length > 0) {
79
93
  rows.push({ kind: "section", id: "section:projects", label: "Projects" });
@@ -11,7 +11,7 @@ import { createRoot, useKeyboard } from "@opentui/react";
11
11
  import { DARK } from "../theme";
12
12
  import { ThemeProvider } from "./theme-context";
13
13
  import { buildStoryTree, loadStories, type LoadedStory } from "./story";
14
- import { AppShell } from "./AppShell";
14
+ import { PaneGrid } from "./PaneGrid";
15
15
  import { ShellHeader } from "./ShellHeader";
16
16
  import { Tree } from "./primitives/Tree";
17
17
  import { NERD } from "./primitives/icons";
@@ -103,7 +103,7 @@ function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
103
103
 
104
104
  return (
105
105
  <ThemeProvider theme={DARK}>
106
- <AppShell
106
+ <PaneGrid
107
107
  theme={DARK}
108
108
  mainHeader={
109
109
  <ShellHeader
@@ -8,14 +8,12 @@ import {
8
8
  actionFor,
9
9
  loadConfig,
10
10
  persistAuthorName,
11
- persistReviewState,
12
11
  persistActions,
13
- persistReviewWidth,
12
+ persistPins,
14
13
  persistTheme,
15
14
  quickActionBody,
16
15
  resolveQuickAction,
17
16
  } from "./config";
18
- import { REVIEW_DEFAULT_WIDTH, REVIEW_MAX_WIDTH } from "./review-panel";
19
17
  import { DARK } from "./theme";
20
18
  import { themeForName } from "./theme-presets";
21
19
 
@@ -128,48 +126,19 @@ describe("loadConfig", () => {
128
126
  }
129
127
  });
130
128
 
131
- test("[ui] review panel defaults to an expanded rail at the default width", () => {
132
- // Act
133
- const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
134
-
135
- // Assert
136
- expect(config.ui.reviewState).toBe("expanded");
137
- expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
138
- });
139
-
140
- test("[ui] parses review_width (clamped) and review_state", () => {
141
- // Arrange
142
- const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg6-"));
143
- const path = join(dir, "config.toml");
144
-
145
- writeFileSync(path, `[ui]\nreview_width = 999\nreview_state = "compact"\n`);
146
-
147
- try {
148
- // Act
149
- const config = loadConfig({ userConfigPath: path });
150
-
151
- // Assert
152
- expect(config.ui.reviewWidth).toBe(REVIEW_MAX_WIDTH); // out-of-range width clamps
153
- expect(config.ui.reviewState).toBe("compact");
154
- } finally {
155
- rmSync(dir, { recursive: true, force: true });
156
- }
157
- });
158
-
159
- test("[ui] ignores an unknown review_state and a non-numeric width", () => {
129
+ test("[ui] diff_view defaults to unified and parses split", () => {
160
130
  // Arrange
161
- const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg7-"));
131
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-diff-"));
162
132
  const path = join(dir, "config.toml");
163
133
 
164
- writeFileSync(path, `[ui]\nreview_width = "wide"\nreview_state = "sideways"\n`);
134
+ writeFileSync(path, `[ui]\ndiff_view = "split"\n`);
165
135
 
166
136
  try {
167
- // Act
168
- const config = loadConfig({ userConfigPath: path });
169
-
170
137
  // Assert
171
- expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
172
- expect(config.ui.reviewState).toBe("expanded");
138
+ expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.diffView).toBe(
139
+ "unified",
140
+ );
141
+ expect(loadConfig({ userConfigPath: path }).ui.diffView).toBe("split");
173
142
  } finally {
174
143
  rmSync(dir, { recursive: true, force: true });
175
144
  }
@@ -288,28 +257,30 @@ describe("loadConfig", () => {
288
257
  }
289
258
  });
290
259
 
291
- test("persistReviewWidth and persistReviewState round-trip through the config file", () => {
260
+ test("persistPins round-trips the pinned-thread ids through the config file", () => {
292
261
  // Arrange
293
- const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg8-"));
262
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-pins-"));
294
263
  const path = join(dir, "config.toml");
295
264
 
296
265
  try {
297
266
  // Act
298
- persistReviewWidth(42, path);
299
- persistReviewState("hidden", path);
267
+ persistPins(["s_one", "s_two"], path);
300
268
 
301
269
  // Assert
302
- const config = loadConfig({ userConfigPath: path });
270
+ expect(loadConfig({ userConfigPath: path }).ui.pins).toEqual(["s_one", "s_two"]);
303
271
 
304
- expect(config.ui.reviewWidth).toBe(42);
305
- expect(config.ui.reviewState).toBe("hidden");
272
+ // unpinning rewrites the list in place, including down to empty
273
+ // Act
274
+ persistPins(["s_two"], path);
275
+
276
+ // Assert
277
+ expect(loadConfig({ userConfigPath: path }).ui.pins).toEqual(["s_two"]);
306
278
 
307
- // a second write replaces the key in place rather than appending a duplicate
308
279
  // Act
309
- persistReviewWidth(30, path);
280
+ persistPins([], path);
310
281
 
311
282
  // Assert
312
- expect(loadConfig({ userConfigPath: path }).ui.reviewWidth).toBe(30);
283
+ expect(loadConfig({ userConfigPath: path }).ui.pins).toEqual([]);
313
284
  } finally {
314
285
  rmSync(dir, { recursive: true, force: true });
315
286
  }
package/src/config.ts CHANGED
@@ -2,8 +2,7 @@
2
2
  * Layered TOML config: built-in defaults → user config → trusted repo
3
3
  * config → env. Sections: [keys] action = "combo" (every action rebindable),
4
4
  * [theme] per-token overrides, [ui] auto_close + editor + theme (a named
5
- * preset) + the review-panel layout (review_width + review_state),
6
- * [integrations.obsidian] notes-vault export.
5
+ * preset) + pins, [integrations.obsidian] notes-vault export.
7
6
  */
8
7
 
9
8
  import { existsSync, readFileSync } from "node:fs";
@@ -15,7 +14,6 @@ import { dirname } from "node:path";
15
14
  import * as v from "valibot";
16
15
  import { DARK, type Theme } from "./theme";
17
16
  import { DEFAULT_THEME_NAME, isThemeName, themeForName, type ThemeName } from "./theme-presets";
18
- import { REVIEW_DEFAULT_WIDTH, clampWidth, type ReviewPanelMode } from "./review-panel";
19
17
 
20
18
  export interface KeymapConfig {
21
19
  [action: string]: string | string[];
@@ -28,6 +26,9 @@ export interface IntegrationsConfig {
28
26
  /** Post-submit behavior: "off" prompts, 0 closes instantly, N counts down. */
29
27
  export type AutoClose = "off" | number;
30
28
 
29
+ /** How the Changes diff renders: one inline column, or old|new side by side (only when zoomed). */
30
+ export type DiffViewMode = "unified" | "split";
31
+
31
32
  /** One marker-popover quick action: a preset comment body, plus optional extra lines. */
32
33
  export interface QuickAction {
33
34
  prompt: string;
@@ -93,17 +94,15 @@ export interface CueloopConfig {
93
94
  theme: Theme;
94
95
  /** The `[theme]` per-token overrides alone, so a live theme switch can re-compose them onto a new preset. */
95
96
  themeOverrides: Partial<Theme>;
96
- /**
97
- * ui.reviewState / ui.reviewWidth are CLIENT VIEW STATE: the review panel's
98
- * collapse mode and expanded-rail width, persisted so they survive restarts.
99
- */
100
97
  ui: {
101
98
  autoClose: AutoClose;
102
99
  editor?: string;
103
- reviewState: ReviewPanelMode;
104
- reviewWidth: number;
105
100
  /** The selected theme preset name; its tokens are the base for `theme`, before any `[theme]` overrides. */
106
101
  theme: ThemeName;
102
+ /** How the Changes diff renders when zoomed: one inline column or old|new side by side. */
103
+ diffView: DiffViewMode;
104
+ /** Session ids the user has pinned to the top of the sidebar; client-local view state. */
105
+ pins: string[];
107
106
  };
108
107
  /** Planner-local author renames: identity id → display name ([authors] table). */
109
108
  authors: Record<string, string>;
@@ -123,6 +122,9 @@ export const DEFAULT_KEYS: CueloopConfig["keys"] = {
123
122
  cut: ["x"],
124
123
  reject_hunk: ["X"],
125
124
  restore_curation: ["u"],
125
+ collapse_file: ["right"],
126
+ expand_file: ["left"],
127
+ split_diff: ["s"],
126
128
  edit: ["e"],
127
129
  next_annotation: ["n"],
128
130
  prev_annotation: ["p"],
@@ -132,9 +134,6 @@ export const DEFAULT_KEYS: CueloopConfig["keys"] = {
132
134
  share: ["S"],
133
135
  quit: ["q"],
134
136
  walk: ["w"],
135
- review_cycle: ["b"],
136
- review_wider: ["]"],
137
- review_narrower: ["["],
138
137
  };
139
138
 
140
139
  const ConfigDocumentSchema = v.object({
@@ -165,9 +164,9 @@ const UiSchema = v.object({
165
164
  undefined,
166
165
  ),
167
166
  editor: v.fallback(v.optional(v.string()), undefined),
168
- review_width: v.fallback(v.optional(v.pipe(v.number(), v.finite())), undefined),
169
- review_state: v.fallback(v.optional(v.picklist(["expanded", "compact", "hidden"])), undefined),
170
167
  theme: v.fallback(v.optional(v.string()), undefined),
168
+ diff_view: v.fallback(v.optional(v.picklist(["unified", "split"])), undefined),
169
+ pins: v.fallback(v.optional(v.array(v.string())), undefined),
171
170
  });
172
171
  const ObsidianSchema = v.object({
173
172
  vault: v.fallback(v.optional(v.string()), undefined),
@@ -252,9 +251,8 @@ function layer(
252
251
  if (ui.success) {
253
252
  if (ui.output.auto_close !== undefined) out.ui.autoClose = ui.output.auto_close;
254
253
  if (ui.output.editor?.trim()) out.ui.editor = ui.output.editor.trim();
255
- if (ui.output.review_width !== undefined)
256
- out.ui.reviewWidth = clampWidth(ui.output.review_width);
257
- if (ui.output.review_state !== undefined) out.ui.reviewState = ui.output.review_state;
254
+ if (ui.output.diff_view !== undefined) out.ui.diffView = ui.output.diff_view;
255
+ if (ui.output.pins !== undefined) out.ui.pins = ui.output.pins;
258
256
  }
259
257
  if (integrations.success && integrations.output.obsidian) {
260
258
  mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
@@ -272,9 +270,9 @@ export function loadConfig(
272
270
  themeOverrides: {},
273
271
  ui: {
274
272
  autoClose: "off",
275
- reviewState: "expanded",
276
- reviewWidth: REVIEW_DEFAULT_WIDTH,
277
273
  theme: DEFAULT_THEME_NAME,
274
+ diffView: "unified",
275
+ pins: [],
278
276
  },
279
277
  authors: {},
280
278
  actions: [...DEFAULT_QUICK_ACTIONS],
@@ -403,19 +401,19 @@ export function persistAutoClose(value: AutoClose, userConfigPath?: string): voi
403
401
  persistUiSetting("auto_close", value === "off" ? '"off"' : String(value), userConfigPath);
404
402
  }
405
403
 
406
- /** Persist the expanded-rail width (`[ui] review_width`) into the user config. */
407
- export function persistReviewWidth(width: number, userConfigPath?: string): void {
408
- persistUiSetting("review_width", String(clampWidth(width)), userConfigPath);
404
+ /** Persist the selected theme preset (`[ui] theme`) into the user config. */
405
+ export function persistTheme(name: ThemeName, userConfigPath?: string): void {
406
+ persistUiSetting("theme", `"${name}"`, userConfigPath);
409
407
  }
410
408
 
411
- /** Persist the review-panel collapse mode (`[ui] review_state`) into the config. */
412
- export function persistReviewState(state: ReviewPanelMode, userConfigPath?: string): void {
413
- persistUiSetting("review_state", `"${state}"`, userConfigPath);
409
+ /** Persist the diff-view choice (`[ui] diff_view`) into the user config. */
410
+ export function persistDiffView(mode: DiffViewMode, userConfigPath?: string): void {
411
+ persistUiSetting("diff_view", `"${mode}"`, userConfigPath);
414
412
  }
415
413
 
416
- /** Persist the selected theme preset (`[ui] theme`) into the user config. */
417
- export function persistTheme(name: ThemeName, userConfigPath?: string): void {
418
- persistUiSetting("theme", `"${name}"`, userConfigPath);
414
+ /** Persist the pinned-thread ids (`[ui] pins`) into the user config. */
415
+ export function persistPins(ids: readonly string[], userConfigPath?: string): void {
416
+ persistUiSetting("pins", `[${ids.map(tomlString).join(", ")}]`, userConfigPath);
419
417
  }
420
418
 
421
419
  function escapeRegExp(text: string): string {