@cueloop/client 0.1.0-alpha.77 → 0.1.0-alpha.79
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 +14 -13
- package/src/App.autoclose.test.tsx +3 -3
- package/src/App.diff.test.tsx +22 -5
- package/src/App.export.test.tsx +1 -2
- package/src/App.focus.test.ts +1 -59
- package/src/App.identity.test.tsx +3 -3
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +91 -26
- package/src/App.readonly.test.tsx +6 -5
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +39 -8
- package/src/App.test.tsx +33 -30
- package/src/App.toast.test.tsx +11 -4
- package/src/App.tsx +263 -274
- package/src/App.walk.test.tsx +11 -10
- package/src/App.workbench.test.tsx +86 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/annotation-palette.ts +1 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-screens.tsx +19 -9
- package/src/app-view-model.ts +3 -1
- package/src/components/AnnotatableFileView.tsx +1 -1
- package/src/components/AnnotationCards.tsx +18 -2
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/CompletionOverlay.stories.tsx +15 -1
- package/src/components/CompletionOverlay.tsx +22 -5
- package/src/components/ConfirmCard.stories.tsx +2 -0
- package/src/components/ConfirmCard.tsx +45 -18
- package/src/components/ConfirmDialog.stories.tsx +3 -3
- package/src/components/ConfirmDialog.tsx +11 -12
- package/src/components/DiffContentView.tsx +18 -11
- package/src/components/DiscussionMarkerRail.tsx +9 -4
- package/src/components/FileContentsView.tsx +1 -1
- package/src/components/GridTabContent.tsx +8 -2
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/PromptDialog.test.tsx +2 -2
- package/src/components/PromptDialog.tsx +9 -10
- package/src/components/SettingsDialog.tsx +1 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.tsx +4 -1
- package/src/components/ThreadTree.tsx +12 -7
- package/src/components/ThreadView.selection.test.tsx +88 -1
- package/src/components/ThreadView.slash.test.tsx +0 -36
- package/src/components/ThreadView.tsx +200 -67
- package/src/components/Toast.stories.tsx +7 -0
- package/src/components/Toast.tsx +15 -8
- package/src/components/WalkWizard.stories.tsx +2 -2
- package/src/components/WalkWizard.tsx +10 -12
- package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
- package/src/components/primitives/Button.tsx +6 -2
- package/src/components/primitives/Card.tsx +5 -2
- package/src/components/primitives/Dialog.tsx +2 -2
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tree.tsx +5 -1
- package/src/components/primitives/frame.ts +5 -0
- package/src/components/session-tree.ts +7 -6
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +13 -4
- package/src/components/stories.test.tsx +12 -4
- package/src/components/story-tree.test.ts +32 -24
- package/src/components/story.ts +32 -15
- package/src/config.test.ts +18 -0
- package/src/config.ts +8 -1
- package/src/diff-fold.ts +1 -1
- package/src/diff-intraline.test.ts +75 -2
- package/src/diff-intraline.ts +95 -26
- package/src/intent-dispatch.test.ts +12 -3
- package/src/intent-dispatch.ts +10 -25
- package/src/key-bindings.ts +0 -6
- package/src/keymap.test.ts +2 -2
- package/src/keymap.ts +0 -2
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/prototype-browser.test.ts +9 -1
- package/src/prototype-browser.ts +22 -0
- package/src/run.ts +39 -25
- package/src/serve.ts +1 -1
- package/src/share.ts +16 -2
- package/src/slash-palette.test.ts +17 -0
- package/src/slash-palette.ts +11 -0
- package/src/test-support.ts +14 -0
- package/src/thread-chords.test.ts +55 -178
- package/src/thread-chords.ts +78 -169
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +3 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +60 -5
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +6 -1
- package/src/thread-controller.ts +386 -78
- package/src/use-annotation-surface.tsx +88 -63
- package/src/use-settings-dialog.tsx +14 -3
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/view-plan.test.ts +56 -6
- package/src/view-plan.ts +154 -5
- package/src/App.share-choice.test.tsx +0 -105
- package/src/components/ManageAccessDialog.stories.tsx +0 -36
- package/src/components/ManageAccessDialog.test.tsx +0 -94
- package/src/components/ManageAccessDialog.tsx +0 -120
- package/src/components/ShareChoiceDialog.stories.tsx +0 -34
- package/src/components/ShareChoiceDialog.test.tsx +0 -103
- package/src/components/ShareChoiceDialog.tsx +0 -82
- package/src/components/ThreadsSidebar.stories.tsx +0 -36
- package/src/components/ThreadsSidebar.tsx +0 -71
- package/src/components/agent-launcher.stories.tsx +0 -26
- package/src/components/agent-launcher.test.tsx +0 -50
- package/src/components/agent-launcher.tsx +0 -149
package/src/config.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obsidian";
|
|
12
|
+
import type { VerdictKind } from "@cueloop/schema";
|
|
12
13
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
13
14
|
import { dirname } from "node:path";
|
|
14
15
|
import * as v from "valibot";
|
|
@@ -107,6 +108,7 @@ export interface CueloopConfig {
|
|
|
107
108
|
theme: ThemeName;
|
|
108
109
|
/** How the Changes diff renders when wide: old|new side by side or one stacked column. */
|
|
109
110
|
diffView: DiffViewMode;
|
|
111
|
+
defaultVerdict: VerdictKind;
|
|
110
112
|
/** Session ids the user has pinned to the top of the sidebar; client-local view state. */
|
|
111
113
|
pins: string[];
|
|
112
114
|
/** The last pane layout a bare launch restores; unset until the user changes one. */
|
|
@@ -162,7 +164,6 @@ export const DEFAULT_KEYS: CueloopConfig["keys"] = {
|
|
|
162
164
|
share: ["S"],
|
|
163
165
|
quit: ["q"],
|
|
164
166
|
walk: ["w"],
|
|
165
|
-
leader: ["ctrl+g"],
|
|
166
167
|
};
|
|
167
168
|
|
|
168
169
|
const SkillsSectionSchema = v.object({ path: v.optional(v.string()) });
|
|
@@ -212,6 +213,10 @@ const UiSchema = v.object({
|
|
|
212
213
|
editor: v.fallback(v.optional(v.string()), undefined),
|
|
213
214
|
theme: v.fallback(v.optional(v.string()), undefined),
|
|
214
215
|
diff_view: v.fallback(v.optional(v.picklist(["split", "stacked", "unified"])), undefined),
|
|
216
|
+
default_verdict: v.fallback(
|
|
217
|
+
v.optional(v.picklist(["comment", "approve", "request_changes"])),
|
|
218
|
+
undefined,
|
|
219
|
+
),
|
|
215
220
|
pins: v.fallback(v.optional(v.array(v.string())), undefined),
|
|
216
221
|
layout: v.fallback(
|
|
217
222
|
v.optional(
|
|
@@ -279,6 +284,7 @@ function applyUi(ui: CueloopConfig["ui"], parsed: v.InferOutput<typeof UiSchema>
|
|
|
279
284
|
// "unified" is the pre-rename spelling of "stacked"; keep loading it so an upgrade never flips the layout
|
|
280
285
|
if (parsed.diff_view !== undefined)
|
|
281
286
|
ui.diffView = parsed.diff_view === "unified" ? "stacked" : parsed.diff_view;
|
|
287
|
+
if (parsed.default_verdict !== undefined) ui.defaultVerdict = parsed.default_verdict;
|
|
282
288
|
if (parsed.pins !== undefined) ui.pins = parsed.pins;
|
|
283
289
|
if (parsed.layout !== undefined) {
|
|
284
290
|
ui.layout = {
|
|
@@ -358,6 +364,7 @@ export function loadConfig(
|
|
|
358
364
|
autoClose: "off",
|
|
359
365
|
theme: DEFAULT_THEME_NAME,
|
|
360
366
|
diffView: "split",
|
|
367
|
+
defaultVerdict: "approve",
|
|
361
368
|
pins: [],
|
|
362
369
|
},
|
|
363
370
|
authors: {},
|
package/src/diff-fold.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Per-file fold state for the diff sheet, applied over the flat diff rows so the
|
|
3
3
|
* cursor, curation, and rendering all read one array. A collapsed file shows only
|
|
4
4
|
* its file band; an expanded file shows its whole contents with the diff woven in
|
|
5
|
-
* (unchanged lines become context rows),
|
|
5
|
+
* (unchanged lines become context rows), an "expand all lines" view.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { DiffRow } from "./view-diff";
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import * as intraline from "./diff-intraline";
|
|
3
|
+
import {
|
|
4
|
+
createIntralineResolver,
|
|
5
|
+
intralineRunsByRow,
|
|
6
|
+
wordLevelChanges,
|
|
7
|
+
type IntralineRun,
|
|
8
|
+
} from "./diff-intraline";
|
|
3
9
|
import type { DiffRow } from "./view-diff";
|
|
4
10
|
|
|
5
11
|
function line(kind: DiffRow["kind"], text: string): DiffRow {
|
|
@@ -163,3 +169,70 @@ describe("intralineRunsByRow", () => {
|
|
|
163
169
|
expect(changedText(runsByRow.get(3)!)).toBe("FOUR");
|
|
164
170
|
});
|
|
165
171
|
});
|
|
172
|
+
|
|
173
|
+
describe("createIntralineResolver", () => {
|
|
174
|
+
test("resolves each row to the same runs the eager pass would, on demand", () => {
|
|
175
|
+
// Arrange - context around two separate change blocks
|
|
176
|
+
const rows = [
|
|
177
|
+
line("ctx", "header"),
|
|
178
|
+
line("del", "one two"),
|
|
179
|
+
line("add", "one TWO"),
|
|
180
|
+
line("ctx", "middle"),
|
|
181
|
+
line("del", "alpha beta"),
|
|
182
|
+
line("add", "alpha GAMMA"),
|
|
183
|
+
line("ctx", "footer"),
|
|
184
|
+
];
|
|
185
|
+
const eager = intralineRunsByRow(rows);
|
|
186
|
+
|
|
187
|
+
// Act
|
|
188
|
+
const resolver = createIntralineResolver(rows);
|
|
189
|
+
|
|
190
|
+
// Assert - lazy row-by-row output matches the eager map, including undefined for context rows
|
|
191
|
+
for (let index = 0; index < rows.length; index++) {
|
|
192
|
+
expect(resolver.runsForRow(index)).toEqual(eager.get(index));
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("a second call for the same row returns the cached runs", () => {
|
|
197
|
+
const rows = [line("del", "one two"), line("add", "one TWO")];
|
|
198
|
+
const resolver = createIntralineResolver(rows);
|
|
199
|
+
|
|
200
|
+
expect(resolver.runsForRow(0)).toBe(resolver.runsForRow(0));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// The performance guard: a virtualized diff must word-diff only the change blocks whose rows are on
|
|
204
|
+
// screen. This is deterministic (a call count, not a wall-clock), so it fails on any machine if a
|
|
205
|
+
// future change makes the resolver eager again - the regression the nav-latency benchmark would show.
|
|
206
|
+
test("word-diffs only the accessed block, not the whole diff", () => {
|
|
207
|
+
// Arrange - 50 independent change blocks, each a single del/add pair separated by context
|
|
208
|
+
const rows: DiffRow[] = [];
|
|
209
|
+
|
|
210
|
+
for (let block = 0; block < 50; block++) {
|
|
211
|
+
rows.push(
|
|
212
|
+
line("ctx", `ctx ${block}`),
|
|
213
|
+
line("del", `old ${block}`),
|
|
214
|
+
line("add", `new ${block}`),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const spy = spyOn(intraline, "wordLevelChanges");
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
// Act - the eager pass word-diffs every block
|
|
221
|
+
spy.mockClear();
|
|
222
|
+
intralineRunsByRow(rows);
|
|
223
|
+
const eagerCalls = spy.mock.calls.length;
|
|
224
|
+
|
|
225
|
+
// Act - the resolver word-diffs only the one block whose row is asked for
|
|
226
|
+
spy.mockClear();
|
|
227
|
+
const resolver = createIntralineResolver(rows);
|
|
228
|
+
resolver.runsForRow(1); // a row in block 0
|
|
229
|
+
const oneBlockCalls = spy.mock.calls.length;
|
|
230
|
+
|
|
231
|
+
// Assert - eager pays for all 50 blocks; lazy pays for one
|
|
232
|
+
expect(eagerCalls).toBe(50);
|
|
233
|
+
expect(oneBlockCalls).toBe(1);
|
|
234
|
+
} finally {
|
|
235
|
+
spy.mockRestore();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
package/src/diff-intraline.ts
CHANGED
|
@@ -36,16 +36,16 @@ function stripTrailingNewline(text: string): string {
|
|
|
36
36
|
return text.replace(/\n$/, "");
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
const
|
|
39
|
+
/** A change block: a run of deletion rows immediately followed by addition rows. */
|
|
40
|
+
interface IntralineBlock {
|
|
41
|
+
deletionStart: number;
|
|
42
|
+
additionStart: number;
|
|
43
|
+
end: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The change blocks in row order, found in one cheap pass (no word diff yet). */
|
|
47
|
+
function intralineBlocks(rows: DiffRow[]): IntralineBlock[] {
|
|
48
|
+
const blocks: IntralineBlock[] = [];
|
|
49
49
|
let index = 0;
|
|
50
50
|
|
|
51
51
|
while (index < rows.length) {
|
|
@@ -59,22 +59,91 @@ export function intralineRunsByRow(rows: DiffRow[]): Map<number, IntralineRun[]>
|
|
|
59
59
|
const additionStart = index;
|
|
60
60
|
|
|
61
61
|
while (index < rows.length && rows[index]!.kind === "add") index++;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
62
|
+
blocks.push({ deletionStart, additionStart, end: index });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return blocks;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Word-diff one change block into [rowIndex, runs] pairs; unmatched rows get no entry. */
|
|
69
|
+
function blockRunEntries(rows: DiffRow[], block: IntralineBlock): Array<[number, IntralineRun[]]> {
|
|
70
|
+
const deletionTexts = rows
|
|
71
|
+
.slice(block.deletionStart, block.additionStart)
|
|
72
|
+
.map((row) => stripTrailingNewline(row.text));
|
|
73
|
+
const additionTexts = rows
|
|
74
|
+
.slice(block.additionStart, block.end)
|
|
75
|
+
.map((row) => stripTrailingNewline(row.text));
|
|
76
|
+
const entries: Array<[number, IntralineRun[]]> = [];
|
|
77
|
+
|
|
78
|
+
for (const [deletionOffset, additionOffset] of alignedPairs(deletionTexts, additionTexts)) {
|
|
79
|
+
const changes = wordLevelChanges(
|
|
80
|
+
deletionTexts[deletionOffset]!,
|
|
81
|
+
additionTexts[additionOffset]!,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
entries.push([block.deletionStart + deletionOffset, sideRuns(changes, "removed")]);
|
|
85
|
+
entries.push([block.additionStart + additionOffset, sideRuns(changes, "added")]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return entries;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The change block whose rows cover `rowIndex`, by binary search over the ordered blocks. */
|
|
92
|
+
function blockContaining(blocks: IntralineBlock[], rowIndex: number): number | undefined {
|
|
93
|
+
let low = 0;
|
|
94
|
+
let high = blocks.length - 1;
|
|
95
|
+
|
|
96
|
+
while (low <= high) {
|
|
97
|
+
const mid = (low + high) >> 1;
|
|
98
|
+
const block = blocks[mid]!;
|
|
99
|
+
|
|
100
|
+
if (rowIndex < block.deletionStart) high = mid - 1;
|
|
101
|
+
else if (rowIndex >= block.end) low = mid + 1;
|
|
102
|
+
else return mid;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A row's intra-line runs, resolved on demand. */
|
|
109
|
+
export interface IntralineResolver {
|
|
110
|
+
runsForRow(rowIndex: number): IntralineRun[] | undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Char-precise intra-line runs for modified diff rows, computed lazily. The change blocks are found
|
|
115
|
+
* up front in one cheap pass; a block is word-diffed only when a row inside it is first asked for,
|
|
116
|
+
* so a virtualized diff pays for the rows on screen, not for the whole file. Within a change block -
|
|
117
|
+
* deletions immediately followed by additions - lines align to their real counterpart (alignedPairs)
|
|
118
|
+
* and each matched pair is word-diffed; unmatched rows get no runs, so the caller renders those whole.
|
|
119
|
+
*/
|
|
120
|
+
export function createIntralineResolver(rows: DiffRow[]): IntralineResolver {
|
|
121
|
+
const blocks = intralineBlocks(rows);
|
|
122
|
+
const computed = new Set<number>();
|
|
123
|
+
const runsByRow = new Map<number, IntralineRun[]>();
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
runsForRow(rowIndex) {
|
|
127
|
+
const blockIndex = blockContaining(blocks, rowIndex);
|
|
128
|
+
|
|
129
|
+
if (blockIndex === undefined) return undefined;
|
|
130
|
+
if (!computed.has(blockIndex)) {
|
|
131
|
+
computed.add(blockIndex);
|
|
132
|
+
for (const [row, runs] of blockRunEntries(rows, blocks[blockIndex]!))
|
|
133
|
+
runsByRow.set(row, runs);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return runsByRow.get(rowIndex);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Every modified row's intra-line runs, eagerly. Prefer createIntralineResolver on a hot path. */
|
|
142
|
+
export function intralineRunsByRow(rows: DiffRow[]): Map<number, IntralineRun[]> {
|
|
143
|
+
const runsByRow = new Map<number, IntralineRun[]>();
|
|
144
|
+
|
|
145
|
+
for (const block of intralineBlocks(rows)) {
|
|
146
|
+
for (const [row, runs] of blockRunEntries(rows, block)) runsByRow.set(row, runs);
|
|
78
147
|
}
|
|
79
148
|
|
|
80
149
|
return runsByRow;
|
|
@@ -93,6 +93,7 @@ function baseController(): ReviewController {
|
|
|
93
93
|
curationItems: mock(() => []),
|
|
94
94
|
restoreCuration: mock(),
|
|
95
95
|
edit: mock(),
|
|
96
|
+
saveEditedBody: mock(),
|
|
96
97
|
annotate: mock(() => undefined),
|
|
97
98
|
addComment: mock(() => undefined),
|
|
98
99
|
commentOnWorkbench: mock(() => Promise.resolve()),
|
|
@@ -108,7 +109,13 @@ function baseController(): ReviewController {
|
|
|
108
109
|
walkLeave: mock(),
|
|
109
110
|
submit: mock(),
|
|
110
111
|
share: mock(),
|
|
112
|
+
unshare: mock(),
|
|
111
113
|
setShareAccess: mock(),
|
|
114
|
+
shareLinks: mock(() => []),
|
|
115
|
+
createShareLink: mock(),
|
|
116
|
+
updateShareLink: mock(),
|
|
117
|
+
deleteShareLink: mock(),
|
|
118
|
+
copyShareLink: mock(),
|
|
112
119
|
treeRows: mock(() => []),
|
|
113
120
|
goToEntry: mock(),
|
|
114
121
|
branch: mock(),
|
|
@@ -136,6 +143,7 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
|
|
|
136
143
|
inboxCursor: 0,
|
|
137
144
|
mode: { type: "normal" },
|
|
138
145
|
session: null,
|
|
146
|
+
defaultVerdict: "approve",
|
|
139
147
|
focusedAnnotationId: undefined,
|
|
140
148
|
selectedCurationId: undefined,
|
|
141
149
|
railTab: "review",
|
|
@@ -155,9 +163,10 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
|
|
|
155
163
|
setSelectedCurationId: mock(),
|
|
156
164
|
setPulsedAnnotationId: mock(),
|
|
157
165
|
selectCardFromDocument: mock(),
|
|
158
|
-
|
|
166
|
+
openBodyEditor: mock(),
|
|
159
167
|
openCardEdit: mock(),
|
|
160
168
|
toggleDiffView: mock(),
|
|
169
|
+
openShareDialog: mock(),
|
|
161
170
|
...overrides,
|
|
162
171
|
};
|
|
163
172
|
}
|
|
@@ -385,7 +394,7 @@ describe("openSubmit", () => {
|
|
|
385
394
|
});
|
|
386
395
|
|
|
387
396
|
describe("share", () => {
|
|
388
|
-
test("opens the
|
|
397
|
+
test("opens the share dialog instead of publishing directly", () => {
|
|
389
398
|
// Arrange
|
|
390
399
|
const deps = makeDeps();
|
|
391
400
|
const dispatch = createIntentDispatch(deps);
|
|
@@ -394,7 +403,7 @@ describe("share", () => {
|
|
|
394
403
|
dispatch({ type: "share" });
|
|
395
404
|
|
|
396
405
|
// Assert
|
|
397
|
-
expect(deps.
|
|
406
|
+
expect(deps.openShareDialog).toHaveBeenCalled();
|
|
398
407
|
expect(deps.controller.share).not.toHaveBeenCalled();
|
|
399
408
|
});
|
|
400
409
|
});
|
package/src/intent-dispatch.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
11
|
-
import { isAddressed,
|
|
11
|
+
import { isAddressed, type Thread, type VerdictKind } from "@cueloop/schema";
|
|
12
12
|
import { displayText, spanKey, startSpan, type DisplayBlock, type SpanState } from "./view-plan";
|
|
13
13
|
import type { DiffRow } from "./view-diff";
|
|
14
14
|
import type { ReviewController } from "./thread-controller";
|
|
@@ -18,7 +18,7 @@ import { quickActionBody, type QuickAction } from "./config";
|
|
|
18
18
|
import { VERDICTS } from "./components/ConfirmCard";
|
|
19
19
|
|
|
20
20
|
/** Which pane of the session tree / review the keyboard grammar is aimed at. */
|
|
21
|
-
export type RailTab = "review" | "
|
|
21
|
+
export type RailTab = "review" | "tree";
|
|
22
22
|
|
|
23
23
|
/** The one overlay/mode the TUI is in; every compose/submit/edit flow is a variant. */
|
|
24
24
|
export type Mode =
|
|
@@ -40,7 +40,6 @@ export type Mode =
|
|
|
40
40
|
| { type: "renameThread"; sessionId: string; text: string }
|
|
41
41
|
| { type: "nameSelf"; text: string }
|
|
42
42
|
| { type: "renameSelf"; text: string }
|
|
43
|
-
| { type: "shareChoice"; index: number }
|
|
44
43
|
| { type: "treePrompt"; ask: TreeAsk; entryId?: string; text: string };
|
|
45
44
|
|
|
46
45
|
/** What a tree prompt asks for: a branch name, a checkpoint name, or the summary a move back leaves. */
|
|
@@ -51,23 +50,6 @@ export function activeSpanState(mode: Mode): SpanState | null {
|
|
|
51
50
|
return mode.type === "span" || mode.type === "spanActions" ? mode.span : null;
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
/**
|
|
55
|
-
* Annotations that still count as feedback: agent notes never do, and an
|
|
56
|
-
* annotation a revision already addressed is settled - it neither blocks the
|
|
57
|
-
* verdict default nor re-enters the next feedback document.
|
|
58
|
-
*/
|
|
59
|
-
export function reviewerAnnotations(session: Thread) {
|
|
60
|
-
return session.annotations.filter(
|
|
61
|
-
(annotation) => !isAgentNote(annotation) && !isAddressed(annotation),
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function defaultVerdict(session: Thread): VerdictKind {
|
|
66
|
-
return reviewerAnnotations(session).length || session.workingCopy !== undefined
|
|
67
|
-
? "request_changes"
|
|
68
|
-
: "approve";
|
|
69
|
-
}
|
|
70
|
-
|
|
71
53
|
/**
|
|
72
54
|
* Everything the reducer reads or writes, supplied fresh each render: the
|
|
73
55
|
* controller and exit hook, the derived reads (view rows, cursor, mode,
|
|
@@ -86,6 +68,7 @@ export interface IntentDispatchDeps {
|
|
|
86
68
|
inboxCursor: number;
|
|
87
69
|
mode: Mode;
|
|
88
70
|
session: Thread | null;
|
|
71
|
+
defaultVerdict: VerdictKind;
|
|
89
72
|
focusedAnnotationId: string | undefined;
|
|
90
73
|
/** The curation item selected for undo, if any. */
|
|
91
74
|
selectedCurationId: string | undefined;
|
|
@@ -115,10 +98,12 @@ export interface IntentDispatchDeps {
|
|
|
115
98
|
setPulsedAnnotationId: Dispatch<SetStateAction<string | null>>;
|
|
116
99
|
|
|
117
100
|
selectCardFromDocument: (annotationId: string) => void;
|
|
118
|
-
|
|
101
|
+
openBodyEditor: () => void;
|
|
119
102
|
openCardEdit: (annotationId: string) => void;
|
|
120
103
|
/** Flip split/stacked diff and persist it (App-owned); split lays out only when wide/zoomed. */
|
|
121
104
|
toggleDiffView: () => void;
|
|
105
|
+
/** Open the share dialog (App-owned); the dialog owns its own keys and links. */
|
|
106
|
+
openShareDialog: () => void;
|
|
122
107
|
}
|
|
123
108
|
|
|
124
109
|
type IntentOfType<Kind extends Intent["type"]> = Extract<Intent, { type: Kind }>;
|
|
@@ -383,12 +368,12 @@ function handleOpenSubmit(_intent: IntentOfType<"openSubmit">, deps: IntentDispa
|
|
|
383
368
|
|
|
384
369
|
if (!session) return;
|
|
385
370
|
deps.liveInput.current = "";
|
|
386
|
-
deps.setMode({ type: "submit", verdict: defaultVerdict
|
|
371
|
+
deps.setMode({ type: "submit", verdict: deps.defaultVerdict, summary: "" });
|
|
387
372
|
}
|
|
388
373
|
|
|
389
|
-
// share opens the
|
|
374
|
+
// share opens the share dialog; it owns its own keys, links list, and publish wizard
|
|
390
375
|
function handleShare(_intent: IntentOfType<"share">, deps: IntentDispatchDeps): void {
|
|
391
|
-
deps.
|
|
376
|
+
deps.openShareDialog();
|
|
392
377
|
}
|
|
393
378
|
|
|
394
379
|
function handleCut(_intent: IntentOfType<"cut">, deps: IntentDispatchDeps): void {
|
|
@@ -450,7 +435,7 @@ function handleRestoreCuration(
|
|
|
450
435
|
}
|
|
451
436
|
|
|
452
437
|
function handleEdit(_intent: IntentOfType<"edit">, deps: IntentDispatchDeps): void {
|
|
453
|
-
deps.
|
|
438
|
+
deps.openBodyEditor();
|
|
454
439
|
}
|
|
455
440
|
|
|
456
441
|
function handleEditCard(_intent: IntentOfType<"editCard">, deps: IntentDispatchDeps): void {
|
package/src/key-bindings.ts
CHANGED
|
@@ -35,9 +35,6 @@ export type HintMode =
|
|
|
35
35
|
| "walk"
|
|
36
36
|
| "read-only";
|
|
37
37
|
|
|
38
|
-
/** Display glyph for the Agent-tab terminal detach chord (ctrl+], App-owned). */
|
|
39
|
-
export const AGENT_DETACH_HINT = "⌃]";
|
|
40
|
-
|
|
41
38
|
export interface CheatsheetEntry {
|
|
42
39
|
keys: string;
|
|
43
40
|
label: string;
|
|
@@ -453,9 +450,6 @@ export class KeyBindings {
|
|
|
453
450
|
build({ overlay: "none", spanMode: true }, "span", "Selection"),
|
|
454
451
|
build({ overlay: "submit", spanMode: false }, "submit", "Submit"),
|
|
455
452
|
build({ overlay: "walk", spanMode: false }, "walk", "Walk"),
|
|
456
|
-
// The Agent-tab terminal chord is owned by App (not the rebindable keymap),
|
|
457
|
-
// so it is listed statically rather than resolved from the active keys.
|
|
458
|
-
{ title: "Agent terminal", entries: [{ keys: AGENT_DETACH_HINT, label: "detach" }] },
|
|
459
453
|
];
|
|
460
454
|
|
|
461
455
|
this.context = saved;
|
package/src/keymap.test.ts
CHANGED
|
@@ -59,8 +59,8 @@ describe("submit overlay", () => {
|
|
|
59
59
|
const keyState = state({ overlay: "submit" });
|
|
60
60
|
const table: [string, Intent[]][] = [
|
|
61
61
|
["escape", [{ type: "closeOverlay" }]],
|
|
62
|
-
["return", [
|
|
63
|
-
["enter", [
|
|
62
|
+
["return", []],
|
|
63
|
+
["enter", []],
|
|
64
64
|
["left", [{ type: "cycleVerdict", direction: -1 }]],
|
|
65
65
|
["right", [{ type: "cycleVerdict", direction: 1 }]],
|
|
66
66
|
["j", []],
|
package/src/keymap.ts
CHANGED
|
@@ -87,7 +87,6 @@ export interface KeyState {
|
|
|
87
87
|
| "submit"
|
|
88
88
|
| "confirm"
|
|
89
89
|
| "prompt"
|
|
90
|
-
| "shareChoice"
|
|
91
90
|
| "spanActions"
|
|
92
91
|
| "completion-prompt"
|
|
93
92
|
| "completion-counting";
|
|
@@ -193,7 +192,6 @@ function submitOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
|
193
192
|
const name = key.name;
|
|
194
193
|
|
|
195
194
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
196
|
-
if (name === "return" || name === "enter") return [{ type: "submitVerdict" }];
|
|
197
195
|
if (name === "left" || name === "right") {
|
|
198
196
|
return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
|
|
199
197
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The native SyntaxStyle for the inline markdown editor: maps each markdown
|
|
3
|
+
* token group to a theme color and attribute set, and resolves the styleId the
|
|
4
|
+
* edit buffer needs for `addHighlightByCharRange`. Cached per theme, mirroring
|
|
5
|
+
* syntaxStyleFor. Syntax punctuation (the "marker" group) is dimmed rather than
|
|
6
|
+
* hidden, since the editor shows raw source.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SyntaxStyle, type StyleDefinitionInput } from "@opentui/core";
|
|
10
|
+
import { type Theme } from "./theme";
|
|
11
|
+
import { MARKDOWN_HIGHLIGHT_GROUPS, type MarkdownHighlightGroup } from "./markdown-highlight";
|
|
12
|
+
|
|
13
|
+
/** A markdown SyntaxStyle plus the lookup from token group to its native styleId. */
|
|
14
|
+
export interface MarkdownEditorStyle {
|
|
15
|
+
style: SyntaxStyle;
|
|
16
|
+
styleIdFor: (group: MarkdownHighlightGroup) => number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function markdownGroupStyles(theme: Theme): Record<MarkdownHighlightGroup, StyleDefinitionInput> {
|
|
20
|
+
return {
|
|
21
|
+
heading: { bold: true },
|
|
22
|
+
marker: { fg: theme.textDim },
|
|
23
|
+
link: { fg: theme.blue, underline: true },
|
|
24
|
+
code: { fg: theme.textDim },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const markdownEditorStyleCache = new WeakMap<Theme, MarkdownEditorStyle>();
|
|
29
|
+
|
|
30
|
+
/** Build (or reuse) the markdown editor's SyntaxStyle for a theme, with a group-to-styleId lookup. */
|
|
31
|
+
export function markdownEditorStyle(theme: Theme): MarkdownEditorStyle {
|
|
32
|
+
const cached = markdownEditorStyleCache.get(theme);
|
|
33
|
+
|
|
34
|
+
if (cached) return cached;
|
|
35
|
+
const groupStyles = markdownGroupStyles(theme);
|
|
36
|
+
const style = SyntaxStyle.fromStyles(groupStyles);
|
|
37
|
+
const styleIds = new Map<MarkdownHighlightGroup, number>();
|
|
38
|
+
|
|
39
|
+
for (const group of MARKDOWN_HIGHLIGHT_GROUPS) {
|
|
40
|
+
styleIds.set(group, style.getStyleId(group) ?? 0);
|
|
41
|
+
}
|
|
42
|
+
const entry: MarkdownEditorStyle = {
|
|
43
|
+
style,
|
|
44
|
+
styleIdFor: (group) => styleIds.get(group) ?? 0,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
markdownEditorStyleCache.set(theme, entry);
|
|
48
|
+
|
|
49
|
+
return entry;
|
|
50
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
layoutFrontmatterGrid,
|
|
4
|
+
layoutMarkdownTable,
|
|
5
|
+
parseFrontmatterRows,
|
|
6
|
+
parseMarkdownTable,
|
|
7
|
+
} from "./markdown-grid";
|
|
8
|
+
|
|
9
|
+
/** The plain text of a grid line, segments concatenated. */
|
|
10
|
+
function lineText(line: { text: string }[]): string {
|
|
11
|
+
return line.map((segment) => segment.text).join("");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("parseMarkdownTable", () => {
|
|
15
|
+
test("reads header cells, alignments, and body rows", () => {
|
|
16
|
+
const table = parseMarkdownTable("| Name | Size |\n| :--- | ---: |\n| a | 1 |\n| bb | 22 |");
|
|
17
|
+
|
|
18
|
+
expect(table.header).toEqual(["Name", "Size"]);
|
|
19
|
+
expect(table.aligns).toEqual(["left", "right"]);
|
|
20
|
+
expect(table.rows).toEqual([
|
|
21
|
+
["a", "1"],
|
|
22
|
+
["bb", "22"],
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("center alignment reads from a :---: delimiter", () => {
|
|
27
|
+
expect(parseMarkdownTable("| a |\n| :---: |\n| x |").aligns).toEqual(["center"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("an escaped pipe stays inside its cell instead of splitting the column", () => {
|
|
31
|
+
const table = parseMarkdownTable("| Name | Note |\n| --- | --- |\n| a | b \\| c |");
|
|
32
|
+
|
|
33
|
+
expect(table.rows).toEqual([["a", "b | c"]]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a blank cell between border pipes survives the border strip", () => {
|
|
37
|
+
expect(parseMarkdownTable("| | b |\n| --- | --- |\n| x | y |").header).toEqual(["", "b"]);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("layoutMarkdownTable", () => {
|
|
42
|
+
test("bolds the header, rules under it, and right-aligns a numeric column", () => {
|
|
43
|
+
const lines = layoutMarkdownTable(
|
|
44
|
+
parseMarkdownTable("| Name | Size |\n| --- | ---: |\n| a | 1 |"),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(lines[0]![0]!.bold).toBe(true);
|
|
48
|
+
expect(lineText(lines[0]!)).toBe("Name Size");
|
|
49
|
+
expect(lines[1]![0]!.dim).toBe(true);
|
|
50
|
+
expect(lineText(lines[1]!)).toBe("─".repeat("Name Size".length));
|
|
51
|
+
// "a" padded to the "Name" column, "1" right-aligned under "Size"
|
|
52
|
+
expect(lineText(lines[2]!)).toBe("a" + " ".repeat(8) + "1");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("parseFrontmatterRows", () => {
|
|
57
|
+
test("splits top-level key: value pairs", () => {
|
|
58
|
+
expect(parseFrontmatterRows("title: Plan\nowner: me")).toEqual([
|
|
59
|
+
{ key: "title", value: "Plan" },
|
|
60
|
+
{ key: "owner", value: "me" },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("folds an indented continuation into the row above", () => {
|
|
65
|
+
expect(parseFrontmatterRows("tags:\n - one\n - two")).toEqual([
|
|
66
|
+
{ key: "tags", value: "- one\n- two" },
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("layoutFrontmatterGrid", () => {
|
|
72
|
+
test("draws a full border with a bold key and a rule between rows", () => {
|
|
73
|
+
const lines = layoutFrontmatterGrid(
|
|
74
|
+
[
|
|
75
|
+
{ key: "title", value: "Plan" },
|
|
76
|
+
{ key: "owner", value: "me" },
|
|
77
|
+
],
|
|
78
|
+
40,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(lineText(lines[0]!).startsWith("┌")).toBe(true);
|
|
82
|
+
expect(lineText(lines.at(-1)!).startsWith("└")).toBe(true);
|
|
83
|
+
// the key cell is bold, the border pieces are dim
|
|
84
|
+
const firstRow = lines[1]!;
|
|
85
|
+
|
|
86
|
+
expect(firstRow.find((segment) => segment.text.includes("title"))!.bold).toBe(true);
|
|
87
|
+
expect(firstRow[0]!.dim).toBe(true);
|
|
88
|
+
// a divider separates the two rows
|
|
89
|
+
expect(lines.some((line) => lineText(line).startsWith("├"))).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("wraps a long value across cell lines, keeping the key on the first", () => {
|
|
93
|
+
const lines = layoutFrontmatterGrid([{ key: "desc", value: "one two three four five" }], 24);
|
|
94
|
+
const bodyLines = lines.filter((line) => lineText(line).startsWith("│"));
|
|
95
|
+
|
|
96
|
+
expect(bodyLines.length).toBeGreaterThan(1);
|
|
97
|
+
expect(bodyLines[0]!.some((segment) => segment.text.includes("desc"))).toBe(true);
|
|
98
|
+
// the continuation line keeps the key column blank
|
|
99
|
+
expect(bodyLines[1]!.some((segment) => segment.text.includes("desc"))).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|