@cueloop/client 0.1.0-alpha.6 → 0.1.0-alpha.61
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/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +37 -33
- package/src/App.appearance.test.tsx +51 -0
- package/src/App.autoclose.test.tsx +73 -47
- package/src/App.diff.test.tsx +169 -22
- package/src/App.export.test.tsx +49 -37
- package/src/App.plan-review.test.tsx +714 -0
- package/src/App.prototype.test.tsx +194 -0
- package/src/App.readonly.test.tsx +69 -35
- package/src/App.rendering.test.tsx +59 -31
- package/src/App.review-panel.test.tsx +143 -0
- package/src/App.submit-confirm.test.tsx +229 -0
- package/src/App.test.tsx +139 -68
- package/src/App.toast.test.tsx +77 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +270 -0
- package/src/app-view-model.ts +307 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +250 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +60 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +129 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffSheet.scroll.test.tsx +135 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +65 -0
- package/src/components/DiffSheet.tsx +374 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +64 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +59 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +109 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +75 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +422 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +80 -0
- package/src/components/PrototypeSheet.stories.tsx +20 -0
- package/src/components/PrototypeSheet.tsx +450 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +103 -0
- package/src/components/ReviewRail.stories.tsx +218 -0
- package/src/components/ReviewRail.tsx +318 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +147 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +50 -0
- package/src/components/agent-launcher.tsx +149 -0
- package/src/components/diff-sheet-layout.test.ts +128 -0
- package/src/components/diff-sheet-layout.ts +184 -0
- package/src/components/plan-sheet-run-style.ts +90 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +52 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +60 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +66 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +138 -0
- package/src/components/stories-app.tsx +124 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +75 -0
- package/src/components/syntax-highlight.ts +102 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +193 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/config.test.ts +448 -31
- package/src/config.ts +339 -57
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +253 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +176 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +135 -9
- package/src/ghostty-terminal.test.ts +84 -0
- package/src/ghostty-terminal.ts +176 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +17 -21
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +499 -0
- package/src/intent-dispatch.ts +585 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +452 -0
- package/src/keymap.test.ts +635 -0
- package/src/keymap.ts +404 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +289 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +237 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +16 -5
- package/src/serve.test.ts +25 -9
- package/src/serve.ts +19 -8
- package/src/session-controller.curate.test.ts +260 -0
- package/src/session-controller.share.test.ts +320 -0
- package/src/session-controller.ts +993 -0
- package/src/share.ts +120 -0
- package/src/test-support.test.ts +60 -0
- package/src/test-support.ts +197 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +187 -0
- package/src/theme.ts +64 -17
- package/src/use-settings-dialog.tsx +275 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +22 -8
- package/src/view-diff.ts +39 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +554 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
package/src/config.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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
|
-
* [theme] per-token overrides, [
|
|
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
7
|
*/
|
|
6
8
|
|
|
7
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -11,6 +13,8 @@ import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obs
|
|
|
11
13
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
12
14
|
import { dirname } from "node:path";
|
|
13
15
|
import { DARK, type Theme } from "./theme";
|
|
16
|
+
import { DEFAULT_THEME_NAME, isThemeName, themeForName, type ThemeName } from "./theme-presets";
|
|
17
|
+
import { REVIEW_DEFAULT_WIDTH, clampWidth, type ReviewPanelMode } from "./review-panel";
|
|
14
18
|
|
|
15
19
|
export interface KeymapConfig {
|
|
16
20
|
[action: string]: string | string[];
|
|
@@ -23,10 +27,87 @@ export interface IntegrationsConfig {
|
|
|
23
27
|
/** Post-submit behavior: "off" prompts, 0 closes instantly, N counts down. */
|
|
24
28
|
export type AutoClose = "off" | number;
|
|
25
29
|
|
|
30
|
+
/** One marker-popover quick action: a preset comment body, plus optional extra lines. */
|
|
31
|
+
export interface QuickAction {
|
|
32
|
+
prompt: string;
|
|
33
|
+
metadata?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The built-in quick-actions for the marker popover when no `[[actions]]` are configured. */
|
|
37
|
+
export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
|
38
|
+
{
|
|
39
|
+
prompt: "Zoom out, research in depth",
|
|
40
|
+
metadata: "Research the broader problem, prior art, and alternatives before changing anything.",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
prompt: "Restate simplified",
|
|
44
|
+
metadata: "Restate this as the simplest thing that works and cut the incidental complexity.",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
prompt: "Out of scope",
|
|
48
|
+
metadata:
|
|
49
|
+
"This is out of scope for the task, so capture it as a follow-up instead of doing it now.",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
prompt: "Let's chat about this",
|
|
53
|
+
metadata:
|
|
54
|
+
"Do not implement yet, and surface the open questions and trade-offs so we decide together.",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
prompt: "Prototype this",
|
|
58
|
+
metadata: "Build a throwaway prototype to answer the question, skipping tests and polish.",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
prompt: "Ensure 0 regressions",
|
|
62
|
+
metadata:
|
|
63
|
+
"Characterize the current behavior with tests first, then keep them green through the change.",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
prompt: "Consider existing repo patterns",
|
|
67
|
+
metadata:
|
|
68
|
+
"Follow the nearest existing pattern in this codebase rather than introducing a new one.",
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/** The comment body a quick action expands to: the prompt, then its system prompt when set. */
|
|
73
|
+
export function quickActionBody(action: QuickAction): string {
|
|
74
|
+
return action.metadata ? `${action.prompt}\n\n${action.metadata}` : action.prompt;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Resolve a quick action by 1-based index or case-insensitive prompt; undefined when no match. */
|
|
78
|
+
export function resolveQuickAction(
|
|
79
|
+
actions: QuickAction[],
|
|
80
|
+
actionRef: string,
|
|
81
|
+
): QuickAction | undefined {
|
|
82
|
+
const index = Number(actionRef);
|
|
83
|
+
|
|
84
|
+
if (Number.isInteger(index) && index >= 1 && index <= actions.length) return actions[index - 1];
|
|
85
|
+
const wanted = actionRef.trim().toLowerCase();
|
|
86
|
+
|
|
87
|
+
return actions.find((action) => action.prompt.toLowerCase() === wanted);
|
|
88
|
+
}
|
|
89
|
+
|
|
26
90
|
export interface CueloopConfig {
|
|
27
91
|
keys: Record<string, string[]>;
|
|
28
92
|
theme: Theme;
|
|
29
|
-
|
|
93
|
+
/** The `[theme]` per-token overrides alone, so a live theme switch can re-compose them onto a new preset. */
|
|
94
|
+
themeOverrides: Partial<Theme>;
|
|
95
|
+
/**
|
|
96
|
+
* ui.reviewState / ui.reviewWidth are CLIENT VIEW STATE: the review panel's
|
|
97
|
+
* collapse mode and expanded-rail width, persisted so they survive restarts.
|
|
98
|
+
*/
|
|
99
|
+
ui: {
|
|
100
|
+
autoClose: AutoClose;
|
|
101
|
+
editor?: string;
|
|
102
|
+
reviewState: ReviewPanelMode;
|
|
103
|
+
reviewWidth: number;
|
|
104
|
+
/** The selected theme preset name; its tokens are the base for `theme`, before any `[theme]` overrides. */
|
|
105
|
+
theme: ThemeName;
|
|
106
|
+
};
|
|
107
|
+
/** Planner-local author renames: identity id → display name ([authors] table). */
|
|
108
|
+
authors: Record<string, string>;
|
|
109
|
+
/** Marker-popover quick actions ([[actions]] tables); the 5 defaults when unset. */
|
|
110
|
+
actions: QuickAction[];
|
|
30
111
|
integrations: IntegrationsConfig;
|
|
31
112
|
}
|
|
32
113
|
|
|
@@ -38,14 +119,21 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
|
|
|
38
119
|
bottom: ["G"],
|
|
39
120
|
span: ["v"],
|
|
40
121
|
comment: ["c"],
|
|
41
|
-
suggest: ["s"],
|
|
42
122
|
cut: ["x"],
|
|
123
|
+
reject_hunk: ["X"],
|
|
124
|
+
restore_curation: ["u"],
|
|
43
125
|
edit: ["e"],
|
|
44
126
|
next_annotation: ["n"],
|
|
45
127
|
prev_annotation: ["p"],
|
|
46
128
|
delete_annotation: ["backspace"],
|
|
129
|
+
rename: ["r"],
|
|
47
130
|
submit: ["return", "enter"],
|
|
131
|
+
share: ["S"],
|
|
48
132
|
quit: ["q"],
|
|
133
|
+
walk: ["w"],
|
|
134
|
+
review_cycle: ["b"],
|
|
135
|
+
review_wider: ["]"],
|
|
136
|
+
review_narrower: ["["],
|
|
49
137
|
};
|
|
50
138
|
|
|
51
139
|
function parseToml(text: string): Record<string, unknown> {
|
|
@@ -53,97 +141,291 @@ function parseToml(text: string): Record<string, unknown> {
|
|
|
53
141
|
return Bun.TOML.parse(text) as Record<string, unknown>;
|
|
54
142
|
}
|
|
55
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Parse a `[[actions]]` array-of-tables. A table without a non-empty `prompt`
|
|
146
|
+
* is skipped; any valid table REPLACES the defaults (the whole set is the
|
|
147
|
+
* user's, not merged). Returns undefined when nothing usable is present.
|
|
148
|
+
*/
|
|
149
|
+
function parseActions(raw: unknown): QuickAction[] | undefined {
|
|
150
|
+
if (!Array.isArray(raw)) return undefined;
|
|
151
|
+
const actions: QuickAction[] = [];
|
|
152
|
+
|
|
153
|
+
for (const entry of raw) {
|
|
154
|
+
if (!entry || typeof entry !== "object") continue;
|
|
155
|
+
const table = entry as Record<string, unknown>;
|
|
156
|
+
const prompt = table["prompt"];
|
|
157
|
+
|
|
158
|
+
if (typeof prompt !== "string" || !prompt.trim()) continue;
|
|
159
|
+
const metadata = table["metadata"];
|
|
160
|
+
|
|
161
|
+
actions.push(
|
|
162
|
+
typeof metadata === "string" && metadata.trim() ? { prompt, metadata } : { prompt },
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return actions.length ? actions : undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function mergeAuthors(target: Record<string, string>, raw: unknown): void {
|
|
170
|
+
const authors = raw as Record<string, unknown> | undefined;
|
|
171
|
+
|
|
172
|
+
if (!authors) return;
|
|
173
|
+
for (const [id, value] of Object.entries(authors)) {
|
|
174
|
+
if (typeof value === "string") target[id] = value;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function mergeKeys(target: Record<string, string[]>, raw: unknown): void {
|
|
179
|
+
const keys = raw as KeymapConfig | undefined;
|
|
180
|
+
|
|
181
|
+
if (!keys) return;
|
|
182
|
+
for (const [action, combo] of Object.entries(keys)) {
|
|
183
|
+
target[action] = Array.isArray(combo) ? combo : [combo];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function mergeAutoClose(target: CueloopConfig["ui"], value: unknown): void {
|
|
188
|
+
if (value === undefined) return;
|
|
189
|
+
if (value === "off") target.autoClose = "off";
|
|
190
|
+
else if (typeof value === "number" && value >= 0) target.autoClose = value;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function mergeUi(target: CueloopConfig["ui"], raw: unknown): void {
|
|
194
|
+
const ui = raw as
|
|
195
|
+
| { auto_close?: unknown; editor?: unknown; review_width?: unknown; review_state?: unknown }
|
|
196
|
+
| undefined;
|
|
197
|
+
|
|
198
|
+
if (!ui) return;
|
|
199
|
+
mergeAutoClose(target, ui.auto_close);
|
|
200
|
+
if (typeof ui.editor === "string" && ui.editor.trim()) target.editor = ui.editor.trim();
|
|
201
|
+
if (typeof ui.review_width === "number" && Number.isFinite(ui.review_width)) {
|
|
202
|
+
target.reviewWidth = clampWidth(ui.review_width);
|
|
203
|
+
}
|
|
204
|
+
if (
|
|
205
|
+
ui.review_state === "expanded" ||
|
|
206
|
+
ui.review_state === "compact" ||
|
|
207
|
+
ui.review_state === "hidden"
|
|
208
|
+
) {
|
|
209
|
+
target.reviewState = ui.review_state;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function mergeObsidian(target: ObsidianConfig, raw: unknown): void {
|
|
214
|
+
const integrations = raw as Record<string, unknown> | undefined;
|
|
215
|
+
const obsidian = integrations?.["obsidian"] as Record<string, unknown> | undefined;
|
|
216
|
+
|
|
217
|
+
if (!obsidian) return;
|
|
218
|
+
if (typeof obsidian["vault"] === "string") target.vault = obsidian["vault"];
|
|
219
|
+
if (typeof obsidian["folder"] === "string") target.folder = obsidian["folder"];
|
|
220
|
+
if (typeof obsidian["filenameFormat"] === "string")
|
|
221
|
+
target.filenameFormat = obsidian["filenameFormat"];
|
|
222
|
+
const separator = obsidian["separator"];
|
|
223
|
+
|
|
224
|
+
if (separator === "space" || separator === "dash" || separator === "underscore")
|
|
225
|
+
target.separator = separator;
|
|
226
|
+
const exportOn = obsidian["exportOn"];
|
|
227
|
+
|
|
228
|
+
if (exportOn === "approve" || exportOn === "resolve" || exportOn === "manual")
|
|
229
|
+
target.exportOn = exportOn;
|
|
230
|
+
}
|
|
231
|
+
|
|
56
232
|
function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig {
|
|
57
233
|
const out: CueloopConfig = {
|
|
58
234
|
keys: { ...base.keys },
|
|
59
235
|
theme: { ...base.theme },
|
|
236
|
+
themeOverrides: { ...base.themeOverrides },
|
|
60
237
|
ui: { ...base.ui },
|
|
238
|
+
authors: { ...base.authors },
|
|
239
|
+
actions: [...base.actions],
|
|
61
240
|
integrations: { obsidian: { ...base.integrations.obsidian } },
|
|
62
241
|
};
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (ui.auto_close === "off") out.ui.autoClose = "off";
|
|
72
|
-
else if (typeof ui.auto_close === "number" && ui.auto_close >= 0) out.ui.autoClose = ui.auto_close;
|
|
73
|
-
}
|
|
74
|
-
const theme = raw["theme"] as Partial<Record<keyof Theme, string>> | undefined;
|
|
75
|
-
if (theme) {
|
|
76
|
-
for (const [token, value] of Object.entries(theme)) {
|
|
77
|
-
if (token in out.theme && typeof value === "string") {
|
|
78
|
-
(out.theme as unknown as Record<string, string>)[token] = value;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
const integrations = raw["integrations"] as Record<string, unknown> | undefined;
|
|
83
|
-
const obsidian = integrations?.["obsidian"] as Record<string, unknown> | undefined;
|
|
84
|
-
if (obsidian) {
|
|
85
|
-
const o = out.integrations.obsidian;
|
|
86
|
-
if (typeof obsidian["vault"] === "string") o.vault = obsidian["vault"];
|
|
87
|
-
if (typeof obsidian["folder"] === "string") o.folder = obsidian["folder"];
|
|
88
|
-
if (typeof obsidian["filenameFormat"] === "string") o.filenameFormat = obsidian["filenameFormat"];
|
|
89
|
-
const sep = obsidian["separator"];
|
|
90
|
-
if (sep === "space" || sep === "dash" || sep === "underscore") o.separator = sep;
|
|
91
|
-
const on = obsidian["exportOn"];
|
|
92
|
-
if (on === "approve" || on === "resolve" || on === "manual") o.exportOn = on;
|
|
93
|
-
}
|
|
242
|
+
const actions = parseActions(raw["actions"]);
|
|
243
|
+
|
|
244
|
+
if (actions) out.actions = actions;
|
|
245
|
+
mergeAuthors(out.authors, raw["authors"]);
|
|
246
|
+
mergeKeys(out.keys, raw["keys"]);
|
|
247
|
+
mergeUi(out.ui, raw["ui"]);
|
|
248
|
+
mergeObsidian(out.integrations.obsidian, raw["integrations"]);
|
|
249
|
+
|
|
94
250
|
return out;
|
|
95
251
|
}
|
|
96
252
|
|
|
97
|
-
export function loadConfig(
|
|
253
|
+
export function loadConfig(
|
|
254
|
+
options: { repoRoot?: string; userConfigPath?: string } = {},
|
|
255
|
+
): CueloopConfig {
|
|
98
256
|
let config: CueloopConfig = {
|
|
99
257
|
keys: { ...DEFAULT_KEYS },
|
|
100
258
|
theme: { ...DARK },
|
|
101
|
-
|
|
259
|
+
themeOverrides: {},
|
|
260
|
+
ui: {
|
|
261
|
+
autoClose: "off",
|
|
262
|
+
reviewState: "expanded",
|
|
263
|
+
reviewWidth: REVIEW_DEFAULT_WIDTH,
|
|
264
|
+
theme: DEFAULT_THEME_NAME,
|
|
265
|
+
},
|
|
266
|
+
authors: {},
|
|
267
|
+
actions: [...DEFAULT_QUICK_ACTIONS],
|
|
102
268
|
integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
|
|
103
269
|
};
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
270
|
+
// Theme name and per-token overrides are separate concerns, composed once
|
|
271
|
+
// after all layers: the last file to set [ui] theme wins, and every [theme]
|
|
272
|
+
// override from every file lands on top - so a later preset never discards an
|
|
273
|
+
// earlier file's token overrides.
|
|
274
|
+
let themeName = DEFAULT_THEME_NAME;
|
|
275
|
+
const themeOverrides: Partial<Record<keyof Theme, string>> = {};
|
|
276
|
+
const userPath = userConfigPathFrom(options.userConfigPath);
|
|
277
|
+
|
|
278
|
+
for (const path of [
|
|
279
|
+
userPath,
|
|
280
|
+
options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined,
|
|
281
|
+
]) {
|
|
109
282
|
if (!path || !existsSync(path)) continue;
|
|
110
283
|
try {
|
|
111
|
-
|
|
284
|
+
const raw = parseToml(readFileSync(path, "utf8"));
|
|
285
|
+
|
|
286
|
+
config = layer(config, raw);
|
|
287
|
+
const rawTheme = (raw["ui"] as { theme?: unknown } | undefined)?.theme;
|
|
288
|
+
|
|
289
|
+
if (typeof rawTheme === "string" && isThemeName(rawTheme)) themeName = rawTheme;
|
|
290
|
+
collectThemeOverrides(raw["theme"], themeOverrides);
|
|
112
291
|
} catch {
|
|
113
292
|
// a broken config never blocks a review; defaults win
|
|
114
293
|
}
|
|
115
294
|
}
|
|
295
|
+
config.ui.theme = themeName;
|
|
296
|
+
config.themeOverrides = themeOverrides;
|
|
297
|
+
config.theme = { ...themeForName(themeName), ...themeOverrides };
|
|
298
|
+
|
|
116
299
|
return config;
|
|
117
300
|
}
|
|
118
301
|
|
|
302
|
+
/** Merge a raw `[theme]` table's known string tokens into the accumulated overrides. */
|
|
303
|
+
function collectThemeOverrides(raw: unknown, into: Partial<Record<keyof Theme, string>>): void {
|
|
304
|
+
if (!raw || typeof raw !== "object") return;
|
|
305
|
+
for (const [token, value] of Object.entries(raw)) {
|
|
306
|
+
if (token in DARK && typeof value === "string") into[token as keyof Theme] = value;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
119
310
|
/** Reverse lookup: key name (+shift) → action, per the loaded keymap. */
|
|
120
|
-
export function actionFor(
|
|
311
|
+
export function actionFor(
|
|
312
|
+
keys: Record<string, string[]>,
|
|
313
|
+
name: string,
|
|
314
|
+
shift: boolean,
|
|
315
|
+
): string | undefined {
|
|
121
316
|
const wanted = shift && name.length === 1 ? name.toUpperCase() : name;
|
|
317
|
+
|
|
122
318
|
for (const [action, combos] of Object.entries(keys)) {
|
|
123
319
|
if (combos.includes(wanted)) return action;
|
|
124
320
|
}
|
|
321
|
+
|
|
125
322
|
return undefined;
|
|
126
323
|
}
|
|
127
324
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
* surgery on the one key we own: replace an existing `auto_close`, or append
|
|
131
|
-
* an `[ui]` section - a full TOML writer is not worth its weight here.
|
|
132
|
-
*/
|
|
133
|
-
export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
|
|
134
|
-
const path =
|
|
325
|
+
function userConfigPathFrom(userConfigPath?: string): string {
|
|
326
|
+
return (
|
|
135
327
|
userConfigPath ??
|
|
136
328
|
process.env.CUELOOP_CONFIG ??
|
|
137
|
-
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml")
|
|
138
|
-
|
|
329
|
+
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml")
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Persist one `[ui]` key into the user config file. Line-level TOML surgery on
|
|
335
|
+
* the keys we own: replace the existing assignment, or drop it under an `[ui]`
|
|
336
|
+
* section (appending the section when absent) - a full TOML writer is not worth
|
|
337
|
+
* its weight here.
|
|
338
|
+
*/
|
|
339
|
+
function persistUiSetting(key: string, rendered: string, userConfigPath?: string): void {
|
|
340
|
+
const path = userConfigPathFrom(userConfigPath);
|
|
139
341
|
let text = "";
|
|
342
|
+
|
|
140
343
|
if (existsSync(path)) text = readFileSync(path, "utf8");
|
|
141
|
-
|
|
142
|
-
|
|
344
|
+
const assignment = new RegExp(`^(\\s*)${key}\\s*=.*$`, "m");
|
|
345
|
+
|
|
346
|
+
if (assignment.test(text)) {
|
|
347
|
+
text = text.replace(assignment, `$1${key} = ${rendered}`);
|
|
143
348
|
} else if (/^\[ui\]/m.test(text)) {
|
|
144
|
-
text = text.replace(/^\[ui\]\s*$/m, `[ui]\
|
|
349
|
+
text = text.replace(/^\[ui\]\s*$/m, `[ui]\n${key} = ${rendered}`);
|
|
350
|
+
} else {
|
|
351
|
+
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[ui]\n${key} = ${rendered}\n`;
|
|
352
|
+
}
|
|
353
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
354
|
+
writeFileSync(path, text);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** A double-quoted TOML basic string with the quote-breaking characters escaped. */
|
|
358
|
+
function tomlString(value: string): string {
|
|
359
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Persist the whole quick-action set as `[[actions]]` tables, replacing any
|
|
364
|
+
* existing ones. The set is the user's entire vocabulary (not merged), so the
|
|
365
|
+
* old blocks are stripped and the new ones appended. An empty set clears them.
|
|
366
|
+
*/
|
|
367
|
+
export function persistActions(actions: QuickAction[], userConfigPath?: string): void {
|
|
368
|
+
const path = userConfigPathFrom(userConfigPath);
|
|
369
|
+
let text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
370
|
+
|
|
371
|
+
// strip every existing [[actions]] block (header through its key lines)
|
|
372
|
+
text = text.replace(/^\[\[actions\]\][^[]*/gm, "").trimEnd();
|
|
373
|
+
const blocks = actions
|
|
374
|
+
.map((action) => {
|
|
375
|
+
const lines = [`[[actions]]`, `prompt = ${tomlString(action.prompt)}`];
|
|
376
|
+
|
|
377
|
+
if (action.metadata) lines.push(`metadata = ${tomlString(action.metadata)}`);
|
|
378
|
+
|
|
379
|
+
return lines.join("\n");
|
|
380
|
+
})
|
|
381
|
+
.join("\n\n");
|
|
382
|
+
|
|
383
|
+
text = blocks ? `${text ? `${text}\n\n` : ""}${blocks}\n` : `${text}\n`;
|
|
384
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
385
|
+
writeFileSync(path, text);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Persist the auto-close choice (`[ui] auto_close`) into the user config. */
|
|
389
|
+
export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
|
|
390
|
+
persistUiSetting("auto_close", value === "off" ? '"off"' : String(value), userConfigPath);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Persist the expanded-rail width (`[ui] review_width`) into the user config. */
|
|
394
|
+
export function persistReviewWidth(width: number, userConfigPath?: string): void {
|
|
395
|
+
persistUiSetting("review_width", String(clampWidth(width)), userConfigPath);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Persist the review-panel collapse mode (`[ui] review_state`) into the config. */
|
|
399
|
+
export function persistReviewState(state: ReviewPanelMode, userConfigPath?: string): void {
|
|
400
|
+
persistUiSetting("review_state", `"${state}"`, userConfigPath);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Persist the selected theme preset (`[ui] theme`) into the user config. */
|
|
404
|
+
export function persistTheme(name: ThemeName, userConfigPath?: string): void {
|
|
405
|
+
persistUiSetting("theme", `"${name}"`, userConfigPath);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function escapeRegExp(text: string): string {
|
|
409
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Persist an author rename into the `[authors]` table. The identity id (an SSH
|
|
414
|
+
* fingerprint) is not a bare TOML key, so it is written quoted.
|
|
415
|
+
*/
|
|
416
|
+
export function persistAuthorName(id: string, name: string, userConfigPath?: string): void {
|
|
417
|
+
const path = userConfigPathFrom(userConfigPath);
|
|
418
|
+
let text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
419
|
+
const key = `"${id.replace(/"/g, '\\"')}"`;
|
|
420
|
+
const value = `"${name.replace(/"/g, '\\"')}"`;
|
|
421
|
+
const assignment = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*=.*$`, "m");
|
|
422
|
+
|
|
423
|
+
if (assignment.test(text)) {
|
|
424
|
+
text = text.replace(assignment, `$1${key} = ${value}`);
|
|
425
|
+
} else if (/^\[authors\]/m.test(text)) {
|
|
426
|
+
text = text.replace(/^\[authors\]\s*$/m, `[authors]\n${key} = ${value}`);
|
|
145
427
|
} else {
|
|
146
|
-
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[
|
|
428
|
+
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[authors]\n${key} = ${value}\n`;
|
|
147
429
|
}
|
|
148
430
|
mkdirSync(dirname(path), { recursive: true });
|
|
149
431
|
writeFileSync(path, text);
|