@cueloop/client 0.1.0-alpha.16 → 0.1.0-alpha.18

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.
@@ -13,7 +13,7 @@ import type { Annotation } from "@cueloop/schema";
13
13
  import type { DiffRow } from "../view-diff";
14
14
  import type { Theme } from "../theme";
15
15
  import { useComponentTheme } from "./theme-context";
16
- import { truncate } from "./format";
16
+ import { truncateToSingleLine } from "./truncate-text";
17
17
 
18
18
  export interface DiffSheetProps {
19
19
  rows: DiffRow[];
@@ -43,7 +43,7 @@ function segmentRows(rows: DiffRow[], annotatedByRow: Map<number, Annotation>):
43
43
  };
44
44
  for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
45
45
  const row = rows[rowIndex]!;
46
- if (row.t === "file" || row.t === "hunk") {
46
+ if (row.kind === "file" || row.kind === "hunk") {
47
47
  closeChunk(null);
48
48
  segments.push({ kind: "header", rowIndex, row });
49
49
  chunkStart = rowIndex + 1;
@@ -79,7 +79,7 @@ function DiffChunk({
79
79
  const lineNumbers = useMemo(() => {
80
80
  const numbers = new Map<number, number>();
81
81
  segment.rows.forEach((row, lineIndex) => {
82
- const gutterNumber = row.t === "del" ? row.oldLine : row.newLine;
82
+ const gutterNumber = row.kind === "del" ? row.oldLine : row.newLine;
83
83
  if (gutterNumber !== undefined) numbers.set(lineIndex, gutterNumber);
84
84
  });
85
85
  return numbers;
@@ -132,8 +132,8 @@ function DiffChunk({
132
132
  >
133
133
  <text style={{ wrapMode: "none" }} selectable={false}>
134
134
  {segment.rows.map((row, lineIndex) => {
135
- const sign = row.t === "add" ? "+" : row.t === "del" ? "-" : " ";
136
- const foreground = row.t === "add" ? tokens.insFg : row.t === "del" ? tokens.delFg : tokens.textMuted;
135
+ const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
136
+ const foreground = row.kind === "add" ? tokens.insFg : row.kind === "del" ? tokens.delFg : tokens.textMuted;
137
137
  const isCursorRow = lineIndex === cursorInChunk;
138
138
  const isAnnotatedRow = segment.annotation !== null && lineIndex === segment.rows.length - 1;
139
139
  return (
@@ -152,7 +152,7 @@ function DiffChunk({
152
152
  <text>
153
153
  <span fg={tokens.textDim}>{" "}</span>
154
154
  <span fg={segment.annotation.id === focusedAnnotationId ? tokens.text : tokens.accent}>
155
- ◆ {truncate(segment.annotation.body, 70)}
155
+ ◆ {truncateToSingleLine(segment.annotation.body, 70)}
156
156
  </span>
157
157
  </text>
158
158
  ) : null}
@@ -168,7 +168,7 @@ export function DiffSheet({ rows, cursor, annotations, focusedAnnotationId, them
168
168
  const byRow = new Map<number, Annotation>();
169
169
  for (const annotation of annotations) {
170
170
  const rowIndex = rows.findIndex(
171
- (row) => row.text === annotation.anchor.quote && (row.t === "ctx" || row.t === "add" || row.t === "del"),
171
+ (row) => row.text === annotation.anchor.quote && (row.kind === "ctx" || row.kind === "add" || row.kind === "del"),
172
172
  );
173
173
  if (rowIndex !== -1) byRow.set(rowIndex, annotation);
174
174
  }
@@ -213,7 +213,7 @@ export function DiffSheet({ rows, cursor, annotations, focusedAnnotationId, them
213
213
  {segments.map((segment, segmentIndex) => {
214
214
  if (segment.kind === "header") {
215
215
  const isCursor = segment.rowIndex === cursor;
216
- if (segment.row.t === "file") {
216
+ if (segment.row.kind === "file") {
217
217
  return (
218
218
  <text key={segmentIndex} fg={tokens.text} bg={isCursor ? tokens.cursorBg : tokens.panel}>
219
219
  {isCursor ? "▎" : " "}■ {rowLine(segment.row)}
@@ -32,7 +32,7 @@ export const SpanSelection: Story = {
32
32
  display={fixtureDisplay()}
33
33
  marks={new Map()}
34
34
  cursor={2}
35
- activeSpan={{ dispIdx: 2, start: 0, end: 10 }}
35
+ activeSpan={{ displayIndex: 2, start: 0, end: 10 }}
36
36
  compose={null}
37
37
  editOrphanCount={0}
38
38
  {...callbacks}
@@ -49,10 +49,10 @@ export const InlineCompose: Story = {
49
49
  display={fixtureDisplay()}
50
50
  marks={new Map()}
51
51
  cursor={2}
52
- activeSpan={{ dispIdx: 2, start: 0, end: 10 }}
52
+ activeSpan={{ displayIndex: 2, start: 0, end: 10 }}
53
53
  compose={{
54
54
  kind: "comment",
55
- dispIdx: 2,
55
+ displayIndex: 2,
56
56
  quote: "The daemon",
57
57
  draft: { text: "Which daemon?", onInput: () => {}, onSave: () => {}, onCancel: () => {} },
58
58
  }}
@@ -23,7 +23,7 @@ import {
23
23
  type Mark,
24
24
  type SpanState,
25
25
  type StyleRun,
26
- } from "../view";
26
+ } from "../view-plan";
27
27
  import type { Theme } from "../theme";
28
28
  import { useComponentTheme } from "./theme-context";
29
29
  import { CodeBlock } from "./CodeBlock";
@@ -32,7 +32,7 @@ import { Button } from "./primitives/Button";
32
32
  import { Toolbar } from "./primitives/Toolbar";
33
33
 
34
34
  export interface PlanSelection {
35
- dispIdx: number;
35
+ displayIndex: number;
36
36
  start: number;
37
37
  end: number;
38
38
  }
@@ -43,12 +43,12 @@ export interface PlanSheetHandle {
43
43
  /** Anchor/extend the renderer's native selection from keyboard span offsets. */
44
44
  driveSpanSelection(span: SpanState): void;
45
45
  clearSelection(): void;
46
- revealBlock(dispIdx: number): void;
46
+ revealBlock(displayIndex: number): void;
47
47
  }
48
48
 
49
49
  export interface PlanComposeState {
50
50
  kind: "comment" | "suggestion";
51
- dispIdx: number;
51
+ displayIndex: number;
52
52
  quote: string;
53
53
  draft: AnnotationDraft;
54
54
  }
@@ -59,10 +59,10 @@ export interface PlanSheetProps {
59
59
  marks: Map<number, Mark[]>;
60
60
  cursor: number;
61
61
  /** Extra selection-style paint on one block (keyboard span or compose anchor). */
62
- activeSpan: { dispIdx: number; start: number; end: number } | null;
62
+ activeSpan: { displayIndex: number; start: number; end: number } | null;
63
63
  compose: PlanComposeState | null;
64
64
  editOrphanCount: number;
65
- onLineActivate: (dispIdx: number) => void;
65
+ onLineActivate: (displayIndex: number) => void;
66
66
  onEditRequest: () => void;
67
67
  theme?: Theme;
68
68
  }
@@ -110,17 +110,17 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
110
110
  readSelection: (): PlanSelection | null => {
111
111
  if (!renderer?.hasSelection) return null;
112
112
  const ordered = [...blockRefs.current.entries()].sort(([a], [b]) => a - b);
113
- for (const [dispIdx, blockRef] of ordered) {
113
+ for (const [displayIndex, blockRef] of ordered) {
114
114
  const selection = blockRef.renderable.getSelection();
115
115
  if (!selection || selection.end <= selection.start) continue;
116
116
  const range = workRangeForRendered(blockRef.runs, selection.start, selection.end);
117
- if (range) return { dispIdx, ...range };
117
+ if (range) return { displayIndex, ...range };
118
118
  }
119
119
  return null;
120
120
  },
121
121
  driveSpanSelection: (span: SpanState): void => {
122
122
  if (!renderer) return;
123
- const blockRef = blockRefs.current.get(span.dispIdx);
123
+ const blockRef = blockRefs.current.get(span.displayIndex);
124
124
  if (!blockRef) return;
125
125
  const renderedStart = renderedOffsetFor(blockRef.runs, span.start);
126
126
  const renderedEnd = renderedOffsetFor(blockRef.runs, span.end - 1);
@@ -133,48 +133,48 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
133
133
  clearSelection: (): void => {
134
134
  renderer?.clearSelection();
135
135
  },
136
- revealBlock: (dispIdx: number): void => {
136
+ revealBlock: (displayIndex: number): void => {
137
137
  try {
138
- scrollRef.current?.scrollChildIntoView(`plan-block-${dispIdx}`);
138
+ scrollRef.current?.scrollChildIntoView(`plan-block-${displayIndex}`);
139
139
  } catch {
140
140
  // reveal is best-effort; selection state is already correct
141
141
  }
142
142
  },
143
143
  }));
144
144
 
145
- const registerBlock = (dispIdx: number, renderable: TextRenderable | null, runs: StyleRun[]): void => {
146
- if (renderable) blockRefs.current.set(dispIdx, { renderable, runs });
147
- else blockRefs.current.delete(dispIdx);
145
+ const registerBlock = (displayIndex: number, renderable: TextRenderable | null, runs: StyleRun[]): void => {
146
+ if (renderable) blockRefs.current.set(displayIndex, { renderable, runs });
147
+ else blockRefs.current.delete(displayIndex);
148
148
  };
149
149
  // stale refs must not survive a shrinking display list
150
150
  useEffect(() => {
151
- for (const dispIdx of blockRefs.current.keys()) {
152
- if (dispIdx >= display.length) blockRefs.current.delete(dispIdx);
151
+ for (const displayIndex of blockRefs.current.keys()) {
152
+ if (displayIndex >= display.length) blockRefs.current.delete(displayIndex);
153
153
  }
154
154
  }, [display]);
155
155
 
156
156
  const children: React.ReactNode[] = [];
157
- for (let dispIdx = 0; dispIdx < display.length; dispIdx++) {
158
- const block = display[dispIdx]!;
159
- const isCursor = dispIdx === cursor;
160
- const gap = topGap(display[dispIdx - 1], block);
157
+ for (let displayIndex = 0; displayIndex < display.length; displayIndex++) {
158
+ const block = display[displayIndex]!;
159
+ const isCursor = displayIndex === cursor;
160
+ const gap = topGap(display[displayIndex - 1], block);
161
161
  if (block.kind === "code") {
162
162
  children.push(
163
163
  <CodeBlock
164
- key={dispIdx}
165
- id={`plan-block-${dispIdx}`}
164
+ key={displayIndex}
165
+ id={`plan-block-${displayIndex}`}
166
166
  language={(block.work ?? block.base)?.lang}
167
167
  content={displayText(block)}
168
168
  isCursor={isCursor}
169
169
  marginTop={gap}
170
- isAnnotated={(marks.get(dispIdx) ?? []).length > 0}
170
+ isAnnotated={(marks.get(displayIndex) ?? []).length > 0}
171
171
  changeTag={block.type !== "same" ? tagLabel(block) : undefined}
172
172
  theme={theme}
173
173
  />,
174
174
  );
175
175
  } else {
176
- const blockMarks = [...(marks.get(dispIdx) ?? [])];
177
- if (activeSpan && activeSpan.dispIdx === dispIdx) {
176
+ const blockMarks = [...(marks.get(displayIndex) ?? [])];
177
+ if (activeSpan && activeSpan.displayIndex === displayIndex) {
178
178
  blockMarks.push({ start: activeSpan.start, end: activeSpan.end, role: "kspan" });
179
179
  }
180
180
  const runs = overlayMarks(blockRuns(block, true), blockMarks);
@@ -182,7 +182,7 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
182
182
  const mappedRuns: StyleRun[] =
183
183
  block.type !== "same" ? [...runs, { text: ` [${tagLabel(block)}]`, role: "plain", start: null }] : runs;
184
184
  children.push(
185
- <box key={dispIdx} id={`plan-block-${dispIdx}`} style={{ flexDirection: "row", marginTop: gap }}>
185
+ <box key={displayIndex} id={`plan-block-${displayIndex}`} style={{ flexDirection: "row", marginTop: gap }}>
186
186
  <text selectable={false}>
187
187
  <span fg={isCursor ? tokens.accent : tokens.textDim}>{isCursor ? "▎ " : " "}</span>
188
188
  <span fg={tokens.textDim}>{marker(block)}</span>
@@ -193,8 +193,8 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
193
193
  selectionBg={tokens.accent}
194
194
  selectionFg={tokens.accentInk}
195
195
  style={{ wrapMode: "word", flexGrow: 1, flexShrink: 1 }}
196
- ref={(renderable: TextRenderable | null) => registerBlock(dispIdx, renderable, mappedRuns)}
197
- onMouseUp={() => onLineActivate(dispIdx)}
196
+ ref={(renderable: TextRenderable | null) => registerBlock(displayIndex, renderable, mappedRuns)}
197
+ onMouseUp={() => onLineActivate(displayIndex)}
198
198
  >
199
199
  {runs.map((run, runIndex) => (
200
200
  <span key={runIndex} {...runStyle(run, block, tokens)}>
@@ -206,9 +206,9 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
206
206
  </box>,
207
207
  );
208
208
  }
209
- if (compose && compose.dispIdx === dispIdx) {
209
+ if (compose && compose.displayIndex === displayIndex) {
210
210
  children.push(
211
- <AnnotationCard key={`compose-${dispIdx}`} kind={compose.kind} quote={compose.quote} draft={compose.draft} theme={theme} />,
211
+ <AnnotationCard key={`compose-${displayIndex}`} kind={compose.kind} quote={compose.quote} draft={compose.draft} theme={theme} />,
212
212
  );
213
213
  }
214
214
  }
@@ -274,7 +274,7 @@ function topGap(previous: DisplayBlock | undefined, current: DisplayBlock): numb
274
274
 
275
275
  function marker(block: DisplayBlock): string {
276
276
  if (block.kind === "li") return "- ";
277
- if (block.kind === "oli") return `${block.oliNum ?? 1}. `;
277
+ if (block.kind === "oli") return `${block.orderedItemNumber ?? 1}. `;
278
278
  if (block.kind === "quote") return "▏ ";
279
279
  return "";
280
280
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The guided walk's focused card wizard (#83): one card per changed file
2
+ * The guided walk's focused card wizard: one card per changed file
3
3
  * floating over the dimmed diff, with a plain step count in the title - no
4
4
  * progress bar. Below the main card a separate gray-bordered block carries
5
5
  * the submitting agent's note for the file, when one exists. Past the last
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The catalog IS the regression suite (#86): every exported story renders in
2
+ * The catalog IS the regression suite: every exported story renders in
3
3
  * a virtual terminal, its char frame snapshots, and its declared colors must
4
4
  * appear among the styled spans. A component file without a colocated
5
5
  * stories file fails the harness, so the safety net cannot silently thin.
@@ -1,7 +1,7 @@
1
1
  /** Shared fixture data for the stories catalog: one plan session, one diff. */
2
2
 
3
3
  import type { Annotation, ReviewSession } from "@cueloop/schema";
4
- import { buildDisplay, marksByDisplay } from "../view";
4
+ import { buildDisplay, marksByDisplay } from "../view-plan";
5
5
  import { diffRows } from "../view-diff";
6
6
 
7
7
  export const FIXTURE_PLAN = `# Migration Plan
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The story contract (#86). Every component file in components/ ships a
2
+ * The story contract. Every component file in components/ ships a
3
3
  * colocated *.stories.tsx that exports `meta` plus named Story objects.
4
4
  * The catalog IS the regression suite: stories.test.tsx walks every story,
5
5
  * snapshots its char frame, and asserts declared colors against the styled
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Code highlighting tokens (#86): the theme's named tokens projected onto
2
+ * Code highlighting tokens: the theme's named tokens projected onto
3
3
  * tree-sitter capture names for the native code renderable. One SyntaxStyle
4
4
  * per theme object, cached, so every code block on screen shares a handle.
5
5
  * Unknown languages simply render unstyled - highlighting is an enhancement,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Provider theming for the component system (#86). The context default is the
2
+ * Provider theming for the component system. The context default is the
3
3
  * built-in dark theme, so embedding a component (or rendering a story) needs
4
4
  * no provider at all. One ThemeProvider swap switches the live theme; every
5
5
  * component also accepts an optional `theme` prop that overrides the context
@@ -0,0 +1,6 @@
1
+ /** Truncate text to one line of at most maxLength characters, ellipsis-terminated. */
2
+
3
+ export function truncateToSingleLine(value: string, maxLength: number): string {
4
+ const oneLine = value.replace(/\n/g, " ");
5
+ return oneLine.length > maxLength ? oneLine.slice(0, maxLength - 1) + "…" : oneLine;
6
+ }
@@ -7,9 +7,9 @@ import { DARK } from "./theme";
7
7
 
8
8
  describe("loadConfig", () => {
9
9
  test("defaults when no file exists", () => {
10
- const c = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
11
- expect(c.keys["comment"]).toEqual(["c"]);
12
- expect(c.theme.accent).toBe(DARK.accent);
10
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
11
+ expect(config.keys["comment"]).toEqual(["c"]);
12
+ expect(config.theme.accent).toBe(DARK.accent);
13
13
  });
14
14
 
15
15
  test("user config rebinds actions and overrides theme tokens", () => {
@@ -20,12 +20,12 @@ describe("loadConfig", () => {
20
20
  `[keys]\ncomment = "a"\nsubmit = ["return", "S"]\n\n[theme]\naccent = "#ff0000"\n`,
21
21
  );
22
22
  try {
23
- const c = loadConfig({ userConfigPath: path });
24
- expect(c.keys["comment"]).toEqual(["a"]);
25
- expect(c.keys["submit"]).toEqual(["return", "S"]);
26
- expect(c.keys["cut"]).toEqual(["x"]); // untouched defaults survive
27
- expect(c.theme.accent).toBe("#ff0000");
28
- expect(c.theme.green).toBe(DARK.green);
23
+ const config = loadConfig({ userConfigPath: path });
24
+ expect(config.keys["comment"]).toEqual(["a"]);
25
+ expect(config.keys["submit"]).toEqual(["return", "S"]);
26
+ expect(config.keys["cut"]).toEqual(["x"]); // untouched defaults survive
27
+ expect(config.theme.accent).toBe("#ff0000");
28
+ expect(config.theme.green).toBe(DARK.green);
29
29
  } finally {
30
30
  rmSync(dir, { recursive: true, force: true });
31
31
  }
@@ -39,8 +39,8 @@ describe("loadConfig", () => {
39
39
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
40
40
  writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[keys]\ncomment = "z"\n`);
41
41
  try {
42
- const c = loadConfig({ userConfigPath: user, repoRoot });
43
- expect(c.keys["comment"]).toEqual(["z"]);
42
+ const config = loadConfig({ userConfigPath: user, repoRoot });
43
+ expect(config.keys["comment"]).toEqual(["z"]);
44
44
  } finally {
45
45
  rmSync(dir, { recursive: true, force: true });
46
46
  }
@@ -51,8 +51,8 @@ describe("loadConfig", () => {
51
51
  const path = join(dir, "config.toml");
52
52
  writeFileSync(path, "not [valid toml");
53
53
  try {
54
- const c = loadConfig({ userConfigPath: path });
55
- expect(c.keys["comment"]).toEqual(["c"]);
54
+ const config = loadConfig({ userConfigPath: path });
55
+ expect(config.keys["comment"]).toEqual(["c"]);
56
56
  } finally {
57
57
  rmSync(dir, { recursive: true, force: true });
58
58
  }
@@ -61,12 +61,12 @@ describe("loadConfig", () => {
61
61
 
62
62
  describe("integrations.obsidian config", () => {
63
63
  test("defaults when the section is absent", () => {
64
- const c = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
65
- expect(c.integrations.obsidian.vault).toBeUndefined();
66
- expect(c.integrations.obsidian.folder).toBe("cueloop");
67
- expect(c.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
68
- expect(c.integrations.obsidian.separator).toBe("space");
69
- expect(c.integrations.obsidian.exportOn).toBe("manual");
64
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
65
+ expect(config.integrations.obsidian.vault).toBeUndefined();
66
+ expect(config.integrations.obsidian.folder).toBe("cueloop");
67
+ expect(config.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
68
+ expect(config.integrations.obsidian.separator).toBe("space");
69
+ expect(config.integrations.obsidian.exportOn).toBe("manual");
70
70
  });
71
71
 
72
72
  test("user config sets the section; invalid enum values are ignored", () => {
@@ -77,12 +77,12 @@ describe("integrations.obsidian config", () => {
77
77
  `[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approve"\nseparator = "comma"\n`,
78
78
  );
79
79
  try {
80
- const c = loadConfig({ userConfigPath: path });
81
- expect(c.integrations.obsidian.vault).toBe("/notes/vault");
82
- expect(c.integrations.obsidian.folder).toBe("plans");
83
- expect(c.integrations.obsidian.exportOn).toBe("approve");
84
- expect(c.integrations.obsidian.separator).toBe("space"); // invalid value falls back
85
- expect(c.keys["comment"]).toEqual(["c"]); // other sections untouched
80
+ const config = loadConfig({ userConfigPath: path });
81
+ expect(config.integrations.obsidian.vault).toBe("/notes/vault");
82
+ expect(config.integrations.obsidian.folder).toBe("plans");
83
+ expect(config.integrations.obsidian.exportOn).toBe("approve");
84
+ expect(config.integrations.obsidian.separator).toBe("space"); // invalid value falls back
85
+ expect(config.keys["comment"]).toEqual(["c"]); // other sections untouched
86
86
  } finally {
87
87
  rmSync(dir, { recursive: true, force: true });
88
88
  }
@@ -96,10 +96,10 @@ describe("integrations.obsidian config", () => {
96
96
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
97
97
  writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[integrations.obsidian]\nfolder = "repo-plans"\n`);
98
98
  try {
99
- const c = loadConfig({ userConfigPath: user, repoRoot });
100
- expect(c.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
101
- expect(c.integrations.obsidian.exportOn).toBe("resolve");
102
- expect(c.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
99
+ const config = loadConfig({ userConfigPath: user, repoRoot });
100
+ expect(config.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
101
+ expect(config.integrations.obsidian.exportOn).toBe("resolve");
102
+ expect(config.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
103
103
  } finally {
104
104
  rmSync(dir, { recursive: true, force: true });
105
105
  }
package/src/config.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Layered TOML config (#20): built-in defaults → user config → trusted repo
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, [integrations.obsidian] notes-vault export.
5
5
  */
@@ -83,19 +83,19 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
83
83
  const integrations = raw["integrations"] as Record<string, unknown> | undefined;
84
84
  const obsidian = integrations?.["obsidian"] as Record<string, unknown> | undefined;
85
85
  if (obsidian) {
86
- const o = out.integrations.obsidian;
87
- if (typeof obsidian["vault"] === "string") o.vault = obsidian["vault"];
88
- if (typeof obsidian["folder"] === "string") o.folder = obsidian["folder"];
89
- if (typeof obsidian["filenameFormat"] === "string") o.filenameFormat = obsidian["filenameFormat"];
90
- const sep = obsidian["separator"];
91
- if (sep === "space" || sep === "dash" || sep === "underscore") o.separator = sep;
92
- const on = obsidian["exportOn"];
93
- if (on === "approve" || on === "resolve" || on === "manual") o.exportOn = on;
86
+ const obsidianConfig = out.integrations.obsidian;
87
+ if (typeof obsidian["vault"] === "string") obsidianConfig.vault = obsidian["vault"];
88
+ if (typeof obsidian["folder"] === "string") obsidianConfig.folder = obsidian["folder"];
89
+ if (typeof obsidian["filenameFormat"] === "string") obsidianConfig.filenameFormat = obsidian["filenameFormat"];
90
+ const separator = obsidian["separator"];
91
+ if (separator === "space" || separator === "dash" || separator === "underscore") obsidianConfig.separator = separator;
92
+ const exportOn = obsidian["exportOn"];
93
+ if (exportOn === "approve" || exportOn === "resolve" || exportOn === "manual") obsidianConfig.exportOn = exportOn;
94
94
  }
95
95
  return out;
96
96
  }
97
97
 
98
- export function loadConfig(opts: { repoRoot?: string; userConfigPath?: string } = {}): CueloopConfig {
98
+ export function loadConfig(options: { repoRoot?: string; userConfigPath?: string } = {}): CueloopConfig {
99
99
  let config: CueloopConfig = {
100
100
  keys: { ...DEFAULT_KEYS },
101
101
  theme: { ...DARK },
@@ -103,10 +103,10 @@ export function loadConfig(opts: { repoRoot?: string; userConfigPath?: string }
103
103
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
104
104
  };
105
105
  const userPath =
106
- opts.userConfigPath ??
106
+ options.userConfigPath ??
107
107
  process.env.CUELOOP_CONFIG ??
108
108
  join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml");
109
- for (const path of [userPath, opts.repoRoot ? join(opts.repoRoot, ".cueloop", "config.toml") : undefined]) {
109
+ for (const path of [userPath, options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined]) {
110
110
  if (!path || !existsSync(path)) continue;
111
111
  try {
112
112
  config = layer(config, parseToml(readFileSync(path, "utf8")));
package/src/editor.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * File-level edit mode = the $EDITOR hand-off (#22, #11): write the working
2
+ * File-level edit mode = the $EDITOR hand-off: write the working
3
3
  * copy to a temp file, run the user's editor on it, read the result back.
4
4
  * Tests point CUELOOP_EDITOR at a non-interactive command.
5
5
  */
package/src/herdr.test.ts CHANGED
@@ -2,46 +2,17 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { focusHerdrPane, returnPaneFor } from "./herdr";
5
+ import { focusHerdrPane } from "./herdr";
6
6
 
7
7
  let dir: string;
8
- const savedEnv: Record<string, string | undefined> = {};
9
8
 
10
9
  beforeEach(() => {
11
10
  dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-"));
12
- for (const k of ["HERDR_ENV", "HERDR_PANE_ID", "HERDR_BIN_PATH", "CUELOOP_RETURN_PANE"]) {
13
- savedEnv[k] = process.env[k];
14
- delete process.env[k];
15
- }
16
11
  });
17
12
  afterEach(() => {
18
- for (const [k, v] of Object.entries(savedEnv)) {
19
- if (v === undefined) delete process.env[k];
20
- else process.env[k] = v;
21
- }
22
13
  rmSync(dir, { recursive: true, force: true });
23
14
  });
24
15
 
25
- describe("returnPaneFor", () => {
26
- test("outside herdr there is never a return target", () => {
27
- expect(returnPaneFor("w1:p1")).toBeUndefined();
28
- });
29
-
30
- test("env override wins, session meta is the fallback", () => {
31
- process.env.HERDR_ENV = "1";
32
- process.env.HERDR_PANE_ID = "w1:p9";
33
- expect(returnPaneFor("w1:p1")).toBe("w1:p1");
34
- process.env.CUELOOP_RETURN_PANE = "w1:p2";
35
- expect(returnPaneFor("w1:p1")).toBe("w1:p2");
36
- });
37
-
38
- test("returning to our own pane is a no-op", () => {
39
- process.env.HERDR_ENV = "1";
40
- process.env.HERDR_PANE_ID = "w1:p1";
41
- expect(returnPaneFor("w1:p1")).toBeUndefined();
42
- });
43
- });
44
-
45
16
  describe("focusHerdrPane", () => {
46
17
  test("resolves the pane's tab and focuses it", () => {
47
18
  const log = join(dir, "calls.log");
@@ -54,15 +25,13 @@ if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
54
25
  `,
55
26
  );
56
27
  Bun.spawnSync(["chmod", "+x", stub]);
57
- process.env.HERDR_BIN_PATH = stub;
58
- expect(focusHerdrPane("w1:p1")).toBe(true);
28
+ expect(focusHerdrPane(stub, "w1:p1")).toBe(true);
59
29
  const calls = readFileSync(log, "utf8").trim().split("\n");
60
30
  expect(calls[0]).toBe("pane get w1:p1");
61
31
  expect(calls[1]).toBe("tab focus w1:t3");
62
32
  });
63
33
 
64
34
  test("a missing binary never throws", () => {
65
- process.env.HERDR_BIN_PATH = join(dir, "nope");
66
- expect(focusHerdrPane("w1:p1")).toBe(false);
35
+ expect(focusHerdrPane(join(dir, "nope"), "w1:p1")).toBe(false);
67
36
  });
68
37
  });
package/src/herdr.ts CHANGED
@@ -1,33 +1,20 @@
1
1
  /**
2
- * First-class herdr return (#23): a review opened beside an agent should hand
3
- * focus back to the agent's pane when it closes, not leave the reviewer on a
4
- * dead tab. Detection is the ambient env contract; the return target comes
5
- * from CUELOOP_RETURN_PANE (set by whoever opened this pane) or the session's
6
- * recorded herdrPane (set by the adapter hook). Everything here is
7
- * best-effort: focus failures never block closing the review.
2
+ * First-class herdr return: a review opened beside an agent hands focus back
3
+ * to the agent's pane when it closes, not leaving the reviewer on a dead tab.
4
+ * The env contract and the return-target rule live in @cueloop/schema; this
5
+ * module owns the one reviewer-side action that spawns the herdr CLI. Focus
6
+ * is best-effort - a failure never blocks closing the review.
8
7
  */
9
8
 
10
- export function insideHerdr(): boolean {
11
- return process.env.HERDR_ENV === "1";
12
- }
13
-
14
- export function returnPaneFor(sessionHerdrPane?: string): string | undefined {
15
- if (!insideHerdr()) return undefined;
16
- const pane = process.env.CUELOOP_RETURN_PANE ?? sessionHerdrPane;
17
- // returning to our own pane would be a no-op at best
18
- return pane && pane !== process.env.HERDR_PANE_ID ? pane : undefined;
19
- }
20
-
21
9
  /** Focus the tab holding the target pane. Synchronous: runs just before exit. */
22
- export function focusHerdrPane(paneId: string): boolean {
23
- const bin = process.env.HERDR_BIN_PATH ?? "herdr";
10
+ export function focusHerdrPane(binPath: string, paneId: string): boolean {
24
11
  try {
25
- const got = Bun.spawnSync([bin, "pane", "get", paneId], { stdout: "pipe", stderr: "ignore" });
12
+ const got = Bun.spawnSync([binPath, "pane", "get", paneId], { stdout: "pipe", stderr: "ignore" });
26
13
  if (got.exitCode !== 0) return false;
27
14
  const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: { tab_id?: string } } };
28
15
  const tab = parsed.result?.pane?.tab_id;
29
16
  if (!tab) return false;
30
- return Bun.spawnSync([bin, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
17
+ return Bun.spawnSync([binPath, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
31
18
  } catch {
32
19
  return false;
33
20
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Binding resolution over @opentui/keymap (#86): the loaded config keymap
2
+ * Binding resolution over @opentui/keymap: the loaded config keymap
3
3
  * registers as layers (grammar, span, compose, submit, completion) on one
4
4
  * Keymap instance, key events resolve to action names through its dispatch,
5
5
  * and the status-line hints generate from getActiveKeys() - a rebound key