@cueloop/client 0.1.0-alpha.59 → 0.1.0-alpha.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.autoclose.test.tsx +7 -0
- package/src/App.diff.test.tsx +11 -0
- package/src/App.export.test.tsx +5 -0
- package/src/App.plan-review.test.tsx +42 -0
- package/src/App.prototype.test.tsx +11 -0
- package/src/App.readonly.test.tsx +10 -0
- package/src/App.rendering.test.tsx +4 -0
- package/src/App.review-panel.test.tsx +7 -0
- package/src/App.submit-confirm.test.tsx +11 -0
- package/src/App.test.tsx +13 -0
- package/src/App.toast.test.tsx +2 -0
- package/src/App.tsx +27 -0
- package/src/App.walk.test.tsx +10 -0
- package/src/app-key-state.ts +1 -0
- package/src/app-screens.tsx +4 -0
- package/src/app-view-model.ts +21 -0
- package/src/attribution.ts +4 -0
- package/src/clipboard.ts +3 -0
- package/src/components/AnnotationCard.tsx +9 -0
- package/src/components/Breadcrumb.tsx +1 -0
- package/src/components/CodeBlock.tsx +1 -0
- package/src/components/CompletionOverlay.tsx +1 -0
- package/src/components/ConfirmCard.tsx +2 -0
- package/src/components/ConfirmDialog.tsx +2 -0
- package/src/components/DiffSheet.scroll.test.tsx +12 -0
- package/src/components/DiffSheet.syntax.test.tsx +3 -0
- package/src/components/DiffSheet.tsx +19 -0
- package/src/components/InboxList.tsx +2 -0
- package/src/components/KeybindsDialog.tsx +1 -0
- package/src/components/MarkerPopover.tsx +1 -0
- package/src/components/MenuBar.tsx +1 -0
- package/src/components/PlanSheet.tsx +26 -0
- package/src/components/PromptDialog.tsx +2 -0
- package/src/components/PrototypeSheet.tsx +27 -0
- package/src/components/ReviewPanel.tsx +2 -0
- package/src/components/ReviewRail.tsx +1 -0
- package/src/components/SettingsDialog.tsx +6 -0
- package/src/components/SettingsRows.tsx +4 -0
- package/src/components/Toast.tsx +1 -0
- package/src/components/agent-launcher.test.tsx +1 -0
- package/src/components/agent-launcher.tsx +2 -0
- package/src/components/diff-sheet-layout.test.ts +1 -0
- package/src/components/diff-sheet-layout.ts +17 -0
- package/src/components/plan-sheet-run-style.ts +4 -0
- package/src/components/primitives/Button.tsx +1 -0
- package/src/components/primitives/Card.tsx +1 -0
- package/src/components/primitives/Dialog.tsx +2 -0
- package/src/components/primitives/StatusBar.tsx +1 -0
- package/src/components/primitives/Tabs.tsx +4 -0
- package/src/components/quick-actions-editor.tsx +2 -0
- package/src/components/stories-app.tsx +1 -0
- package/src/components/stories.test.tsx +4 -0
- package/src/components/story.ts +6 -0
- package/src/components/syntax-highlight.ts +10 -0
- package/src/components/terminal-pane.ts +12 -0
- package/src/components/theme-context.tsx +1 -0
- package/src/components/truncate-text.ts +1 -0
- package/src/config.test.ts +18 -0
- package/src/config.ts +27 -0
- package/src/diff-hunk-curate.ts +20 -0
- package/src/diff-intraline.test.ts +1 -0
- package/src/diff-intraline.ts +14 -0
- package/src/diff-syntax.ts +22 -0
- package/src/editor.test.ts +6 -0
- package/src/editor.ts +12 -0
- package/src/ghostty-terminal.test.ts +2 -0
- package/src/ghostty-terminal.ts +8 -0
- package/src/herdr.test.ts +2 -0
- package/src/herdr.ts +3 -0
- package/src/integrations.ts +2 -0
- package/src/intent-dispatch.test.ts +6 -0
- package/src/intent-dispatch.ts +37 -0
- package/src/key-bindings.test.ts +4 -0
- package/src/key-bindings.ts +22 -0
- package/src/keymap.test.ts +13 -0
- package/src/keymap.ts +43 -0
- package/src/kitty-image.ts +9 -0
- package/src/prototype-browser.test.ts +1 -0
- package/src/prototype-browser.ts +22 -0
- package/src/pty.test.ts +3 -0
- package/src/pty.ts +15 -0
- package/src/run.ts +1 -0
- package/src/serve.test.ts +1 -0
- package/src/serve.ts +3 -0
- package/src/session-controller.curate.test.ts +7 -0
- package/src/session-controller.share.test.ts +11 -0
- package/src/session-controller.ts +79 -0
- package/src/share.ts +7 -0
- package/src/test-support.test.ts +5 -0
- package/src/test-support.ts +10 -0
- package/src/theme-presets.ts +1 -0
- package/src/use-settings-dialog.tsx +12 -0
- package/src/view-diff.test.ts +3 -0
- package/src/view-diff.ts +5 -0
- package/src/view-plan.test.ts +7 -0
- package/src/view-plan.ts +59 -0
- package/src/walk.ts +4 -0
package/src/diff-syntax.ts
CHANGED
|
@@ -58,12 +58,14 @@ export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
|
|
|
58
58
|
|
|
59
59
|
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
|
60
60
|
const row = rows[rowIndex]!;
|
|
61
|
+
|
|
61
62
|
if (row.kind === "file" || row.kind === "hunk") {
|
|
62
63
|
flush();
|
|
63
64
|
filetype = row.kind === "file" ? filetypeForPath(row.file) : filetype;
|
|
64
65
|
continue;
|
|
65
66
|
}
|
|
66
67
|
const text = stripTrailingNewline(row.text);
|
|
68
|
+
|
|
67
69
|
if (row.kind === "ctx" || row.kind === "del") {
|
|
68
70
|
oldLines.push(text);
|
|
69
71
|
oldRowIndexByLine.push(rowIndex);
|
|
@@ -74,6 +76,7 @@ export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
|
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
flush();
|
|
79
|
+
|
|
77
80
|
return jobs;
|
|
78
81
|
}
|
|
79
82
|
|
|
@@ -84,48 +87,61 @@ export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
|
|
|
84
87
|
*/
|
|
85
88
|
export function spansByLine(source: string, highlights: SimpleHighlight[]): SyntaxSpan[][] {
|
|
86
89
|
const lineStarts: number[] = [0];
|
|
90
|
+
|
|
87
91
|
for (let index = 0; index < source.length; index++) {
|
|
88
92
|
if (source[index] === "\n") lineStarts.push(index + 1);
|
|
89
93
|
}
|
|
90
94
|
const lineOf = (offset: number): number => {
|
|
91
95
|
let low = 0;
|
|
92
96
|
let high = lineStarts.length - 1;
|
|
97
|
+
|
|
93
98
|
while (low < high) {
|
|
94
99
|
const mid = (low + high + 1) >> 1;
|
|
100
|
+
|
|
95
101
|
if (lineStarts[mid]! <= offset) low = mid;
|
|
96
102
|
else high = mid - 1;
|
|
97
103
|
}
|
|
104
|
+
|
|
98
105
|
return low;
|
|
99
106
|
};
|
|
100
107
|
// Per-line char->group, later highlights overriding, then coalesce into spans.
|
|
101
108
|
const groupsByLine: Array<Array<string | undefined>> = lineStarts.map((start, line) => {
|
|
102
109
|
const end = line + 1 < lineStarts.length ? lineStarts[line + 1]! - 1 : source.length;
|
|
110
|
+
|
|
103
111
|
return Array.from({ length: Math.max(0, end - start) }, () => undefined);
|
|
104
112
|
});
|
|
113
|
+
|
|
105
114
|
for (const [start, end, group] of highlights) {
|
|
106
115
|
// A capture can span newlines (block comments, template literals); color the
|
|
107
116
|
// covered columns on every line it crosses, not just the starting line.
|
|
108
117
|
let cursor = start;
|
|
118
|
+
|
|
109
119
|
while (cursor < end) {
|
|
110
120
|
const line = lineOf(cursor);
|
|
111
121
|
const lineStart = lineStarts[line]!;
|
|
112
122
|
const lineEnd = line + 1 < lineStarts.length ? lineStarts[line + 1]! - 1 : source.length;
|
|
113
123
|
const chars = groupsByLine[line]!;
|
|
114
124
|
const to = Math.min(end, lineEnd) - lineStart;
|
|
125
|
+
|
|
115
126
|
for (let column = cursor - lineStart; column < to; column++) chars[column] = group;
|
|
116
127
|
cursor = lineEnd + 1;
|
|
117
128
|
}
|
|
118
129
|
}
|
|
130
|
+
|
|
119
131
|
return groupsByLine.map((chars) => {
|
|
120
132
|
const spans: SyntaxSpan[] = [];
|
|
133
|
+
|
|
121
134
|
for (let column = 0; column < chars.length; column++) {
|
|
122
135
|
const group = chars[column];
|
|
136
|
+
|
|
123
137
|
if (group === undefined) continue;
|
|
124
138
|
const previous = spans[spans.length - 1];
|
|
139
|
+
|
|
125
140
|
if (previous && previous.group === group && previous.end === column)
|
|
126
141
|
previous.end = column + 1;
|
|
127
142
|
else spans.push({ start: column, end: column + 1, group });
|
|
128
143
|
}
|
|
144
|
+
|
|
129
145
|
return spans;
|
|
130
146
|
});
|
|
131
147
|
}
|
|
@@ -138,17 +154,23 @@ export function spansByLine(source: string, highlights: SimpleHighlight[]): Synt
|
|
|
138
154
|
export async function highlightDiffRows(rows: DiffRow[]): Promise<Map<number, SyntaxSpan[]>> {
|
|
139
155
|
const spansByRow = new Map<number, SyntaxSpan[]>();
|
|
140
156
|
const jobs = highlightJobs(rows);
|
|
157
|
+
|
|
141
158
|
if (!jobs.length) return spansByRow;
|
|
142
159
|
const client = getTreeSitterClient();
|
|
160
|
+
|
|
143
161
|
await client.initialize();
|
|
144
162
|
for (const job of jobs) {
|
|
145
163
|
const result = await client.highlightOnce(job.source, job.filetype);
|
|
164
|
+
|
|
146
165
|
if (!result.highlights) continue;
|
|
147
166
|
const lineSpans = spansByLine(job.source, result.highlights);
|
|
167
|
+
|
|
148
168
|
job.rowIndexByLine.forEach((rowIndex, line) => {
|
|
149
169
|
const spans = lineSpans[line];
|
|
170
|
+
|
|
150
171
|
if (spans && spans.length) spansByRow.set(rowIndex, spans);
|
|
151
172
|
});
|
|
152
173
|
}
|
|
174
|
+
|
|
153
175
|
return spansByRow;
|
|
154
176
|
}
|
package/src/editor.test.ts
CHANGED
|
@@ -53,6 +53,7 @@ describe("resolveEditorCommand", () => {
|
|
|
53
53
|
|
|
54
54
|
describe("editInEditor", () => {
|
|
55
55
|
let dir: string;
|
|
56
|
+
|
|
56
57
|
beforeEach(() => {
|
|
57
58
|
dir = mkdtempSync(join(tmpdir(), "cueloop-edit-test-"));
|
|
58
59
|
});
|
|
@@ -63,13 +64,16 @@ describe("editInEditor", () => {
|
|
|
63
64
|
/** A runnable editor stub with a chosen base name and body. */
|
|
64
65
|
function stubEditor(name: string, body: string): string {
|
|
65
66
|
const script = join(dir, name);
|
|
67
|
+
|
|
66
68
|
writeFileSync(script, `#!/bin/sh\n${body}\n`);
|
|
67
69
|
chmodSync(script, 0o755);
|
|
70
|
+
|
|
68
71
|
return script;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
const instant = (): (() => number) => {
|
|
72
75
|
let value = 0;
|
|
76
|
+
|
|
73
77
|
return () => (value += 5);
|
|
74
78
|
};
|
|
75
79
|
|
|
@@ -94,6 +98,7 @@ describe("editInEditor", () => {
|
|
|
94
98
|
confirmSaved: (label, path) => {
|
|
95
99
|
askedLabel = label;
|
|
96
100
|
writeFileSync(path, "saved in the GUI");
|
|
101
|
+
|
|
97
102
|
return true;
|
|
98
103
|
},
|
|
99
104
|
});
|
|
@@ -125,6 +130,7 @@ describe("editInEditor", () => {
|
|
|
125
130
|
now: instant(),
|
|
126
131
|
confirmSaved: () => {
|
|
127
132
|
asked = true;
|
|
133
|
+
|
|
128
134
|
return true;
|
|
129
135
|
},
|
|
130
136
|
});
|
package/src/editor.ts
CHANGED
|
@@ -69,6 +69,7 @@ export function resolveEditor(
|
|
|
69
69
|
env: Record<string, string | undefined> = process.env,
|
|
70
70
|
): string {
|
|
71
71
|
const candidates = [configuredEditor, env.CUELOOP_EDITOR, env.VISUAL, env.EDITOR];
|
|
72
|
+
|
|
72
73
|
return candidates.find((candidate) => candidate && candidate.trim())?.trim() ?? DEFAULT_EDITOR;
|
|
73
74
|
}
|
|
74
75
|
|
|
@@ -80,14 +81,18 @@ function editorBaseName(command: string): string {
|
|
|
80
81
|
export function resolveEditorCommand(rawEditor: string): ResolvedEditor {
|
|
81
82
|
const parts = rawEditor.trim().split(/\s+/);
|
|
82
83
|
const base = editorBaseName(parts[0] ?? "");
|
|
84
|
+
|
|
83
85
|
if (TERMINAL_EDITORS.has(base)) return { argv: parts, waits: true };
|
|
84
86
|
const waitFlags = GUI_WAIT_FLAGS[base];
|
|
87
|
+
|
|
85
88
|
if (waitFlags) {
|
|
86
89
|
const alreadyWaits = parts.some(
|
|
87
90
|
(part) => part === "--wait" || part === "-w" || part === "--block",
|
|
88
91
|
);
|
|
92
|
+
|
|
89
93
|
return { argv: alreadyWaits ? parts : [...parts, ...waitFlags], waits: true };
|
|
90
94
|
}
|
|
95
|
+
|
|
91
96
|
return { argv: parts, waits: false };
|
|
92
97
|
}
|
|
93
98
|
|
|
@@ -97,6 +102,7 @@ const NO_WAIT_THRESHOLD_MS = 1000;
|
|
|
97
102
|
function suspectsNoWait(resolved: ResolvedEditor, elapsedMs: number, unchanged: boolean): boolean {
|
|
98
103
|
if (resolved.waits) return false; // holds the terminal or has a wait flag - trust the exit
|
|
99
104
|
if (!unchanged) return false; // edits landed, so it waited after all
|
|
105
|
+
|
|
100
106
|
return elapsedMs < NO_WAIT_THRESHOLD_MS;
|
|
101
107
|
}
|
|
102
108
|
|
|
@@ -114,6 +120,7 @@ function promptSaved(editorLabel: string, path: string): boolean {
|
|
|
114
120
|
const answer = prompt(
|
|
115
121
|
`${editorLabel} returned immediately and the plan is unchanged - a GUI editor needs its wait flag. Save and close ${path}, then press Enter to load your edits (or type n to skip):`,
|
|
116
122
|
);
|
|
123
|
+
|
|
117
124
|
return answer !== null && answer.trim().toLowerCase() !== "n";
|
|
118
125
|
}
|
|
119
126
|
|
|
@@ -127,6 +134,7 @@ export function editInEditor(
|
|
|
127
134
|
const resolved = resolveEditorCommand(resolveEditor(handOff.editor, env));
|
|
128
135
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-edit-"));
|
|
129
136
|
const path = join(dir, filename);
|
|
137
|
+
|
|
130
138
|
writeFileSync(path, content);
|
|
131
139
|
try {
|
|
132
140
|
const startedAt = now();
|
|
@@ -135,12 +143,16 @@ export function editInEditor(
|
|
|
135
143
|
stdout: "inherit",
|
|
136
144
|
stderr: "inherit",
|
|
137
145
|
});
|
|
146
|
+
|
|
138
147
|
if (editorProcess.exitCode !== 0) throw new Error(`editor exited ${editorProcess.exitCode}`);
|
|
139
148
|
let next = readFileSync(path, "utf8");
|
|
149
|
+
|
|
140
150
|
if (suspectsNoWait(resolved, now() - startedAt, next === content)) {
|
|
141
151
|
const confirm = handOff.confirmSaved ?? promptSaved;
|
|
152
|
+
|
|
142
153
|
if (confirm(resolved.argv[0] ?? "the editor", path)) next = readFileSync(path, "utf8");
|
|
143
154
|
}
|
|
155
|
+
|
|
144
156
|
return { content: next, changed: next !== content };
|
|
145
157
|
} finally {
|
|
146
158
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -14,6 +14,7 @@ describe("ghostty-terminal FFI", () => {
|
|
|
14
14
|
test.skipIf(!factory)("writes VT bytes and reads back decoded cells", () => {
|
|
15
15
|
// Arrange
|
|
16
16
|
const term = factory!.create(80, 24)!;
|
|
17
|
+
|
|
17
18
|
expect(term).not.toBeNull();
|
|
18
19
|
|
|
19
20
|
// Act - plain text then bold + palette-green "GO"
|
|
@@ -38,6 +39,7 @@ describe("ghostty-terminal FFI", () => {
|
|
|
38
39
|
|
|
39
40
|
// Assert - cursor advanced past the three glyphs on row 0
|
|
40
41
|
const cursor = term.readCursor();
|
|
42
|
+
|
|
41
43
|
expect(cursor.y).toBe(0);
|
|
42
44
|
expect(cursor.x).toBe(3);
|
|
43
45
|
term.free();
|
package/src/ghostty-terminal.ts
CHANGED
|
@@ -56,6 +56,7 @@ function nativeLibraryPath(): string | null {
|
|
|
56
56
|
const suffix = process.platform === "darwin" ? "dylib" : "so";
|
|
57
57
|
const dir = `${process.platform}-${process.arch}`;
|
|
58
58
|
const path = join(import.meta.dir, "..", "native", dir, `libcueloopvt.${suffix}`);
|
|
59
|
+
|
|
59
60
|
return existsSync(path) ? path : null;
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -65,14 +66,17 @@ function nativeLibraryPath(): string | null {
|
|
|
65
66
|
*/
|
|
66
67
|
export function loadGhosttyTerminals(): GhosttyTerminalFactory | null {
|
|
67
68
|
const path = nativeLibraryPath();
|
|
69
|
+
|
|
68
70
|
if (!path) return null;
|
|
69
71
|
try {
|
|
70
72
|
const lib = dlopen(path, CVT_SYMBOLS);
|
|
73
|
+
|
|
71
74
|
return new GhosttyTerminalFactory(lib.symbols);
|
|
72
75
|
} catch (error) {
|
|
73
76
|
// The file exists but failed to load (bad arch, missing symbol) - a real
|
|
74
77
|
// fault, not the expected no-prebuilt case; surface it, then degrade.
|
|
75
78
|
console.error(`cueloop: failed to load ${path}:`, error);
|
|
79
|
+
|
|
76
80
|
return null;
|
|
77
81
|
}
|
|
78
82
|
}
|
|
@@ -83,6 +87,7 @@ export class GhosttyTerminalFactory {
|
|
|
83
87
|
/** A fresh terminal sized to cols x rows, or null if allocation failed. */
|
|
84
88
|
create(cols: number, rows: number): GhosttyTerminal | null {
|
|
85
89
|
const handle = this.lib.cvt_new(cols, rows);
|
|
90
|
+
|
|
86
91
|
return handle ? new GhosttyTerminal(this.lib, handle) : null;
|
|
87
92
|
}
|
|
88
93
|
}
|
|
@@ -126,6 +131,7 @@ export class GhosttyTerminal {
|
|
|
126
131
|
if (this.freed) return null;
|
|
127
132
|
if (this.lib.cvt_cell(this.handle, x, y, ptr(this.out)) !== 0) return null;
|
|
128
133
|
const flags = this.out[13]!;
|
|
134
|
+
|
|
129
135
|
return {
|
|
130
136
|
codepoint: this.outView.getUint32(0, true),
|
|
131
137
|
fg: decodeColor(this.out[7]!, this.out[4]!, this.out[5]!, this.out[6]!),
|
|
@@ -145,6 +151,7 @@ export class GhosttyTerminal {
|
|
|
145
151
|
if (this.freed) return { x: 0, y: 0, visible: false };
|
|
146
152
|
this.lib.cvt_cursor(this.handle, ptr(this.cursorOut));
|
|
147
153
|
const view = new DataView(this.cursorOut.buffer);
|
|
154
|
+
|
|
148
155
|
return {
|
|
149
156
|
x: view.getUint16(0, true),
|
|
150
157
|
y: view.getUint16(2, true),
|
|
@@ -164,5 +171,6 @@ export class GhosttyTerminal {
|
|
|
164
171
|
function decodeColor(kind: number, r: number, g: number, b: number): GhosttyColor {
|
|
165
172
|
if (kind === 2) return { kind: "rgb", r, g, b };
|
|
166
173
|
if (kind === 1) return { kind: "palette", index: r };
|
|
174
|
+
|
|
167
175
|
return { kind: "default" };
|
|
168
176
|
}
|
package/src/herdr.test.ts
CHANGED
|
@@ -18,6 +18,7 @@ describe("focusHerdrPane", () => {
|
|
|
18
18
|
// Arrange
|
|
19
19
|
const log = join(dir, "calls.log");
|
|
20
20
|
const stub = join(dir, "herdr");
|
|
21
|
+
|
|
21
22
|
writeFileSync(
|
|
22
23
|
stub,
|
|
23
24
|
`#!/bin/sh
|
|
@@ -30,6 +31,7 @@ if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
|
|
|
30
31
|
// Assert
|
|
31
32
|
expect(focusHerdrPane(stub, "w1:p1")).toBe(true);
|
|
32
33
|
const calls = readFileSync(log, "utf8").trim().split("\n");
|
|
34
|
+
|
|
33
35
|
expect(calls[0]).toBe("pane get w1:p1");
|
|
34
36
|
expect(calls[1]).toBe("tab focus w1:t3");
|
|
35
37
|
});
|
package/src/herdr.ts
CHANGED
|
@@ -13,10 +13,13 @@ export function focusHerdrPane(binPath: string, paneId: string): boolean {
|
|
|
13
13
|
stdout: "pipe",
|
|
14
14
|
stderr: "ignore",
|
|
15
15
|
});
|
|
16
|
+
|
|
16
17
|
if (got.exitCode !== 0) return false;
|
|
17
18
|
const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: { tab_id?: string } } };
|
|
18
19
|
const tab = parsed.result?.pane?.tab_id;
|
|
20
|
+
|
|
19
21
|
if (!tab) return false;
|
|
22
|
+
|
|
20
23
|
return (
|
|
21
24
|
Bun.spawnSync([binPath, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" })
|
|
22
25
|
.exitCode === 0
|
package/src/integrations.ts
CHANGED
|
@@ -25,6 +25,8 @@ export async function loadBundledExporters(
|
|
|
25
25
|
const obsidian = integrations.obsidian;
|
|
26
26
|
const record = await registry.load("obsidian", createObsidianExtension(obsidian));
|
|
27
27
|
const run = record.exporters.get("obsidian");
|
|
28
|
+
|
|
28
29
|
if (!run) return [];
|
|
30
|
+
|
|
29
31
|
return [{ name: "obsidian", run, runsOn: (verdict) => shouldExport(obsidian.exportOn, verdict) }];
|
|
30
32
|
}
|
|
@@ -51,6 +51,7 @@ function baseController() {
|
|
|
51
51
|
/** A deps bag where every effect is a mock and every read has a plain default. */
|
|
52
52
|
function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
|
|
53
53
|
const controller = baseController();
|
|
54
|
+
|
|
54
55
|
return {
|
|
55
56
|
controller: controller as unknown as IntentDispatchDeps["controller"],
|
|
56
57
|
onExit: mock(),
|
|
@@ -102,6 +103,7 @@ describe("move", () => {
|
|
|
102
103
|
const advance = (deps.setCursor as ReturnType<typeof mock>).mock.calls[0]![0] as (
|
|
103
104
|
current: number,
|
|
104
105
|
) => number;
|
|
106
|
+
|
|
105
107
|
expect(advance(2)).toBe(2); // already at the end, stays
|
|
106
108
|
expect(advance(0)).toBe(1);
|
|
107
109
|
});
|
|
@@ -147,6 +149,7 @@ describe("annotation navigation", () => {
|
|
|
147
149
|
test("cycling skips annotations a revision already addressed", () => {
|
|
148
150
|
// Arrange: a2 is addressed, so next from a1 lands on a3
|
|
149
151
|
const session = sessionWith(["a1", "a2", "a3"]);
|
|
152
|
+
|
|
150
153
|
(session.annotations[1] as { resolution?: object }).resolution = {
|
|
151
154
|
revision: 2,
|
|
152
155
|
source: "agent",
|
|
@@ -337,6 +340,7 @@ describe("restoreCuration", () => {
|
|
|
337
340
|
test("restores the selected item and clears the selection", () => {
|
|
338
341
|
// Arrange
|
|
339
342
|
const deps = makeDeps({ selectedCurationId: "diff:f#1#2" });
|
|
343
|
+
|
|
340
344
|
(deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue(items);
|
|
341
345
|
const dispatch = createIntentDispatch(deps);
|
|
342
346
|
|
|
@@ -351,6 +355,7 @@ describe("restoreCuration", () => {
|
|
|
351
355
|
test("with nothing selected, undoes the last removal", () => {
|
|
352
356
|
// Arrange
|
|
353
357
|
const deps = makeDeps({ selectedCurationId: undefined });
|
|
358
|
+
|
|
354
359
|
(deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue(items);
|
|
355
360
|
const dispatch = createIntentDispatch(deps);
|
|
356
361
|
|
|
@@ -364,6 +369,7 @@ describe("restoreCuration", () => {
|
|
|
364
369
|
test("does nothing when there is nothing curated out", () => {
|
|
365
370
|
// Arrange
|
|
366
371
|
const deps = makeDeps();
|
|
372
|
+
|
|
367
373
|
(deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue([]);
|
|
368
374
|
const dispatch = createIntentDispatch(deps);
|
|
369
375
|
|
package/src/intent-dispatch.ts
CHANGED
|
@@ -126,6 +126,7 @@ function handleStatus(intent: IntentOfType<"status">, deps: IntentDispatchDeps):
|
|
|
126
126
|
|
|
127
127
|
function handleMove(intent: IntentOfType<"move">, deps: IntentDispatchDeps): void {
|
|
128
128
|
const navigableCount = deps.isDiff ? deps.rows.length : deps.display.length;
|
|
129
|
+
|
|
129
130
|
if (intent.to === "down") deps.setCursor((current) => Math.min(navigableCount - 1, current + 1));
|
|
130
131
|
else if (intent.to === "up") deps.setCursor((current) => Math.max(0, current - 1));
|
|
131
132
|
else if (intent.to === "top") deps.setCursor(0);
|
|
@@ -134,6 +135,7 @@ function handleMove(intent: IntentOfType<"move">, deps: IntentDispatchDeps): voi
|
|
|
134
135
|
|
|
135
136
|
function handleInboxMove(intent: IntentOfType<"inboxMove">, deps: IntentDispatchDeps): void {
|
|
136
137
|
const navigableCount = deps.inbox?.length ?? 0;
|
|
138
|
+
|
|
137
139
|
deps.setInboxCursor((current) =>
|
|
138
140
|
intent.to === "down" ? Math.min(navigableCount - 1, current + 1) : Math.max(0, current - 1),
|
|
139
141
|
);
|
|
@@ -141,6 +143,7 @@ function handleInboxMove(intent: IntentOfType<"inboxMove">, deps: IntentDispatch
|
|
|
141
143
|
|
|
142
144
|
function handleOpenSession(_intent: IntentOfType<"openSession">, deps: IntentDispatchDeps): void {
|
|
143
145
|
const selected = deps.inbox?.[deps.inboxCursor];
|
|
146
|
+
|
|
144
147
|
if (selected) deps.controller.open(selected.id);
|
|
145
148
|
}
|
|
146
149
|
|
|
@@ -149,6 +152,7 @@ function handleRequestDeleteSession(
|
|
|
149
152
|
deps: IntentDispatchDeps,
|
|
150
153
|
): void {
|
|
151
154
|
const selected = deps.inbox?.[deps.inboxCursor];
|
|
155
|
+
|
|
152
156
|
if (selected)
|
|
153
157
|
deps.setMode({
|
|
154
158
|
type: "confirmDelete",
|
|
@@ -161,8 +165,10 @@ function handleOpenRename(_intent: IntentOfType<"openRename">, deps: IntentDispa
|
|
|
161
165
|
const focused = deps.session?.annotations.find(
|
|
162
166
|
(annotation) => annotation.id === deps.focusedAnnotationId,
|
|
163
167
|
);
|
|
168
|
+
|
|
164
169
|
if (!focused?.author) {
|
|
165
170
|
deps.controller.setStatus("that is your own note - nothing to rename");
|
|
171
|
+
|
|
166
172
|
return;
|
|
167
173
|
}
|
|
168
174
|
deps.setMode({
|
|
@@ -177,6 +183,7 @@ function handleConfirmDialog(
|
|
|
177
183
|
deps: IntentDispatchDeps,
|
|
178
184
|
): void {
|
|
179
185
|
const { mode, controller } = deps;
|
|
186
|
+
|
|
180
187
|
if (mode.type === "confirmDelete") controller.deleteSession(mode.sessionId);
|
|
181
188
|
else if (mode.type === "rename") deps.renameAuthor(mode.authorId, mode.text.trim());
|
|
182
189
|
else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
|
|
@@ -185,19 +192,23 @@ function handleConfirmDialog(
|
|
|
185
192
|
|
|
186
193
|
function handleStartSpan(_intent: IntentOfType<"startSpan">, deps: IntentDispatchDeps): void {
|
|
187
194
|
const block = deps.display[deps.cursor];
|
|
195
|
+
|
|
188
196
|
if (!block?.work) return;
|
|
189
197
|
const span = startSpan(deps.cursor, displayText(block));
|
|
198
|
+
|
|
190
199
|
if (span) deps.setMode({ type: "span", span });
|
|
191
200
|
}
|
|
192
201
|
|
|
193
202
|
function handleSpanKey(intent: IntentOfType<"spanKey">, deps: IntentDispatchDeps): void {
|
|
194
203
|
const { mode } = deps;
|
|
204
|
+
|
|
195
205
|
if (mode.type === "span") {
|
|
196
206
|
const span = spanKey(
|
|
197
207
|
mode.span,
|
|
198
208
|
intent.name,
|
|
199
209
|
displayText(deps.display[mode.span.displayIndex]!),
|
|
200
210
|
);
|
|
211
|
+
|
|
201
212
|
deps.setMode({ type: "span", span });
|
|
202
213
|
}
|
|
203
214
|
}
|
|
@@ -205,6 +216,7 @@ function handleSpanKey(intent: IntentOfType<"spanKey">, deps: IntentDispatchDeps
|
|
|
205
216
|
function handleSpanCut(_intent: IntentOfType<"spanCut">, deps: IntentDispatchDeps): void {
|
|
206
217
|
// the block the span sits in, cut whole (partial-span cut is not modeled)
|
|
207
218
|
const { mode } = deps;
|
|
219
|
+
|
|
208
220
|
if (mode.type === "span") {
|
|
209
221
|
deps.controller.cut(mode.span.displayIndex);
|
|
210
222
|
deps.setMode({ type: "normal" });
|
|
@@ -216,6 +228,7 @@ function handleOpenSpanActions(
|
|
|
216
228
|
deps: IntentDispatchDeps,
|
|
217
229
|
): void {
|
|
218
230
|
const { mode } = deps;
|
|
231
|
+
|
|
219
232
|
if (mode.type === "span") deps.setMode({ type: "spanActions", span: mode.span, index: 0 });
|
|
220
233
|
}
|
|
221
234
|
|
|
@@ -224,11 +237,13 @@ function handleMoveSpanAction(
|
|
|
224
237
|
deps: IntentDispatchDeps,
|
|
225
238
|
): void {
|
|
226
239
|
const { mode } = deps;
|
|
240
|
+
|
|
227
241
|
if (mode.type === "spanActions") {
|
|
228
242
|
const index = Math.max(
|
|
229
243
|
0,
|
|
230
244
|
Math.min(deps.quickActions.length - 1, mode.index + intent.direction),
|
|
231
245
|
);
|
|
246
|
+
|
|
232
247
|
deps.setMode({ ...mode, index });
|
|
233
248
|
}
|
|
234
249
|
}
|
|
@@ -238,6 +253,7 @@ function handleCloseSpanActions(
|
|
|
238
253
|
deps: IntentDispatchDeps,
|
|
239
254
|
): void {
|
|
240
255
|
const { mode } = deps;
|
|
256
|
+
|
|
241
257
|
if (mode.type === "spanActions") deps.setMode({ type: "span", span: mode.span });
|
|
242
258
|
}
|
|
243
259
|
|
|
@@ -246,8 +262,10 @@ function handlePickSpanAction(
|
|
|
246
262
|
deps: IntentDispatchDeps,
|
|
247
263
|
): void {
|
|
248
264
|
const { mode, session, controller } = deps;
|
|
265
|
+
|
|
249
266
|
if (mode.type !== "spanActions") return;
|
|
250
267
|
const action = deps.quickActions[intent.index ?? mode.index];
|
|
268
|
+
|
|
251
269
|
if (session && action) {
|
|
252
270
|
const body = quickActionBody(action);
|
|
253
271
|
const annotationId = controller.annotate(
|
|
@@ -257,6 +275,7 @@ function handlePickSpanAction(
|
|
|
257
275
|
mode.span.end,
|
|
258
276
|
body,
|
|
259
277
|
);
|
|
278
|
+
|
|
260
279
|
if (annotationId) deps.setFocusedAnnotationId(annotationId);
|
|
261
280
|
}
|
|
262
281
|
deps.setMode({ type: "normal" });
|
|
@@ -264,6 +283,7 @@ function handlePickSpanAction(
|
|
|
264
283
|
|
|
265
284
|
function handleOpenCompose(intent: IntentOfType<"openCompose">, deps: IntentDispatchDeps): void {
|
|
266
285
|
const { mode } = deps;
|
|
286
|
+
|
|
267
287
|
deps.liveInput.current = "";
|
|
268
288
|
if (intent.from === "span" && mode.type === "span") {
|
|
269
289
|
deps.setMode({
|
|
@@ -276,6 +296,7 @@ function handleOpenCompose(intent: IntentOfType<"openCompose">, deps: IntentDisp
|
|
|
276
296
|
});
|
|
277
297
|
} else if (deps.isDiff) {
|
|
278
298
|
const row = deps.rows[deps.cursor];
|
|
299
|
+
|
|
279
300
|
if (row)
|
|
280
301
|
deps.setMode({
|
|
281
302
|
type: "compose",
|
|
@@ -288,10 +309,12 @@ function handleOpenCompose(intent: IntentOfType<"openCompose">, deps: IntentDisp
|
|
|
288
309
|
} else {
|
|
289
310
|
// a mouse drag leaves a native selection; it wins over the cursor block
|
|
290
311
|
const native = deps.planSheetRef.current?.readSelection() ?? null;
|
|
312
|
+
|
|
291
313
|
if (native) {
|
|
292
314
|
deps.setMode({ type: "compose", kind: intent.kind, ...native, text: "" });
|
|
293
315
|
} else {
|
|
294
316
|
const block = deps.display[deps.cursor];
|
|
317
|
+
|
|
295
318
|
if (block)
|
|
296
319
|
deps.setMode({
|
|
297
320
|
type: "compose",
|
|
@@ -307,6 +330,7 @@ function handleOpenCompose(intent: IntentOfType<"openCompose">, deps: IntentDisp
|
|
|
307
330
|
|
|
308
331
|
function handleOpenSubmit(_intent: IntentOfType<"openSubmit">, deps: IntentDispatchDeps): void {
|
|
309
332
|
const { session } = deps;
|
|
333
|
+
|
|
310
334
|
if (!session) return;
|
|
311
335
|
deps.liveInput.current = "";
|
|
312
336
|
// the confirm card lives in the expanded review rail; a compact or hidden
|
|
@@ -340,8 +364,10 @@ function handleRestoreCuration(
|
|
|
340
364
|
// undo the selected curated-out item, or the last rejected when none is
|
|
341
365
|
// selected, so a bare `u` reads as "undo my last curation"
|
|
342
366
|
const items = deps.controller.curationItems();
|
|
367
|
+
|
|
343
368
|
if (!items.length) return;
|
|
344
369
|
const targetId = deps.selectedCurationId ?? items[items.length - 1]!.id;
|
|
370
|
+
|
|
345
371
|
deps.controller.restoreCuration(targetId);
|
|
346
372
|
deps.setSelectedCurationId(undefined);
|
|
347
373
|
}
|
|
@@ -362,6 +388,7 @@ function handleAnnotationCycle(
|
|
|
362
388
|
const annotations = (deps.session?.annotations ?? []).filter(
|
|
363
389
|
(annotation) => !isAddressed(annotation),
|
|
364
390
|
);
|
|
391
|
+
|
|
365
392
|
if (!annotations.length) return;
|
|
366
393
|
const focusedIndex = annotations.findIndex(
|
|
367
394
|
(annotation) => annotation.id === deps.focusedAnnotationId,
|
|
@@ -371,6 +398,7 @@ function handleAnnotationCycle(
|
|
|
371
398
|
? 0
|
|
372
399
|
: (focusedIndex + (intent.type === "nextAnnotation" ? 1 : -1) + annotations.length) %
|
|
373
400
|
annotations.length;
|
|
401
|
+
|
|
374
402
|
deps.selectCardFromDocument(annotations[nextIndex]!.id);
|
|
375
403
|
}
|
|
376
404
|
|
|
@@ -413,9 +441,11 @@ function handleCloseOverlay(_intent: IntentOfType<"closeOverlay">, deps: IntentD
|
|
|
413
441
|
function handleSaveCompose(_intent: IntentOfType<"saveCompose">, deps: IntentDispatchDeps): void {
|
|
414
442
|
const { mode, session, controller } = deps;
|
|
415
443
|
const body = deps.liveInput.current.trim();
|
|
444
|
+
|
|
416
445
|
if (mode.type === "railEdit") {
|
|
417
446
|
if (session && body) controller.updateAnnotation(mode.id, body);
|
|
418
447
|
deps.setMode({ type: "normal" });
|
|
448
|
+
|
|
419
449
|
return;
|
|
420
450
|
}
|
|
421
451
|
if (mode.type !== "compose") return;
|
|
@@ -427,6 +457,7 @@ function handleSaveCompose(_intent: IntentOfType<"saveCompose">, deps: IntentDis
|
|
|
427
457
|
mode.end,
|
|
428
458
|
body,
|
|
429
459
|
);
|
|
460
|
+
|
|
430
461
|
if (annotationId) deps.setFocusedAnnotationId(annotationId);
|
|
431
462
|
}
|
|
432
463
|
deps.setMode({ type: "normal" });
|
|
@@ -437,15 +468,18 @@ function handleSubmitVerdict(
|
|
|
437
468
|
deps: IntentDispatchDeps,
|
|
438
469
|
): void {
|
|
439
470
|
const { mode } = deps;
|
|
471
|
+
|
|
440
472
|
if (mode.type === "submit") deps.controller.submit(mode.verdict, deps.liveInput.current);
|
|
441
473
|
deps.setMode({ type: "normal" });
|
|
442
474
|
}
|
|
443
475
|
|
|
444
476
|
function handleCycleVerdict(intent: IntentOfType<"cycleVerdict">, deps: IntentDispatchDeps): void {
|
|
445
477
|
const { mode } = deps;
|
|
478
|
+
|
|
446
479
|
if (mode.type !== "submit") return;
|
|
447
480
|
const verdictIndex =
|
|
448
481
|
(VERDICTS.indexOf(mode.verdict) + intent.direction + VERDICTS.length) % VERDICTS.length;
|
|
482
|
+
|
|
449
483
|
deps.setMode({ ...mode, verdict: VERDICTS[verdictIndex]! });
|
|
450
484
|
}
|
|
451
485
|
|
|
@@ -472,6 +506,7 @@ function handleCycleReviewPanel(
|
|
|
472
506
|
deps: IntentDispatchDeps,
|
|
473
507
|
): void {
|
|
474
508
|
const next = cycleReviewPanelMode(deps.reviewMode);
|
|
509
|
+
|
|
475
510
|
deps.setReviewMode(next);
|
|
476
511
|
deps.controller.saveReviewPanel({ mode: next });
|
|
477
512
|
}
|
|
@@ -485,6 +520,7 @@ function handleResizeReviewPanel(
|
|
|
485
520
|
deps.reviewWidth + intent.direction * REVIEW_RESIZE_STEP,
|
|
486
521
|
deps.terminalWidth,
|
|
487
522
|
);
|
|
523
|
+
|
|
488
524
|
deps.reviewWidthRef.current = next;
|
|
489
525
|
deps.setReviewWidth(next);
|
|
490
526
|
deps.controller.saveReviewPanel({ width: next });
|
|
@@ -543,6 +579,7 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
|
|
|
543
579
|
intent: Intent,
|
|
544
580
|
deps: IntentDispatchDeps,
|
|
545
581
|
) => void;
|
|
582
|
+
|
|
546
583
|
handler(intent, deps);
|
|
547
584
|
};
|
|
548
585
|
}
|
package/src/key-bindings.test.ts
CHANGED
|
@@ -8,7 +8,9 @@ function bindings(
|
|
|
8
8
|
context: { overlay?: "none" | "walk" | "compose" | "submit"; spanMode?: boolean } = {},
|
|
9
9
|
): KeyBindings {
|
|
10
10
|
const resolver = new KeyBindings(DEFAULT_KEYS);
|
|
11
|
+
|
|
11
12
|
resolver.setContext({ overlay: context.overlay ?? "none", spanMode: context.spanMode ?? false });
|
|
13
|
+
|
|
12
14
|
return resolver;
|
|
13
15
|
}
|
|
14
16
|
|
|
@@ -32,6 +34,7 @@ describe("grammar-layer resolution", () => {
|
|
|
32
34
|
["q", false, "quit"],
|
|
33
35
|
["z", false, undefined],
|
|
34
36
|
];
|
|
37
|
+
|
|
35
38
|
for (const [name, shift, action] of table) {
|
|
36
39
|
test(`${shift ? "shift+" : ""}${name} -> ${action}`, () => {
|
|
37
40
|
expect(bindings().resolveAction({ name, shift })).toBe(action!);
|
|
@@ -142,6 +145,7 @@ describe("getActiveKeys-generated status hints", () => {
|
|
|
142
145
|
test("a rebound key shows its real binding in the hint", () => {
|
|
143
146
|
// Arrange
|
|
144
147
|
const resolver = new KeyBindings({ ...DEFAULT_KEYS, comment: ["m"] });
|
|
148
|
+
|
|
145
149
|
resolver.setContext({ overlay: "none", spanMode: false });
|
|
146
150
|
|
|
147
151
|
// Assert
|