@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.70
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 +39 -32
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +76 -50
- package/src/App.diff.test.tsx +156 -42
- package/src/App.export.test.tsx +58 -40
- package/src/App.plan-review.test.tsx +363 -0
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +128 -67
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.submit-confirm.test.tsx +191 -0
- package/src/App.test.tsx +310 -114
- package/src/App.toast.test.tsx +89 -0
- package/src/App.tsx +1039 -799
- package/src/App.walk.test.tsx +298 -0
- package/src/App.workbench.test.tsx +461 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +61 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +432 -0
- package/src/app-view-model.ts +159 -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/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +286 -0
- package/src/components/AppShell.zoom.test.tsx +94 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +171 -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 +128 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffContentView.scroll.test.tsx +121 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +859 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +368 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +83 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +87 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +195 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +54 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +219 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +158 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +330 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +71 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1845 -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-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/pages/Workspace.stories.tsx +181 -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/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -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/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +96 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.test.tsx +49 -0
- package/src/components/quick-actions-editor.tsx +150 -0
- package/src/components/session-tree.test.ts +134 -0
- package/src/components/session-tree.ts +108 -0
- package/src/components/stories-app.tsx +184 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +142 -0
- package/src/components/syntax-highlight.ts +110 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -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 +461 -31
- package/src/config.ts +389 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +11 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +698 -0
- package/src/intent-dispatch.ts +700 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +465 -0
- package/src/keymap.test.ts +611 -0
- package/src/keymap.ts +410 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/mark-runs.ts +120 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +307 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +30 -9
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +20 -9
- package/src/share.ts +215 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +68 -0
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +104 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +355 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +214 -0
- package/src/theme.ts +84 -19
- package/src/thread-chords.test.ts +170 -0
- package/src/thread-chords.ts +169 -0
- package/src/thread-controller.curate.test.ts +438 -0
- package/src/thread-controller.live-diff.test.ts +199 -0
- package/src/thread-controller.share.test.ts +472 -0
- package/src/thread-controller.tree.test.ts +255 -0
- package/src/thread-controller.ts +1509 -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/use-annotation-surface.tsx +851 -0
- package/src/use-changes-workbench.ts +151 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +274 -0
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +66 -9
- package/src/view-diff.ts +231 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +570 -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 -312
package/src/config.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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) + pins, [integrations.obsidian] notes-vault export.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -10,7 +11,9 @@ import { join } from "node:path";
|
|
|
10
11
|
import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obsidian";
|
|
11
12
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
12
13
|
import { dirname } from "node:path";
|
|
14
|
+
import * as v from "valibot";
|
|
13
15
|
import { DARK, type Theme } from "./theme";
|
|
16
|
+
import { DEFAULT_THEME_NAME, isThemeName, themeForName, type ThemeName } from "./theme-presets";
|
|
14
17
|
|
|
15
18
|
export interface KeymapConfig {
|
|
16
19
|
[action: string]: string | string[];
|
|
@@ -23,127 +26,456 @@ export interface IntegrationsConfig {
|
|
|
23
26
|
/** Post-submit behavior: "off" prompts, 0 closes instantly, N counts down. */
|
|
24
27
|
export type AutoClose = "off" | number;
|
|
25
28
|
|
|
29
|
+
/** How the Changes diff renders when wide: old|new side by side, or one stacked column. A narrow
|
|
30
|
+
* (unzoomed) pane has no room for two columns, so it is always stacked regardless of this choice. */
|
|
31
|
+
export type DiffViewMode = "split" | "stacked";
|
|
32
|
+
|
|
33
|
+
/** One marker-popover quick action: a preset comment body, plus optional extra lines. */
|
|
34
|
+
export interface QuickAction {
|
|
35
|
+
prompt: string;
|
|
36
|
+
metadata?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The built-in quick-actions for the marker popover when no `[[actions]]` are configured. */
|
|
40
|
+
export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
|
41
|
+
{
|
|
42
|
+
prompt: "Zoom out, research in depth",
|
|
43
|
+
metadata: "Research the broader problem, prior art, and alternatives before changing anything.",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
prompt: "Restate simplified",
|
|
47
|
+
metadata: "Restate this as the simplest thing that works and cut the incidental complexity.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
prompt: "Out of scope",
|
|
51
|
+
metadata:
|
|
52
|
+
"This is out of scope for the task, so capture it as a follow-up instead of doing it now.",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
prompt: "Let's chat about this",
|
|
56
|
+
metadata:
|
|
57
|
+
"Do not implement yet, and surface the open questions and trade-offs so we decide together.",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
prompt: "Prototype this",
|
|
61
|
+
metadata: "Build a throwaway prototype to answer the question, skipping tests and polish.",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
prompt: "Ensure 0 regressions",
|
|
65
|
+
metadata:
|
|
66
|
+
"Characterize the current behavior with tests first, then keep them green through the change.",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
prompt: "Consider existing repo patterns",
|
|
70
|
+
metadata:
|
|
71
|
+
"Follow the nearest existing pattern in this codebase rather than introducing a new one.",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
prompt: "LGTM",
|
|
75
|
+
metadata: "This looks good to me.",
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/** The comment body a quick action expands to: the prompt, then its system prompt when set. */
|
|
80
|
+
export function quickActionBody(action: QuickAction): string {
|
|
81
|
+
return action.metadata ? `${action.prompt}\n\n${action.metadata}` : action.prompt;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Resolve a quick action by 1-based index or case-insensitive prompt; undefined when no match. */
|
|
85
|
+
export function resolveQuickAction(
|
|
86
|
+
actions: QuickAction[],
|
|
87
|
+
actionRef: string,
|
|
88
|
+
): QuickAction | undefined {
|
|
89
|
+
const index = Number(actionRef);
|
|
90
|
+
|
|
91
|
+
if (Number.isInteger(index) && index >= 1 && index <= actions.length) return actions[index - 1];
|
|
92
|
+
const wanted = actionRef.trim().toLowerCase();
|
|
93
|
+
|
|
94
|
+
return actions.find((action) => action.prompt.toLowerCase() === wanted);
|
|
95
|
+
}
|
|
96
|
+
|
|
26
97
|
export interface CueloopConfig {
|
|
27
98
|
keys: Record<string, string[]>;
|
|
28
99
|
theme: Theme;
|
|
29
|
-
|
|
100
|
+
/** The `[theme]` per-token overrides alone, so a live theme switch can re-compose them onto a new preset. */
|
|
101
|
+
themeOverrides: Partial<Theme>;
|
|
102
|
+
ui: {
|
|
103
|
+
autoClose: AutoClose;
|
|
104
|
+
editor?: string;
|
|
105
|
+
/** The selected theme preset name; its tokens are the base for `theme`, before any `[theme]` overrides. */
|
|
106
|
+
theme: ThemeName;
|
|
107
|
+
/** How the Changes diff renders when wide: old|new side by side or one stacked column. */
|
|
108
|
+
diffView: DiffViewMode;
|
|
109
|
+
/** Session ids the user has pinned to the top of the sidebar; client-local view state. */
|
|
110
|
+
pins: string[];
|
|
111
|
+
};
|
|
112
|
+
/** Planner-local author renames: identity id → display name ([authors] table). */
|
|
113
|
+
authors: Record<string, string>;
|
|
114
|
+
/** Marker-popover quick actions ([[actions]] tables); the 5 defaults when unset. */
|
|
115
|
+
actions: QuickAction[];
|
|
116
|
+
/** Directory of user-level skills surfaced in the "/" palette ([skills] path); ~/.agents/skills default. */
|
|
117
|
+
skillsPath: string;
|
|
30
118
|
integrations: IntegrationsConfig;
|
|
119
|
+
/** Opt-in experimental features ([experimental] table); all default off. */
|
|
120
|
+
experimental: ExperimentalConfig;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ExperimentalConfig {
|
|
124
|
+
/** Render a prototype as a pixel mockup (kitty graphics) instead of the default markdown design doc. */
|
|
125
|
+
prototypePixels: boolean;
|
|
31
126
|
}
|
|
32
127
|
|
|
33
128
|
/** Every action in the grammar, with its default binding(s). */
|
|
34
|
-
export const DEFAULT_KEYS:
|
|
129
|
+
export const DEFAULT_KEYS: CueloopConfig["keys"] = {
|
|
35
130
|
down: ["j", "down"],
|
|
36
131
|
up: ["k", "up"],
|
|
37
132
|
top: ["g"],
|
|
38
133
|
bottom: ["G"],
|
|
39
134
|
span: ["v"],
|
|
40
135
|
comment: ["c"],
|
|
41
|
-
suggest: ["s"],
|
|
42
136
|
cut: ["x"],
|
|
137
|
+
reject_hunk: ["X"],
|
|
138
|
+
restore_curation: ["u"],
|
|
139
|
+
collapse_file: ["right"],
|
|
140
|
+
expand_file: ["left"],
|
|
141
|
+
split_diff: ["s"],
|
|
43
142
|
edit: ["e"],
|
|
44
143
|
next_annotation: ["n"],
|
|
45
144
|
prev_annotation: ["p"],
|
|
46
145
|
delete_annotation: ["backspace"],
|
|
146
|
+
rename: ["r"],
|
|
47
147
|
submit: ["return", "enter"],
|
|
148
|
+
share: ["S"],
|
|
48
149
|
quit: ["q"],
|
|
150
|
+
walk: ["w"],
|
|
49
151
|
};
|
|
50
152
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
153
|
+
const SkillsSectionSchema = v.object({ path: v.optional(v.string()) });
|
|
154
|
+
|
|
155
|
+
function skillsPathFrom(
|
|
156
|
+
raw: v.InferOutput<typeof SkillsSectionSchema> | undefined,
|
|
157
|
+
fallback: string,
|
|
158
|
+
): string {
|
|
159
|
+
return raw?.path?.trim() ? raw.path.trim() : fallback;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const ConfigDocumentSchema = v.object({
|
|
163
|
+
actions: v.optional(v.array(v.unknown())),
|
|
164
|
+
authors: v.optional(v.unknown()),
|
|
165
|
+
integrations: v.optional(v.unknown()),
|
|
166
|
+
keys: v.optional(v.unknown()),
|
|
167
|
+
theme: v.optional(v.unknown()),
|
|
168
|
+
ui: v.optional(v.unknown()),
|
|
169
|
+
experimental: v.optional(v.unknown()),
|
|
170
|
+
skills: v.fallback(v.optional(SkillsSectionSchema), undefined),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const QuickActionSchema = v.object({
|
|
174
|
+
prompt: v.pipe(
|
|
175
|
+
v.string(),
|
|
176
|
+
v.check((prompt) => Boolean(prompt.trim())),
|
|
177
|
+
),
|
|
178
|
+
metadata: v.optional(v.string()),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const AuthorsSchema = v.record(v.string(), v.unknown());
|
|
182
|
+
const KeysSchema = v.record(v.string(), v.unknown());
|
|
183
|
+
const KeyComboSchema = v.union([v.string(), v.array(v.string())]);
|
|
184
|
+
// Every field falls back to undefined on its own, so one bad value in a
|
|
185
|
+
// section never discards the section's other settings.
|
|
186
|
+
const UiSchema = v.object({
|
|
187
|
+
auto_close: v.fallback(
|
|
188
|
+
v.optional(v.union([v.literal("off"), v.pipe(v.number(), v.minValue(0))])),
|
|
189
|
+
undefined,
|
|
190
|
+
),
|
|
191
|
+
editor: v.fallback(v.optional(v.string()), undefined),
|
|
192
|
+
theme: v.fallback(v.optional(v.string()), undefined),
|
|
193
|
+
diff_view: v.fallback(v.optional(v.picklist(["split", "stacked", "unified"])), undefined),
|
|
194
|
+
pins: v.fallback(v.optional(v.array(v.string())), undefined),
|
|
195
|
+
});
|
|
196
|
+
const ObsidianSchema = v.object({
|
|
197
|
+
vault: v.fallback(v.optional(v.string()), undefined),
|
|
198
|
+
folder: v.fallback(v.optional(v.string()), undefined),
|
|
199
|
+
filenameFormat: v.fallback(v.optional(v.string()), undefined),
|
|
200
|
+
separator: v.fallback(v.optional(v.picklist(["space", "dash", "underscore"])), undefined),
|
|
201
|
+
exportOn: v.fallback(v.optional(v.picklist(["approve", "resolve", "manual"])), undefined),
|
|
202
|
+
});
|
|
203
|
+
const IntegrationsSchema = v.object({ obsidian: v.optional(ObsidianSchema) });
|
|
204
|
+
const ExperimentalSchema = v.object({
|
|
205
|
+
prototype_pixels: v.fallback(v.optional(v.boolean()), undefined),
|
|
206
|
+
});
|
|
207
|
+
const ThemeOverridesSchema = v.record(v.string(), v.unknown());
|
|
208
|
+
|
|
209
|
+
function isThemeToken(token: string): token is keyof Theme {
|
|
210
|
+
return token in DARK;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseToml(text: string) {
|
|
214
|
+
return v.parse(ConfigDocumentSchema, Bun.TOML.parse(text));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseActions(entries: readonly unknown[] | undefined): QuickAction[] | undefined {
|
|
218
|
+
if (!entries) return undefined;
|
|
219
|
+
const actions: QuickAction[] = [];
|
|
220
|
+
|
|
221
|
+
for (const entry of entries) {
|
|
222
|
+
const result = v.safeParse(QuickActionSchema, entry);
|
|
223
|
+
|
|
224
|
+
if (!result.success) continue;
|
|
225
|
+
const { prompt, metadata } = result.output;
|
|
226
|
+
|
|
227
|
+
actions.push(metadata?.trim() ? { prompt, metadata } : { prompt });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return actions.length ? actions : undefined;
|
|
54
231
|
}
|
|
55
232
|
|
|
56
|
-
function
|
|
233
|
+
function mergeObsidian(
|
|
234
|
+
target: ObsidianConfig,
|
|
235
|
+
obsidian: v.InferOutput<typeof ObsidianSchema>,
|
|
236
|
+
): void {
|
|
237
|
+
if (obsidian.vault !== undefined) target.vault = obsidian.vault;
|
|
238
|
+
if (obsidian.folder !== undefined) target.folder = obsidian.folder;
|
|
239
|
+
if (obsidian.filenameFormat !== undefined) target.filenameFormat = obsidian.filenameFormat;
|
|
240
|
+
if (obsidian.separator !== undefined) target.separator = obsidian.separator;
|
|
241
|
+
if (obsidian.exportOn !== undefined) target.exportOn = obsidian.exportOn;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function layer(
|
|
245
|
+
base: CueloopConfig,
|
|
246
|
+
raw: v.InferOutput<typeof ConfigDocumentSchema>,
|
|
247
|
+
): CueloopConfig {
|
|
57
248
|
const out: CueloopConfig = {
|
|
58
249
|
keys: { ...base.keys },
|
|
59
250
|
theme: { ...base.theme },
|
|
251
|
+
themeOverrides: { ...base.themeOverrides },
|
|
60
252
|
ui: { ...base.ui },
|
|
253
|
+
authors: { ...base.authors },
|
|
254
|
+
actions: [...base.actions],
|
|
255
|
+
skillsPath: base.skillsPath,
|
|
61
256
|
integrations: { obsidian: { ...base.integrations.obsidian } },
|
|
257
|
+
experimental: { ...base.experimental },
|
|
62
258
|
};
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
259
|
+
const actions = parseActions(raw.actions);
|
|
260
|
+
const authors = v.safeParse(AuthorsSchema, raw.authors);
|
|
261
|
+
const keys = v.safeParse(KeysSchema, raw.keys);
|
|
262
|
+
const ui = v.safeParse(UiSchema, raw.ui);
|
|
263
|
+
const integrations = v.safeParse(IntegrationsSchema, raw.integrations);
|
|
264
|
+
const experimental = v.safeParse(ExperimentalSchema, raw.experimental);
|
|
265
|
+
|
|
266
|
+
if (actions) out.actions = actions;
|
|
267
|
+
out.skillsPath = skillsPathFrom(raw.skills, base.skillsPath);
|
|
268
|
+
if (authors.success) {
|
|
269
|
+
for (const [id, value] of Object.entries(authors.output)) {
|
|
270
|
+
const name = v.safeParse(v.string(), value);
|
|
271
|
+
|
|
272
|
+
if (name.success) out.authors[id] = name.output;
|
|
67
273
|
}
|
|
68
274
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
}
|
|
275
|
+
if (keys.success) {
|
|
276
|
+
for (const [action, value] of Object.entries(keys.output)) {
|
|
277
|
+
const combo = v.safeParse(KeyComboSchema, value);
|
|
278
|
+
|
|
279
|
+
if (combo.success)
|
|
280
|
+
out.keys[action] = Array.isArray(combo.output) ? combo.output : [combo.output];
|
|
80
281
|
}
|
|
81
282
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (on === "approve" || on === "resolve" || on === "manual") o.exportOn = on;
|
|
283
|
+
if (ui.success) {
|
|
284
|
+
if (ui.output.auto_close !== undefined) out.ui.autoClose = ui.output.auto_close;
|
|
285
|
+
if (ui.output.editor?.trim()) out.ui.editor = ui.output.editor.trim();
|
|
286
|
+
// "unified" is the pre-rename spelling of "stacked"; keep loading it so an upgrade never flips the layout
|
|
287
|
+
if (ui.output.diff_view !== undefined)
|
|
288
|
+
out.ui.diffView = ui.output.diff_view === "unified" ? "stacked" : ui.output.diff_view;
|
|
289
|
+
if (ui.output.pins !== undefined) out.ui.pins = ui.output.pins;
|
|
290
|
+
}
|
|
291
|
+
if (integrations.success && integrations.output.obsidian) {
|
|
292
|
+
mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
|
|
93
293
|
}
|
|
294
|
+
if (experimental.success && experimental.output.prototype_pixels !== undefined) {
|
|
295
|
+
out.experimental.prototypePixels = experimental.output.prototype_pixels;
|
|
296
|
+
}
|
|
297
|
+
|
|
94
298
|
return out;
|
|
95
299
|
}
|
|
96
300
|
|
|
97
|
-
export function loadConfig(
|
|
301
|
+
export function loadConfig(
|
|
302
|
+
options: { repoRoot?: string; userConfigPath?: string } = {},
|
|
303
|
+
): CueloopConfig {
|
|
98
304
|
let config: CueloopConfig = {
|
|
99
305
|
keys: { ...DEFAULT_KEYS },
|
|
100
306
|
theme: { ...DARK },
|
|
101
|
-
|
|
307
|
+
themeOverrides: {},
|
|
308
|
+
ui: {
|
|
309
|
+
autoClose: "off",
|
|
310
|
+
theme: DEFAULT_THEME_NAME,
|
|
311
|
+
diffView: "split",
|
|
312
|
+
pins: [],
|
|
313
|
+
},
|
|
314
|
+
authors: {},
|
|
315
|
+
actions: [...DEFAULT_QUICK_ACTIONS],
|
|
316
|
+
skillsPath: join(homedir(), ".agents", "skills"),
|
|
102
317
|
integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
|
|
318
|
+
experimental: { prototypePixels: false },
|
|
103
319
|
};
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
320
|
+
// Theme name and per-token overrides are separate concerns, composed once
|
|
321
|
+
// after all layers: the last file to set [ui] theme wins, and every [theme]
|
|
322
|
+
// override from every file lands on top - so a later preset never discards an
|
|
323
|
+
// earlier file's token overrides.
|
|
324
|
+
let themeName = DEFAULT_THEME_NAME;
|
|
325
|
+
const themeOverrides: Partial<Record<keyof Theme, string>> = {};
|
|
326
|
+
const userPath = userConfigPathFrom(options.userConfigPath);
|
|
327
|
+
|
|
328
|
+
for (const path of [
|
|
329
|
+
userPath,
|
|
330
|
+
options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined,
|
|
331
|
+
]) {
|
|
109
332
|
if (!path || !existsSync(path)) continue;
|
|
110
333
|
try {
|
|
111
|
-
|
|
334
|
+
const raw = parseToml(readFileSync(path, "utf8"));
|
|
335
|
+
|
|
336
|
+
config = layer(config, raw);
|
|
337
|
+
const ui = v.safeParse(UiSchema, raw.ui);
|
|
338
|
+
const rawTheme = ui.success ? ui.output.theme : undefined;
|
|
339
|
+
const parsedThemeOverrides = v.safeParse(ThemeOverridesSchema, raw.theme);
|
|
340
|
+
|
|
341
|
+
if (rawTheme && isThemeName(rawTheme)) themeName = rawTheme;
|
|
342
|
+
if (parsedThemeOverrides.success) {
|
|
343
|
+
for (const [token, value] of Object.entries(parsedThemeOverrides.output)) {
|
|
344
|
+
const override = v.safeParse(v.string(), value);
|
|
345
|
+
|
|
346
|
+
if (override.success && isThemeToken(token)) themeOverrides[token] = override.output;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
112
349
|
} catch {
|
|
113
350
|
// a broken config never blocks a review; defaults win
|
|
114
351
|
}
|
|
115
352
|
}
|
|
353
|
+
config.ui.theme = themeName;
|
|
354
|
+
config.themeOverrides = themeOverrides;
|
|
355
|
+
config.theme = { ...themeForName(themeName), ...themeOverrides };
|
|
356
|
+
|
|
116
357
|
return config;
|
|
117
358
|
}
|
|
118
359
|
|
|
119
360
|
/** Reverse lookup: key name (+shift) → action, per the loaded keymap. */
|
|
120
|
-
export function actionFor(
|
|
361
|
+
export function actionFor(
|
|
362
|
+
keys: Record<string, string[]>,
|
|
363
|
+
name: string,
|
|
364
|
+
shift: boolean,
|
|
365
|
+
): string | undefined {
|
|
121
366
|
const wanted = shift && name.length === 1 ? name.toUpperCase() : name;
|
|
367
|
+
|
|
122
368
|
for (const [action, combos] of Object.entries(keys)) {
|
|
123
369
|
if (combos.includes(wanted)) return action;
|
|
124
370
|
}
|
|
371
|
+
|
|
125
372
|
return undefined;
|
|
126
373
|
}
|
|
127
374
|
|
|
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 =
|
|
375
|
+
function userConfigPathFrom(userConfigPath?: string): string {
|
|
376
|
+
return (
|
|
135
377
|
userConfigPath ??
|
|
136
378
|
process.env.CUELOOP_CONFIG ??
|
|
137
|
-
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml")
|
|
138
|
-
|
|
379
|
+
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml")
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Persist one `[ui]` key into the user config file. Line-level TOML surgery on
|
|
385
|
+
* the keys we own: replace the existing assignment, or drop it under an `[ui]`
|
|
386
|
+
* section (appending the section when absent) - a full TOML writer is not worth
|
|
387
|
+
* its weight here.
|
|
388
|
+
*/
|
|
389
|
+
function persistUiSetting(key: string, rendered: string, userConfigPath?: string): void {
|
|
390
|
+
const path = userConfigPathFrom(userConfigPath);
|
|
139
391
|
let text = "";
|
|
392
|
+
|
|
140
393
|
if (existsSync(path)) text = readFileSync(path, "utf8");
|
|
141
|
-
|
|
142
|
-
|
|
394
|
+
const assignment = new RegExp(`^(\\s*)${key}\\s*=.*$`, "m");
|
|
395
|
+
|
|
396
|
+
if (assignment.test(text)) {
|
|
397
|
+
text = text.replace(assignment, `$1${key} = ${rendered}`);
|
|
143
398
|
} else if (/^\[ui\]/m.test(text)) {
|
|
144
|
-
text = text.replace(/^\[ui\]\s*$/m, `[ui]\
|
|
399
|
+
text = text.replace(/^\[ui\]\s*$/m, `[ui]\n${key} = ${rendered}`);
|
|
400
|
+
} else {
|
|
401
|
+
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[ui]\n${key} = ${rendered}\n`;
|
|
402
|
+
}
|
|
403
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
404
|
+
writeFileSync(path, text);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** A double-quoted TOML basic string with the quote-breaking characters escaped. */
|
|
408
|
+
function tomlString(value: string): string {
|
|
409
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Persist the whole quick-action set as `[[actions]]` tables, replacing any
|
|
414
|
+
* existing ones. The set is the user's entire vocabulary (not merged), so the
|
|
415
|
+
* old blocks are stripped and the new ones appended. An empty set clears them.
|
|
416
|
+
*/
|
|
417
|
+
export function persistActions(actions: QuickAction[], userConfigPath?: string): void {
|
|
418
|
+
const path = userConfigPathFrom(userConfigPath);
|
|
419
|
+
let text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
420
|
+
|
|
421
|
+
// strip every existing [[actions]] block (header through its key lines)
|
|
422
|
+
text = text.replace(/^\[\[actions\]\][^[]*/gm, "").trimEnd();
|
|
423
|
+
const blocks = actions
|
|
424
|
+
.map((action) => {
|
|
425
|
+
const lines = [`[[actions]]`, `prompt = ${tomlString(action.prompt)}`];
|
|
426
|
+
|
|
427
|
+
if (action.metadata) lines.push(`metadata = ${tomlString(action.metadata)}`);
|
|
428
|
+
|
|
429
|
+
return lines.join("\n");
|
|
430
|
+
})
|
|
431
|
+
.join("\n\n");
|
|
432
|
+
|
|
433
|
+
text = blocks ? `${text ? `${text}\n\n` : ""}${blocks}\n` : `${text}\n`;
|
|
434
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
435
|
+
writeFileSync(path, text);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Persist the auto-close choice (`[ui] auto_close`) into the user config. */
|
|
439
|
+
export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
|
|
440
|
+
persistUiSetting("auto_close", value === "off" ? '"off"' : String(value), userConfigPath);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Persist the selected theme preset (`[ui] theme`) into the user config. */
|
|
444
|
+
export function persistTheme(name: ThemeName, userConfigPath?: string): void {
|
|
445
|
+
persistUiSetting("theme", `"${name}"`, userConfigPath);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Persist the diff-view choice (`[ui] diff_view`) into the user config. */
|
|
449
|
+
export function persistDiffView(mode: DiffViewMode, userConfigPath?: string): void {
|
|
450
|
+
persistUiSetting("diff_view", `"${mode}"`, userConfigPath);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Persist the pinned-thread ids (`[ui] pins`) into the user config. */
|
|
454
|
+
export function persistPins(ids: readonly string[], userConfigPath?: string): void {
|
|
455
|
+
persistUiSetting("pins", `[${ids.map(tomlString).join(", ")}]`, userConfigPath);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function escapeRegExp(text: string): string {
|
|
459
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Persist an author rename into the `[authors]` table. The identity id (an SSH
|
|
464
|
+
* fingerprint) is not a bare TOML key, so it is written quoted.
|
|
465
|
+
*/
|
|
466
|
+
export function persistAuthorName(id: string, name: string, userConfigPath?: string): void {
|
|
467
|
+
const path = userConfigPathFrom(userConfigPath);
|
|
468
|
+
let text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
469
|
+
const key = `"${id.replace(/"/g, '\\"')}"`;
|
|
470
|
+
const value = `"${name.replace(/"/g, '\\"')}"`;
|
|
471
|
+
const assignment = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*=.*$`, "m");
|
|
472
|
+
|
|
473
|
+
if (assignment.test(text)) {
|
|
474
|
+
text = text.replace(assignment, `$1${key} = ${value}`);
|
|
475
|
+
} else if (/^\[authors\]/m.test(text)) {
|
|
476
|
+
text = text.replace(/^\[authors\]\s*$/m, `[authors]\n${key} = ${value}`);
|
|
145
477
|
} else {
|
|
146
|
-
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[
|
|
478
|
+
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[authors]\n${key} = ${value}\n`;
|
|
147
479
|
}
|
|
148
480
|
mkdirSync(dirname(path), { recursive: true });
|
|
149
481
|
writeFileSync(path, text);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Per-file collapse and full-file weave over the flat diff rows. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type { DiffRow } from "./view-diff";
|
|
5
|
+
import { applyFold, weaveFullFileRows } from "./diff-fold";
|
|
6
|
+
|
|
7
|
+
function row(kind: DiffRow["kind"], text: string, extra: Partial<DiffRow> = {}): DiffRow {
|
|
8
|
+
return { kind, text, file: "a.ts", ...extra };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// a two-line change in a four-line file: line 2 swapped, lines 1/3/4 unchanged
|
|
12
|
+
const base: DiffRow[] = [
|
|
13
|
+
row("file", "a.ts"),
|
|
14
|
+
row("hunk", "@@ -1,3 +1,3 @@"),
|
|
15
|
+
row("ctx", "line one", { oldLine: 1, newLine: 1 }),
|
|
16
|
+
row("del", "old two", { oldLine: 2 }),
|
|
17
|
+
row("add", "new two", { newLine: 2 }),
|
|
18
|
+
row("ctx", "line three", { oldLine: 3, newLine: 3 }),
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const contents = {
|
|
22
|
+
path: "a.ts",
|
|
23
|
+
oldContents: "line one\nold two\nline three\nline four\n",
|
|
24
|
+
newContents: "line one\nnew two\nline three\nline four\n",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("applyFold", () => {
|
|
28
|
+
test("with no fold state it returns the base rows unchanged", () => {
|
|
29
|
+
expect(applyFold(base, new Set(), new Set(), [contents])).toBe(base);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a collapsed file keeps only its file band", () => {
|
|
33
|
+
// Act
|
|
34
|
+
const folded = applyFold(base, new Set(["a.ts"]), new Set(), [contents]);
|
|
35
|
+
|
|
36
|
+
// Assert
|
|
37
|
+
expect(folded).toHaveLength(1);
|
|
38
|
+
expect(folded[0]).toEqual(row("file", "a.ts"));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("an expanded file weaves the unchanged tail line the hunk never showed", () => {
|
|
42
|
+
// Act
|
|
43
|
+
const folded = applyFold(base, new Set(), new Set(["a.ts"]), [contents]);
|
|
44
|
+
|
|
45
|
+
// Assert - the hunk header is gone and "line four" (outside the hunk) now shows
|
|
46
|
+
expect(folded.some((r) => r.kind === "hunk")).toBe(false);
|
|
47
|
+
const four = folded.find((r) => r.text === "line four");
|
|
48
|
+
|
|
49
|
+
expect(four).toEqual(row("ctx", "line four", { oldLine: 4, newLine: 4 }));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("expand is a no-op without the file's full contents", () => {
|
|
53
|
+
// Act - no contents entry, so the file keeps its hunks
|
|
54
|
+
const folded = applyFold(base, new Set(), new Set(["a.ts"]), undefined);
|
|
55
|
+
|
|
56
|
+
// Assert
|
|
57
|
+
expect(folded).toEqual(base);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("weaveFullFileRows", () => {
|
|
62
|
+
test("keeps the change rows and fills every unchanged line as context in order", () => {
|
|
63
|
+
// Arrange - the file's code rows (no headers)
|
|
64
|
+
const codeRows = base.slice(2);
|
|
65
|
+
|
|
66
|
+
// Act
|
|
67
|
+
const woven = weaveFullFileRows("a.ts", codeRows, contents.newContents);
|
|
68
|
+
|
|
69
|
+
// Assert - full file: ctx1, del, add, ctx3, ctx4
|
|
70
|
+
expect(woven.map((r) => `${r.kind}:${r.text}`)).toEqual([
|
|
71
|
+
"ctx:line one",
|
|
72
|
+
"del:old two",
|
|
73
|
+
"add:new two",
|
|
74
|
+
"ctx:line three",
|
|
75
|
+
"ctx:line four",
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
});
|