@cueloop/client 0.1.0-alpha.63 → 0.1.0-alpha.65
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 +7 -6
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +76 -247
- package/src/App.readonly.test.tsx +80 -35
- package/src/App.rendering.test.tsx +5 -6
- package/src/App.review-panel.test.tsx +29 -32
- package/src/App.submit-confirm.test.tsx +27 -12
- package/src/App.test.tsx +73 -49
- package/src/App.toast.test.tsx +34 -22
- package/src/App.tree.test.tsx +224 -0
- package/src/App.tsx +134 -87
- package/src/app-screens.tsx +27 -1
- package/src/app-view-model.ts +7 -109
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/DiffSheet.scroll.test.tsx +5 -2
- package/src/components/DiffSheet.tsx +1 -6
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/PrototypeSheet.tsx +5 -9
- package/src/components/ReviewPanel.stories.tsx +14 -0
- package/src/components/ReviewRail.stories.tsx +14 -0
- package/src/components/ReviewRail.tsx +15 -6
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +1620 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +156 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
- package/src/components/primitives/Tabs.tsx +1 -1
- package/src/components/story-fixtures.ts +6 -2
- package/src/components/story.ts +16 -4
- package/src/components/syntax-highlight.ts +10 -2
- package/src/components/terminal-pane.test.tsx +1 -1
- package/src/components/terminal-pane.ts +7 -1
- package/src/config.ts +112 -99
- package/src/diff-hunk-curate.ts +4 -104
- package/src/editor.ts +5 -1
- package/src/ghostty-terminal.test.ts +1 -1
- package/src/herdr.ts +10 -2
- package/src/index.ts +1 -0
- package/src/intent-dispatch.test.ts +235 -55
- package/src/intent-dispatch.ts +104 -34
- package/src/key-bindings.test.ts +1 -1
- package/src/key-bindings.ts +16 -7
- package/src/keymap.test.ts +9 -11
- package/src/keymap.ts +14 -7
- package/src/kitty-image.test.ts +3 -3
- package/src/prototype-browser.ts +7 -6
- package/src/serve.ts +1 -1
- package/src/session-controller.curate.test.ts +106 -4
- package/src/session-controller.share.test.ts +220 -70
- package/src/session-controller.tree.test.ts +228 -0
- package/src/session-controller.ts +370 -94
- package/src/share.ts +117 -17
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +69 -1
- package/src/theme-presets.ts +9 -2
- package/src/thread-chords.test.ts +154 -0
- package/src/thread-chords.ts +144 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/view-diff.ts +1 -4
- package/src/view-plan.ts +35 -19
- package/src/components/PlanSheet.stories.tsx +0 -113
- package/src/components/PlanSheet.tsx +0 -422
- package/src/components/plan-sheet-run-style.ts +0 -90
|
@@ -63,9 +63,11 @@ export function fixturePlanSession(overrides: Partial<ReviewSession> = {}): Revi
|
|
|
63
63
|
},
|
|
64
64
|
revisions: [{ revision: 1, content: FIXTURE_PLAN, submittedAt: "2026-01-01T00:00:00Z" }],
|
|
65
65
|
annotations: FIXTURE_ANNOTATIONS,
|
|
66
|
+
verdict: null,
|
|
66
67
|
status: "pending",
|
|
68
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
67
69
|
...overrides,
|
|
68
|
-
}
|
|
70
|
+
};
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
export function fixtureDiffSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
|
|
@@ -80,9 +82,11 @@ export function fixtureDiffSession(overrides: Partial<ReviewSession> = {}): Revi
|
|
|
80
82
|
},
|
|
81
83
|
revisions: [{ revision: 1, content: FIXTURE_PATCH, submittedAt: "2026-01-01T00:00:00Z" }],
|
|
82
84
|
annotations: [],
|
|
85
|
+
verdict: null,
|
|
83
86
|
status: "pending",
|
|
87
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
84
88
|
...overrides,
|
|
85
|
-
}
|
|
89
|
+
};
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
export function fixtureDisplay() {
|
package/src/components/story.ts
CHANGED
|
@@ -32,7 +32,19 @@ export interface LoadedStory {
|
|
|
32
32
|
|
|
33
33
|
export function isStory(value: unknown): value is Story {
|
|
34
34
|
return (
|
|
35
|
-
typeof value === "object" &&
|
|
35
|
+
typeof value === "object" &&
|
|
36
|
+
value !== null &&
|
|
37
|
+
"render" in value &&
|
|
38
|
+
typeof value.render === "function"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isStoryMeta(value: unknown): value is StoryMeta {
|
|
43
|
+
return (
|
|
44
|
+
typeof value === "object" &&
|
|
45
|
+
value !== null &&
|
|
46
|
+
"title" in value &&
|
|
47
|
+
typeof value.title === "string"
|
|
36
48
|
);
|
|
37
49
|
}
|
|
38
50
|
|
|
@@ -43,9 +55,9 @@ export async function loadStories(): Promise<LoadedStory[]> {
|
|
|
43
55
|
const loaded: LoadedStory[] = [];
|
|
44
56
|
|
|
45
57
|
for (const file of files) {
|
|
46
|
-
const moduleExports =
|
|
47
|
-
const meta = moduleExports["meta"]
|
|
48
|
-
const moduleTitle = meta
|
|
58
|
+
const moduleExports = await import(`${import.meta.dir}/${file}`);
|
|
59
|
+
const meta = moduleExports["meta"];
|
|
60
|
+
const moduleTitle = isStoryMeta(meta) ? meta.title : file.replace(/\.stories\.tsx$/, "");
|
|
49
61
|
|
|
50
62
|
for (const [exportName, exported] of Object.entries(moduleExports)) {
|
|
51
63
|
if (exportName === "meta" || !isStory(exported)) continue;
|
|
@@ -19,7 +19,7 @@ export function filetypeFor(language?: string): string | undefined {
|
|
|
19
19
|
const info = (language ?? "").toLowerCase();
|
|
20
20
|
|
|
21
21
|
if (!info) return undefined;
|
|
22
|
-
const aliases:
|
|
22
|
+
const aliases: SyntaxAliases = {
|
|
23
23
|
ts: "typescript",
|
|
24
24
|
js: "javascript",
|
|
25
25
|
py: "python",
|
|
@@ -47,13 +47,21 @@ export function filetypeForPath(path: string): string | undefined {
|
|
|
47
47
|
return extensionToFiletype.get(basename.slice(dotIndex + 1).toLowerCase());
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
interface SyntaxAliases {
|
|
51
|
+
[alias: string]: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
50
54
|
interface SyntaxGroupStyle {
|
|
51
55
|
fg: string;
|
|
52
56
|
italic?: boolean;
|
|
53
57
|
}
|
|
54
58
|
|
|
59
|
+
interface SyntaxGroupStyles {
|
|
60
|
+
[group: string]: SyntaxGroupStyle;
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
/** The one group -> style map, keyed by tree-sitter capture name. */
|
|
56
|
-
function syntaxGroupStyles(theme: Theme):
|
|
64
|
+
function syntaxGroupStyles(theme: Theme): SyntaxGroupStyles {
|
|
57
65
|
return {
|
|
58
66
|
default: { fg: theme.textMuted },
|
|
59
67
|
comment: { fg: theme.textDim, italic: true },
|
|
@@ -34,7 +34,7 @@ describe("TerminalPaneRenderable", () => {
|
|
|
34
34
|
|
|
35
35
|
test("exposes availability + the renderable class", () => {
|
|
36
36
|
// Assert - the module loads and reports platform support honestly
|
|
37
|
-
expect(
|
|
37
|
+
expect([true, false]).toContain(embeddedTerminalAvailable());
|
|
38
38
|
expect(TerminalPaneRenderable).toBeDefined();
|
|
39
39
|
});
|
|
40
40
|
});
|
|
@@ -47,6 +47,12 @@ export interface TerminalPaneOptions extends RenderableOptions {
|
|
|
47
47
|
onExit?: (exitCode: number) => void;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function terminalEnvironment(env: NodeJS.ProcessEnv) {
|
|
51
|
+
return Object.fromEntries(
|
|
52
|
+
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
50
56
|
const encoder = new TextEncoder();
|
|
51
57
|
const DEFAULT_FG = RGBA.fromInts(208, 208, 208, 255);
|
|
52
58
|
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0);
|
|
@@ -87,7 +93,7 @@ export class TerminalPaneRenderable extends Renderable {
|
|
|
87
93
|
cols,
|
|
88
94
|
rows,
|
|
89
95
|
cwd: this.opts.cwd ?? process.cwd(),
|
|
90
|
-
env:
|
|
96
|
+
env: this.opts.env ?? terminalEnvironment(process.env),
|
|
91
97
|
});
|
|
92
98
|
this.pty.onData((data) => {
|
|
93
99
|
// the shim streams a UTF-8-decoded string (split multibyte is handled); a
|
package/src/config.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { join } from "node:path";
|
|
|
12
12
|
import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obsidian";
|
|
13
13
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
14
14
|
import { dirname } from "node:path";
|
|
15
|
+
import * as v from "valibot";
|
|
15
16
|
import { DARK, type Theme } from "./theme";
|
|
16
17
|
import { DEFAULT_THEME_NAME, isThemeName, themeForName, type ThemeName } from "./theme-presets";
|
|
17
18
|
import { REVIEW_DEFAULT_WIDTH, clampWidth, type ReviewPanelMode } from "./review-panel";
|
|
@@ -112,7 +113,7 @@ export interface CueloopConfig {
|
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
/** Every action in the grammar, with its default binding(s). */
|
|
115
|
-
export const DEFAULT_KEYS:
|
|
116
|
+
export const DEFAULT_KEYS: CueloopConfig["keys"] = {
|
|
116
117
|
down: ["j", "down"],
|
|
117
118
|
up: ["k", "up"],
|
|
118
119
|
top: ["g"],
|
|
@@ -136,100 +137,87 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
|
|
|
136
137
|
review_narrower: ["["],
|
|
137
138
|
};
|
|
138
139
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
const ConfigDocumentSchema = v.object({
|
|
141
|
+
actions: v.optional(v.array(v.unknown())),
|
|
142
|
+
authors: v.optional(v.unknown()),
|
|
143
|
+
integrations: v.optional(v.unknown()),
|
|
144
|
+
keys: v.optional(v.unknown()),
|
|
145
|
+
theme: v.optional(v.unknown()),
|
|
146
|
+
ui: v.optional(v.unknown()),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const QuickActionSchema = v.object({
|
|
150
|
+
prompt: v.pipe(
|
|
151
|
+
v.string(),
|
|
152
|
+
v.check((prompt) => Boolean(prompt.trim())),
|
|
153
|
+
),
|
|
154
|
+
metadata: v.optional(v.string()),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const AuthorsSchema = v.record(v.string(), v.unknown());
|
|
158
|
+
const KeysSchema = v.record(v.string(), v.unknown());
|
|
159
|
+
const KeyComboSchema = v.union([v.string(), v.array(v.string())]);
|
|
160
|
+
// Every field falls back to undefined on its own, so one bad value in a
|
|
161
|
+
// section never discards the section's other settings.
|
|
162
|
+
const UiSchema = v.object({
|
|
163
|
+
auto_close: v.fallback(
|
|
164
|
+
v.optional(v.union([v.literal("off"), v.pipe(v.number(), v.minValue(0))])),
|
|
165
|
+
undefined,
|
|
166
|
+
),
|
|
167
|
+
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
|
+
theme: v.fallback(v.optional(v.string()), undefined),
|
|
171
|
+
});
|
|
172
|
+
const ObsidianSchema = v.object({
|
|
173
|
+
vault: v.fallback(v.optional(v.string()), undefined),
|
|
174
|
+
folder: v.fallback(v.optional(v.string()), undefined),
|
|
175
|
+
filenameFormat: v.fallback(v.optional(v.string()), undefined),
|
|
176
|
+
separator: v.fallback(v.optional(v.picklist(["space", "dash", "underscore"])), undefined),
|
|
177
|
+
exportOn: v.fallback(v.optional(v.picklist(["approve", "resolve", "manual"])), undefined),
|
|
178
|
+
});
|
|
179
|
+
const IntegrationsSchema = v.object({ obsidian: v.optional(ObsidianSchema) });
|
|
180
|
+
const ThemeOverridesSchema = v.record(v.string(), v.unknown());
|
|
181
|
+
|
|
182
|
+
function isThemeToken(token: string): token is keyof Theme {
|
|
183
|
+
return token in DARK;
|
|
142
184
|
}
|
|
143
185
|
|
|
144
|
-
|
|
145
|
-
|
|
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;
|
|
186
|
+
function parseToml(text: string) {
|
|
187
|
+
return v.parse(ConfigDocumentSchema, Bun.TOML.parse(text));
|
|
167
188
|
}
|
|
168
189
|
|
|
169
|
-
function
|
|
170
|
-
|
|
190
|
+
function parseActions(entries: readonly unknown[] | undefined): QuickAction[] | undefined {
|
|
191
|
+
if (!entries) return undefined;
|
|
192
|
+
const actions: QuickAction[] = [];
|
|
171
193
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (typeof value === "string") target[id] = value;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
const result = v.safeParse(QuickActionSchema, entry);
|
|
177
196
|
|
|
178
|
-
|
|
179
|
-
|
|
197
|
+
if (!result.success) continue;
|
|
198
|
+
const { prompt, metadata } = result.output;
|
|
180
199
|
|
|
181
|
-
|
|
182
|
-
for (const [action, combo] of Object.entries(keys)) {
|
|
183
|
-
target[action] = Array.isArray(combo) ? combo : [combo];
|
|
200
|
+
actions.push(metadata?.trim() ? { prompt, metadata } : { prompt });
|
|
184
201
|
}
|
|
185
|
-
}
|
|
186
202
|
|
|
187
|
-
|
|
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
|
-
}
|
|
203
|
+
return actions.length ? actions : undefined;
|
|
211
204
|
}
|
|
212
205
|
|
|
213
|
-
function mergeObsidian(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (
|
|
218
|
-
if (
|
|
219
|
-
if (
|
|
220
|
-
if (
|
|
221
|
-
|
|
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;
|
|
206
|
+
function mergeObsidian(
|
|
207
|
+
target: ObsidianConfig,
|
|
208
|
+
obsidian: v.InferOutput<typeof ObsidianSchema>,
|
|
209
|
+
): void {
|
|
210
|
+
if (obsidian.vault !== undefined) target.vault = obsidian.vault;
|
|
211
|
+
if (obsidian.folder !== undefined) target.folder = obsidian.folder;
|
|
212
|
+
if (obsidian.filenameFormat !== undefined) target.filenameFormat = obsidian.filenameFormat;
|
|
213
|
+
if (obsidian.separator !== undefined) target.separator = obsidian.separator;
|
|
214
|
+
if (obsidian.exportOn !== undefined) target.exportOn = obsidian.exportOn;
|
|
230
215
|
}
|
|
231
216
|
|
|
232
|
-
function layer(
|
|
217
|
+
function layer(
|
|
218
|
+
base: CueloopConfig,
|
|
219
|
+
raw: v.InferOutput<typeof ConfigDocumentSchema>,
|
|
220
|
+
): CueloopConfig {
|
|
233
221
|
const out: CueloopConfig = {
|
|
234
222
|
keys: { ...base.keys },
|
|
235
223
|
theme: { ...base.theme },
|
|
@@ -239,13 +227,38 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
|
|
|
239
227
|
actions: [...base.actions],
|
|
240
228
|
integrations: { obsidian: { ...base.integrations.obsidian } },
|
|
241
229
|
};
|
|
242
|
-
const actions = parseActions(raw
|
|
230
|
+
const actions = parseActions(raw.actions);
|
|
231
|
+
const authors = v.safeParse(AuthorsSchema, raw.authors);
|
|
232
|
+
const keys = v.safeParse(KeysSchema, raw.keys);
|
|
233
|
+
const ui = v.safeParse(UiSchema, raw.ui);
|
|
234
|
+
const integrations = v.safeParse(IntegrationsSchema, raw.integrations);
|
|
243
235
|
|
|
244
236
|
if (actions) out.actions = actions;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
237
|
+
if (authors.success) {
|
|
238
|
+
for (const [id, value] of Object.entries(authors.output)) {
|
|
239
|
+
const name = v.safeParse(v.string(), value);
|
|
240
|
+
|
|
241
|
+
if (name.success) out.authors[id] = name.output;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (keys.success) {
|
|
245
|
+
for (const [action, value] of Object.entries(keys.output)) {
|
|
246
|
+
const combo = v.safeParse(KeyComboSchema, value);
|
|
247
|
+
|
|
248
|
+
if (combo.success)
|
|
249
|
+
out.keys[action] = Array.isArray(combo.output) ? combo.output : [combo.output];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (ui.success) {
|
|
253
|
+
if (ui.output.auto_close !== undefined) out.ui.autoClose = ui.output.auto_close;
|
|
254
|
+
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;
|
|
258
|
+
}
|
|
259
|
+
if (integrations.success && integrations.output.obsidian) {
|
|
260
|
+
mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
|
|
261
|
+
}
|
|
249
262
|
|
|
250
263
|
return out;
|
|
251
264
|
}
|
|
@@ -284,10 +297,18 @@ export function loadConfig(
|
|
|
284
297
|
const raw = parseToml(readFileSync(path, "utf8"));
|
|
285
298
|
|
|
286
299
|
config = layer(config, raw);
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
300
|
+
const ui = v.safeParse(UiSchema, raw.ui);
|
|
301
|
+
const rawTheme = ui.success ? ui.output.theme : undefined;
|
|
302
|
+
const parsedThemeOverrides = v.safeParse(ThemeOverridesSchema, raw.theme);
|
|
303
|
+
|
|
304
|
+
if (rawTheme && isThemeName(rawTheme)) themeName = rawTheme;
|
|
305
|
+
if (parsedThemeOverrides.success) {
|
|
306
|
+
for (const [token, value] of Object.entries(parsedThemeOverrides.output)) {
|
|
307
|
+
const override = v.safeParse(v.string(), value);
|
|
308
|
+
|
|
309
|
+
if (override.success && isThemeToken(token)) themeOverrides[token] = override.output;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
291
312
|
} catch {
|
|
292
313
|
// a broken config never blocks a review; defaults win
|
|
293
314
|
}
|
|
@@ -299,14 +320,6 @@ export function loadConfig(
|
|
|
299
320
|
return config;
|
|
300
321
|
}
|
|
301
322
|
|
|
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
|
-
|
|
310
323
|
/** Reverse lookup: key name (+shift) → action, per the loaded keymap. */
|
|
311
324
|
export function actionFor(
|
|
312
325
|
keys: Record<string, string[]>,
|
package/src/diff-hunk-curate.ts
CHANGED
|
@@ -13,16 +13,12 @@
|
|
|
13
13
|
* the stored git patch and the model split hunks differently.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
17
|
-
import
|
|
16
|
+
import type { FileDiffMetadata } from "@pierre/diffs";
|
|
17
|
+
import type { HunkRejection } from "@cueloop/schema";
|
|
18
18
|
import type { DiffRow } from "./view-diff";
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
export
|
|
22
|
-
path: string;
|
|
23
|
-
hunkIndex: number;
|
|
24
|
-
changeIndex?: number;
|
|
25
|
-
}
|
|
20
|
+
export { curateDiff, parseFileDiff } from "@cueloop/daemon/curate";
|
|
21
|
+
export type { HunkRejection } from "@cueloop/schema";
|
|
26
22
|
|
|
27
23
|
/** Which side of the diff a line number addresses. */
|
|
28
24
|
type DiffSide = "addition" | "deletion";
|
|
@@ -34,24 +30,6 @@ export interface LocatedLine {
|
|
|
34
30
|
changeIndex?: number;
|
|
35
31
|
}
|
|
36
32
|
|
|
37
|
-
/**
|
|
38
|
-
* Context lines around each change. Matching git's default (3) makes the model
|
|
39
|
-
* split hunks like the rendered working-tree patch does; git's coalescing can
|
|
40
|
-
* still merge adjacent hunks differently, but the row is mapped by absolute line
|
|
41
|
-
* number (not by counting hunks), so a whole-hunk reject can only ever span the
|
|
42
|
-
* model hunk it lands in - never the wrong file or a crash.
|
|
43
|
-
*/
|
|
44
|
-
const DIFF_CONTEXT = 3;
|
|
45
|
-
|
|
46
|
-
/** Parse one file's full contents into a non-partial diff model. */
|
|
47
|
-
export function parseFileDiff(file: DiffFileContents): FileDiffMetadata {
|
|
48
|
-
return parseDiffFromFile(
|
|
49
|
-
{ name: file.path, contents: file.oldContents },
|
|
50
|
-
{ name: file.path, contents: file.newContents },
|
|
51
|
-
{ context: DIFF_CONTEXT },
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
33
|
/**
|
|
56
34
|
* Resolve a 1-based file line on one side to its hunk and, when the line is
|
|
57
35
|
* part of a change block, that block's `hunkContent` index.
|
|
@@ -173,81 +151,3 @@ export function isRowRejected(
|
|
|
173
151
|
(rejection.changeIndex === undefined || rejection.changeIndex === located.changeIndex),
|
|
174
152
|
);
|
|
175
153
|
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Apply one file's rejections and return its curated patch, or null when the
|
|
179
|
-
* curated content equals the old content (every change rejected - no patch).
|
|
180
|
-
* Whole-hunk rejections supersede change-level ones for the same hunk. Because
|
|
181
|
-
* `diffAcceptRejectHunk` returns a model with the same hunk/content shape, the
|
|
182
|
-
* indices stay valid across calls; they are applied high-to-low regardless, so
|
|
183
|
-
* the order is deterministic.
|
|
184
|
-
*/
|
|
185
|
-
function curateFilePatch(file: DiffFileContents, rejections: HunkRejection[]): string | null {
|
|
186
|
-
let model = parseFileDiff(file);
|
|
187
|
-
const wholeHunks = new Set(
|
|
188
|
-
rejections
|
|
189
|
-
.filter((rejection) => rejection.changeIndex === undefined)
|
|
190
|
-
.map((rejection) => rejection.hunkIndex),
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
for (const hunkIndex of [...wholeHunks].sort((a, b) => b - a)) {
|
|
194
|
-
model = diffAcceptRejectHunk(model, hunkIndex, "reject");
|
|
195
|
-
}
|
|
196
|
-
const changeRejections = rejections
|
|
197
|
-
.filter(
|
|
198
|
-
(rejection) => rejection.changeIndex !== undefined && !wholeHunks.has(rejection.hunkIndex),
|
|
199
|
-
)
|
|
200
|
-
.sort((a, b) => b.hunkIndex - a.hunkIndex || b.changeIndex! - a.changeIndex!);
|
|
201
|
-
|
|
202
|
-
for (const rejection of changeRejections) {
|
|
203
|
-
model = diffAcceptRejectHunk(model, rejection.hunkIndex, {
|
|
204
|
-
type: "reject",
|
|
205
|
-
changeIndex: rejection.changeIndex!,
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
const curatedNew = model.additionLines.join("");
|
|
209
|
-
|
|
210
|
-
if (curatedNew === file.oldContents) return null;
|
|
211
|
-
const patch = unifiedDiffText(file.oldContents, curatedNew, file.path);
|
|
212
|
-
|
|
213
|
-
if (patch === null) return null;
|
|
214
|
-
|
|
215
|
-
return withFileStateHeaders(patch, file.path, file.status, curatedNew);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Point the diff headers at /dev/null for a created or fully-deleted file so the
|
|
220
|
-
* curated patch applies. git's status - not empty contents - decides existence,
|
|
221
|
-
* so editing an existing empty file stays a modify; a deletion whose removals
|
|
222
|
-
* were partly restored also falls back to a modify.
|
|
223
|
-
*/
|
|
224
|
-
function withFileStateHeaders(
|
|
225
|
-
patch: string,
|
|
226
|
-
path: string,
|
|
227
|
-
status: DiffFileStatus,
|
|
228
|
-
curatedNew: string,
|
|
229
|
-
): string {
|
|
230
|
-
if (status === "added") return patch.replace(`--- a/${path}`, "--- /dev/null");
|
|
231
|
-
if (status === "deleted" && curatedNew === "") {
|
|
232
|
-
return patch.replace(`+++ b/${path}`, "+++ /dev/null");
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
return patch;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* The curated unified-diff patch of the accepted changes across every file.
|
|
240
|
-
* Files with no net change (unchanged, or with all changes rejected) drop out.
|
|
241
|
-
*/
|
|
242
|
-
export function curateDiff(files: DiffFileContents[], rejections: HunkRejection[]): string {
|
|
243
|
-
const parts: string[] = [];
|
|
244
|
-
|
|
245
|
-
for (const file of files) {
|
|
246
|
-
const fileRejections = rejections.filter((rejection) => rejection.path === file.path);
|
|
247
|
-
const patch = curateFilePatch(file, fileRejections);
|
|
248
|
-
|
|
249
|
-
if (patch) parts.push(patch);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return parts.join("\n");
|
|
253
|
-
}
|
package/src/editor.ts
CHANGED
|
@@ -21,7 +21,11 @@ export interface EditResult {
|
|
|
21
21
|
const DEFAULT_EDITOR = "nano";
|
|
22
22
|
|
|
23
23
|
/** GUI editors fork and return immediately unless given their wait flag. */
|
|
24
|
-
|
|
24
|
+
interface GuiWaitFlags {
|
|
25
|
+
[editor: string]: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const GUI_WAIT_FLAGS: GuiWaitFlags = {
|
|
25
29
|
code: ["--wait"],
|
|
26
30
|
"code-insiders": ["--wait"],
|
|
27
31
|
codium: ["--wait"],
|
|
@@ -8,7 +8,7 @@ const factory = loadGhosttyTerminals();
|
|
|
8
8
|
describe("ghostty-terminal FFI", () => {
|
|
9
9
|
test("reports platform support without throwing", () => {
|
|
10
10
|
// Assert - null on unsupported platforms, a factory where a dylib ships
|
|
11
|
-
expect(factory === null ||
|
|
11
|
+
expect(factory === null || factory.create !== undefined).toBe(true);
|
|
12
12
|
});
|
|
13
13
|
|
|
14
14
|
test.skipIf(!factory)("writes VT bytes and reads back decoded cells", () => {
|
package/src/herdr.ts
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* is best-effort - a failure never blocks closing the review.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import * as v from "valibot";
|
|
10
|
+
|
|
11
|
+
const HerdrPaneSchema = v.object({
|
|
12
|
+
result: v.optional(v.object({ pane: v.optional(v.object({ tab_id: v.optional(v.string()) })) })),
|
|
13
|
+
});
|
|
14
|
+
|
|
9
15
|
/** Focus the tab holding the target pane. Synchronous: runs just before exit. */
|
|
10
16
|
export function focusHerdrPane(binPath: string, paneId: string): boolean {
|
|
11
17
|
try {
|
|
@@ -15,8 +21,10 @@ export function focusHerdrPane(binPath: string, paneId: string): boolean {
|
|
|
15
21
|
});
|
|
16
22
|
|
|
17
23
|
if (got.exitCode !== 0) return false;
|
|
18
|
-
const parsed = JSON.parse(got.stdout.toString())
|
|
19
|
-
|
|
24
|
+
const parsed = v.safeParse(HerdrPaneSchema, JSON.parse(got.stdout.toString()));
|
|
25
|
+
|
|
26
|
+
if (!parsed.success) return false;
|
|
27
|
+
const tab = parsed.output.result?.pane?.tab_id;
|
|
20
28
|
|
|
21
29
|
if (!tab) return false;
|
|
22
30
|
|