@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.
- package/package.json +5 -5
- package/src/App.plan-review.test.tsx +1 -1
- package/src/App.readonly.test.tsx +3 -3
- package/src/App.rendering.test.tsx +16 -16
- package/src/App.test.tsx +1 -1
- package/src/App.tsx +138 -139
- package/src/App.walk.test.tsx +1 -1
- package/src/components/AnnotationCard.tsx +4 -4
- package/src/components/ComposeBar.tsx +2 -2
- package/src/components/DiffSheet.tsx +8 -8
- package/src/components/PlanSheet.stories.tsx +3 -3
- package/src/components/PlanSheet.tsx +31 -31
- package/src/components/WalkWizard.tsx +1 -1
- package/src/components/stories.test.tsx +1 -1
- package/src/components/story-fixtures.ts +1 -1
- package/src/components/story.ts +1 -1
- package/src/components/syntax-highlight.ts +1 -1
- package/src/components/theme-context.tsx +1 -1
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +29 -29
- package/src/config.ts +12 -12
- package/src/editor.ts +1 -1
- package/src/herdr.test.ts +3 -34
- package/src/herdr.ts +8 -21
- package/src/key-bindings.ts +1 -1
- package/src/keymap.test.ts +111 -111
- package/src/keymap.ts +64 -64
- package/src/run.ts +3 -3
- package/src/serve.test.ts +6 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.ts +76 -73
- package/src/theme.ts +1 -1
- package/src/view-diff.test.ts +8 -8
- package/src/view-diff.ts +22 -22
- package/src/{view.test.ts → view-plan.test.ts} +48 -48
- package/src/view-plan.ts +362 -0
- package/src/walk.ts +4 -4
- package/src/components/format.ts +0 -6
- package/src/view.ts +0 -341
|
@@ -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 {
|
|
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.
|
|
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.
|
|
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.
|
|
136
|
-
const foreground = row.
|
|
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
|
-
◆ {
|
|
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.
|
|
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.
|
|
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={{
|
|
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={{
|
|
52
|
+
activeSpan={{ displayIndex: 2, start: 0, end: 10 }}
|
|
53
53
|
compose={{
|
|
54
54
|
kind: "comment",
|
|
55
|
-
|
|
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
|
-
|
|
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(
|
|
46
|
+
revealBlock(displayIndex: number): void;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export interface PlanComposeState {
|
|
50
50
|
kind: "comment" | "suggestion";
|
|
51
|
-
|
|
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: {
|
|
62
|
+
activeSpan: { displayIndex: number; start: number; end: number } | null;
|
|
63
63
|
compose: PlanComposeState | null;
|
|
64
64
|
editOrphanCount: number;
|
|
65
|
-
onLineActivate: (
|
|
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 [
|
|
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 {
|
|
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.
|
|
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: (
|
|
136
|
+
revealBlock: (displayIndex: number): void => {
|
|
137
137
|
try {
|
|
138
|
-
scrollRef.current?.scrollChildIntoView(`plan-block-${
|
|
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 = (
|
|
146
|
-
if (renderable) blockRefs.current.set(
|
|
147
|
-
else blockRefs.current.delete(
|
|
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
|
|
152
|
-
if (
|
|
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
|
|
158
|
-
const block = display[
|
|
159
|
-
const isCursor =
|
|
160
|
-
const gap = topGap(display[
|
|
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={
|
|
165
|
-
id={`plan-block-${
|
|
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(
|
|
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(
|
|
177
|
-
if (activeSpan && activeSpan.
|
|
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={
|
|
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(
|
|
197
|
-
onMouseUp={() => onLineActivate(
|
|
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.
|
|
209
|
+
if (compose && compose.displayIndex === displayIndex) {
|
|
210
210
|
children.push(
|
|
211
|
-
<AnnotationCard key={`compose-${
|
|
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.
|
|
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
|
|
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
|
|
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
|
package/src/components/story.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The story contract
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -7,9 +7,9 @@ import { DARK } from "./theme";
|
|
|
7
7
|
|
|
8
8
|
describe("loadConfig", () => {
|
|
9
9
|
test("defaults when no file exists", () => {
|
|
10
|
-
const
|
|
11
|
-
expect(
|
|
12
|
-
expect(
|
|
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
|
|
24
|
-
expect(
|
|
25
|
-
expect(
|
|
26
|
-
expect(
|
|
27
|
-
expect(
|
|
28
|
-
expect(
|
|
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
|
|
43
|
-
expect(
|
|
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
|
|
55
|
-
expect(
|
|
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
|
|
65
|
-
expect(
|
|
66
|
-
expect(
|
|
67
|
-
expect(
|
|
68
|
-
expect(
|
|
69
|
-
expect(
|
|
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
|
|
81
|
-
expect(
|
|
82
|
-
expect(
|
|
83
|
-
expect(
|
|
84
|
-
expect(
|
|
85
|
-
expect(
|
|
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
|
|
100
|
-
expect(
|
|
101
|
-
expect(
|
|
102
|
-
expect(
|
|
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
|
|
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
|
|
87
|
-
if (typeof obsidian["vault"] === "string")
|
|
88
|
-
if (typeof obsidian["folder"] === "string")
|
|
89
|
-
if (typeof obsidian["filenameFormat"] === "string")
|
|
90
|
-
const
|
|
91
|
-
if (
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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([
|
|
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([
|
|
17
|
+
return Bun.spawnSync([binPath, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
|
|
31
18
|
} catch {
|
|
32
19
|
return false;
|
|
33
20
|
}
|
package/src/key-bindings.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Binding resolution over @opentui/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
|