@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/key-bindings.ts
CHANGED
|
@@ -161,6 +161,7 @@ class HeadlessKeymapHost implements KeymapHost<object, KeymapEvent> {
|
|
|
161
161
|
}
|
|
162
162
|
onKeyPress(listener: (event: KeymapEvent) => void): () => void {
|
|
163
163
|
this.pressListeners.add(listener);
|
|
164
|
+
|
|
164
165
|
return () => this.pressListeners.delete(listener);
|
|
165
166
|
}
|
|
166
167
|
onKeyRelease(): () => void {
|
|
@@ -193,14 +194,17 @@ function normalizedEvent(key: ResolvableKey): { name: string; shift: boolean } {
|
|
|
193
194
|
shift: key.shift || key.name !== key.name.toLowerCase(),
|
|
194
195
|
};
|
|
195
196
|
}
|
|
197
|
+
|
|
196
198
|
return { name: key.name, shift: false };
|
|
197
199
|
}
|
|
200
|
+
|
|
198
201
|
return { name: key.name, shift: key.shift };
|
|
199
202
|
}
|
|
200
203
|
|
|
201
204
|
/** Config combos write shifted letters as uppercase; the parser wants shift+. */
|
|
202
205
|
function bindingKeyFor(combo: string): string {
|
|
203
206
|
if (combo.length === 1 && /[A-Z]/.test(combo)) return `shift+${combo.toLowerCase()}`;
|
|
207
|
+
|
|
204
208
|
return combo;
|
|
205
209
|
}
|
|
206
210
|
|
|
@@ -242,6 +246,7 @@ export class KeyBindings {
|
|
|
242
246
|
this.unregisterGrammarLayer?.();
|
|
243
247
|
const bindings: { key: string; cmd: string }[] = [];
|
|
244
248
|
const commandNames = new Set<string>();
|
|
249
|
+
|
|
245
250
|
for (const [action, combos] of Object.entries(keys)) {
|
|
246
251
|
commandNames.add(action);
|
|
247
252
|
for (const combo of combos) bindings.push({ key: bindingKeyFor(combo), cmd: action });
|
|
@@ -257,6 +262,7 @@ export class KeyBindings {
|
|
|
257
262
|
name,
|
|
258
263
|
run: () => {
|
|
259
264
|
this.resolvedCommand = name;
|
|
265
|
+
|
|
260
266
|
return true;
|
|
261
267
|
},
|
|
262
268
|
})),
|
|
@@ -268,9 +274,11 @@ export class KeyBindings {
|
|
|
268
274
|
name,
|
|
269
275
|
run: () => {
|
|
270
276
|
this.resolvedCommand = name;
|
|
277
|
+
|
|
271
278
|
return true;
|
|
272
279
|
},
|
|
273
280
|
});
|
|
281
|
+
|
|
274
282
|
this.keymap.registerLayer({
|
|
275
283
|
priority: 10,
|
|
276
284
|
when: () => this.context.overlay === "none" && this.context.spanMode,
|
|
@@ -355,8 +363,10 @@ export class KeyBindings {
|
|
|
355
363
|
*/
|
|
356
364
|
resolveAction(key: ResolvableKey): string | undefined {
|
|
357
365
|
const normalized = normalizedEvent(key);
|
|
366
|
+
|
|
358
367
|
this.resolvedCommand = undefined;
|
|
359
368
|
this.host.emitKeyPress(makeKeymapEvent(normalized.name, normalized.shift));
|
|
369
|
+
|
|
360
370
|
return this.resolvedCommand;
|
|
361
371
|
}
|
|
362
372
|
|
|
@@ -367,15 +377,19 @@ export class KeyBindings {
|
|
|
367
377
|
typeof activeKey.command === "string"
|
|
368
378
|
? activeKey.command
|
|
369
379
|
: activeKey.bindings?.[0]?.command;
|
|
380
|
+
|
|
370
381
|
if (bindingCommand !== command) continue;
|
|
382
|
+
|
|
371
383
|
return KEY_GLYPHS[activeKey.stroke.name] ?? activeKey.display;
|
|
372
384
|
}
|
|
385
|
+
|
|
373
386
|
return null;
|
|
374
387
|
}
|
|
375
388
|
|
|
376
389
|
/** The status-line hint for a view state, generated from the active keys. */
|
|
377
390
|
statusHint(mode: HintMode): string {
|
|
378
391
|
const fragments: string[] = [];
|
|
392
|
+
|
|
379
393
|
for (const entry of HINT_TEMPLATES[mode]) {
|
|
380
394
|
if ("text" in entry) {
|
|
381
395
|
fragments.push(entry.text);
|
|
@@ -384,14 +398,17 @@ export class KeyBindings {
|
|
|
384
398
|
const displays = entry.commands
|
|
385
399
|
.map((command) => this.keyDisplayFor(command))
|
|
386
400
|
.filter((display): display is string => display !== null);
|
|
401
|
+
|
|
387
402
|
if (!displays.length) continue;
|
|
388
403
|
const keyPart = displays.join("/");
|
|
404
|
+
|
|
389
405
|
if (!entry.label) fragments.push(keyPart);
|
|
390
406
|
else
|
|
391
407
|
fragments.push(
|
|
392
408
|
entry.labelFirst ? `${entry.label} ${keyPart}` : `${keyPart} ${entry.label}`,
|
|
393
409
|
);
|
|
394
410
|
}
|
|
411
|
+
|
|
395
412
|
return fragments.join(" · ");
|
|
396
413
|
}
|
|
397
414
|
|
|
@@ -405,14 +422,17 @@ export class KeyBindings {
|
|
|
405
422
|
const build = (context: KeyLayerContext, mode: HintMode, title: string): CheatsheetSection => {
|
|
406
423
|
this.context = context;
|
|
407
424
|
const entries: CheatsheetEntry[] = [];
|
|
425
|
+
|
|
408
426
|
for (const entry of HINT_TEMPLATES[mode]) {
|
|
409
427
|
if ("text" in entry || !entry.label) continue;
|
|
410
428
|
const keys = entry.commands
|
|
411
429
|
.map((command) => this.keyDisplayFor(command))
|
|
412
430
|
.filter((display): display is string => display !== null)
|
|
413
431
|
.join(" / ");
|
|
432
|
+
|
|
414
433
|
if (keys) entries.push({ keys, label: entry.label });
|
|
415
434
|
}
|
|
435
|
+
|
|
416
436
|
return { title, entries };
|
|
417
437
|
};
|
|
418
438
|
const sections = [
|
|
@@ -424,7 +444,9 @@ export class KeyBindings {
|
|
|
424
444
|
// so it is listed statically rather than resolved from the active keys.
|
|
425
445
|
{ title: "Agent terminal", entries: [{ keys: AGENT_DETACH_HINT, label: "detach" }] },
|
|
426
446
|
];
|
|
447
|
+
|
|
427
448
|
this.context = saved;
|
|
449
|
+
|
|
428
450
|
return sections.filter((section) => section.entries.length > 0);
|
|
429
451
|
}
|
|
430
452
|
}
|
package/src/keymap.test.ts
CHANGED
|
@@ -41,6 +41,7 @@ describe("compose overlay", () => {
|
|
|
41
41
|
["j", []],
|
|
42
42
|
["q", []],
|
|
43
43
|
];
|
|
44
|
+
|
|
44
45
|
for (const [name, expected] of table) {
|
|
45
46
|
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
46
47
|
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
@@ -69,6 +70,7 @@ describe("submit overlay", () => {
|
|
|
69
70
|
["j", []],
|
|
70
71
|
["q", []],
|
|
71
72
|
];
|
|
73
|
+
|
|
72
74
|
for (const [name, expected] of table) {
|
|
73
75
|
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
74
76
|
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
@@ -92,6 +94,7 @@ describe("completion overlay", () => {
|
|
|
92
94
|
[counting, "a", [{ type: "optInAutoClose" }]],
|
|
93
95
|
[counting, "escape", [{ type: "dismissCompletion" }]],
|
|
94
96
|
];
|
|
97
|
+
|
|
95
98
|
for (const [overlayState, name, expected] of table) {
|
|
96
99
|
test(`${overlayState.overlay} ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
97
100
|
expect(reduceKey(overlayState, key(name))).toEqual(expected);
|
|
@@ -113,6 +116,7 @@ describe("inbox mode", () => {
|
|
|
113
116
|
["c", []],
|
|
114
117
|
["escape", []],
|
|
115
118
|
];
|
|
119
|
+
|
|
116
120
|
for (const [name, expected] of table) {
|
|
117
121
|
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
118
122
|
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
@@ -217,6 +221,7 @@ describe("plan normal mode", () => {
|
|
|
217
221
|
["escape", false, [{ type: "deselect" }]],
|
|
218
222
|
["z", false, []],
|
|
219
223
|
];
|
|
224
|
+
|
|
220
225
|
for (const [name, shift, expected] of table) {
|
|
221
226
|
test(`${shift ? "shift+" : ""}${name} -> ${JSON.stringify(expected)}`, () => {
|
|
222
227
|
expect(reduceKey(keyState, key(name, shift))).toEqual(expected);
|
|
@@ -297,6 +302,7 @@ describe("span mode", () => {
|
|
|
297
302
|
["q", [{ type: "exit" }]],
|
|
298
303
|
["j", []],
|
|
299
304
|
];
|
|
305
|
+
|
|
300
306
|
for (const [name, expected] of table) {
|
|
301
307
|
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
302
308
|
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
@@ -364,6 +370,7 @@ describe("span actions overlay", () => {
|
|
|
364
370
|
["c", []],
|
|
365
371
|
["q", []],
|
|
366
372
|
];
|
|
373
|
+
|
|
367
374
|
for (const [name, expected] of table) {
|
|
368
375
|
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
369
376
|
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
@@ -388,6 +395,7 @@ describe("diff mode", () => {
|
|
|
388
395
|
["e", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
|
|
389
396
|
["q", [{ type: "exit" }]],
|
|
390
397
|
];
|
|
398
|
+
|
|
391
399
|
for (const [name, expected] of table) {
|
|
392
400
|
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
393
401
|
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
@@ -466,6 +474,7 @@ describe("guided walk", () => {
|
|
|
466
474
|
["j", []],
|
|
467
475
|
["c", []],
|
|
468
476
|
];
|
|
477
|
+
|
|
469
478
|
for (const [name, expected] of table) {
|
|
470
479
|
test(`walking ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
471
480
|
expect(reduceKey(walking, key(name))).toEqual(expected);
|
|
@@ -491,6 +500,7 @@ describe("review panel controls", () => {
|
|
|
491
500
|
["]", [{ type: "resizeReviewPanel", direction: 1 }]],
|
|
492
501
|
["[", [{ type: "resizeReviewPanel", direction: -1 }]],
|
|
493
502
|
];
|
|
503
|
+
|
|
494
504
|
for (const view of ["plan", "diff"] as const) {
|
|
495
505
|
for (const [name, expected] of table) {
|
|
496
506
|
test(`${view} ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
@@ -535,6 +545,7 @@ describe("duplicated-branch collapse", () => {
|
|
|
535
545
|
for (const name of ["n", "p", "backspace", "return"]) {
|
|
536
546
|
const plan = reduceKey(state(patch), key(name));
|
|
537
547
|
const diff = reduceKey(state({ ...patch, view: "diff" }), key(name));
|
|
548
|
+
|
|
538
549
|
expect(diff).toEqual(plan);
|
|
539
550
|
}
|
|
540
551
|
}
|
|
@@ -545,6 +556,7 @@ describe("read-only filter", () => {
|
|
|
545
556
|
test("every mutating key answers observer - read-only, in plan and diff", () => {
|
|
546
557
|
for (const view of ["plan", "diff"] as const) {
|
|
547
558
|
const keyState = state({ view, readOnly: true });
|
|
559
|
+
|
|
548
560
|
for (const name of ["c", "x", "e", "backspace", "return", "enter"]) {
|
|
549
561
|
expect(reduceKey(keyState, key(name))).toEqual([
|
|
550
562
|
{ type: "status", message: "observer - read-only" },
|
|
@@ -594,6 +606,7 @@ describe("confirm overlay", () => {
|
|
|
594
606
|
["j", []],
|
|
595
607
|
["d", []],
|
|
596
608
|
];
|
|
609
|
+
|
|
597
610
|
for (const [name, expected] of table) {
|
|
598
611
|
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
599
612
|
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
package/src/keymap.ts
CHANGED
|
@@ -123,19 +123,23 @@ function status(message: string): Intent[] {
|
|
|
123
123
|
export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: string): Intent[] {
|
|
124
124
|
const name = key.name;
|
|
125
125
|
const overlayGrammar = overlayGrammars[state.overlay];
|
|
126
|
+
|
|
126
127
|
if (overlayGrammar) return overlayGrammar(state, key);
|
|
127
128
|
const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
|
|
129
|
+
|
|
128
130
|
if (action === "quit") return [{ type: "exit" }];
|
|
129
131
|
// the ONE read-only rule: any mutating attempt answers instead of acting
|
|
130
132
|
// (span-mode c, x, and a are hardwired keys, so they gate by name as well)
|
|
131
133
|
const mutating =
|
|
132
134
|
MUTATING_ACTIONS.has(action ?? "") ||
|
|
133
135
|
(state.spanMode && (name === "c" || name === "x" || name === "a"));
|
|
136
|
+
|
|
134
137
|
if (state.readOnly && mutating) return status("observer - read-only");
|
|
135
138
|
|
|
136
139
|
// share is a session-level verb: it works from any view, owner only
|
|
137
140
|
if (action === "share") {
|
|
138
141
|
if (state.canShare === false) return status("only the plan owner can share");
|
|
142
|
+
|
|
139
143
|
return [{ type: "share" }];
|
|
140
144
|
}
|
|
141
145
|
|
|
@@ -146,8 +150,10 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
146
150
|
// the review panel rides both plan and diff reviews; collapsing and resizing
|
|
147
151
|
// are view state, so the read-only gate above lets them through
|
|
148
152
|
const reviewPanel = reviewPanelGrammar(action);
|
|
153
|
+
|
|
149
154
|
if (reviewPanel) return reviewPanel;
|
|
150
155
|
if (state.view === "diff") return diffGrammar(state, action);
|
|
156
|
+
|
|
151
157
|
return planGrammar(state, action, name);
|
|
152
158
|
}
|
|
153
159
|
|
|
@@ -166,60 +172,74 @@ const overlayGrammars: Partial<Record<KeyState["overlay"], OverlayGrammar>> = {
|
|
|
166
172
|
|
|
167
173
|
function composeOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
168
174
|
const name = key.name;
|
|
175
|
+
|
|
169
176
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
170
177
|
if (name === "return" || name === "enter") {
|
|
171
178
|
// In the composer, ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline - the
|
|
172
179
|
// focused textarea owns that; only a bare ⏎ saves. The submit overlay
|
|
173
180
|
// keeps its plain ⏎ submit.
|
|
174
181
|
if (key.shift || key.meta) return [];
|
|
182
|
+
|
|
175
183
|
return [{ type: "saveCompose" }];
|
|
176
184
|
}
|
|
185
|
+
|
|
177
186
|
return [];
|
|
178
187
|
}
|
|
179
188
|
|
|
180
189
|
function submitOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
181
190
|
const name = key.name;
|
|
191
|
+
|
|
182
192
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
183
193
|
if (name === "return" || name === "enter") return [{ type: "submitVerdict" }];
|
|
184
194
|
if (name === "left" || name === "right") {
|
|
185
195
|
return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
|
|
186
196
|
}
|
|
197
|
+
|
|
187
198
|
return [];
|
|
188
199
|
}
|
|
189
200
|
|
|
190
201
|
function confirmOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
191
202
|
const name = key.name;
|
|
203
|
+
|
|
192
204
|
if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
|
|
193
205
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
206
|
+
|
|
194
207
|
return [];
|
|
195
208
|
}
|
|
196
209
|
|
|
197
210
|
function promptOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
198
211
|
const name = key.name;
|
|
212
|
+
|
|
199
213
|
if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
|
|
200
214
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
215
|
+
|
|
201
216
|
return [];
|
|
202
217
|
}
|
|
203
218
|
|
|
204
219
|
function spanActionsOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
205
220
|
const name = key.name;
|
|
221
|
+
|
|
206
222
|
if (name === "j" || name === "down") return [{ type: "moveSpanAction", direction: 1 }];
|
|
207
223
|
if (name === "k" || name === "up") return [{ type: "moveSpanAction", direction: -1 }];
|
|
208
224
|
if (name === "return" || name === "enter") return [{ type: "pickSpanAction" }];
|
|
209
225
|
if (name === "escape") return [{ type: "closeSpanActions" }];
|
|
226
|
+
|
|
210
227
|
return [];
|
|
211
228
|
}
|
|
212
229
|
|
|
213
230
|
function completionOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
214
231
|
const name = key.name;
|
|
232
|
+
|
|
215
233
|
if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
|
|
216
234
|
if (name === "a") return [{ type: "optInAutoClose" }];
|
|
217
235
|
if (name === "escape") return [{ type: "dismissCompletion" }];
|
|
236
|
+
|
|
218
237
|
return [];
|
|
219
238
|
}
|
|
220
239
|
|
|
221
240
|
function walkOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
222
241
|
const name = key.name;
|
|
242
|
+
|
|
223
243
|
if (name === "]") return [{ type: "walkForward" }];
|
|
224
244
|
if (name === "[") return [{ type: "walkBack" }];
|
|
225
245
|
if (name === "escape") return [{ type: "walkLeave" }];
|
|
@@ -227,6 +247,7 @@ function walkOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
|
227
247
|
return [{ type: "walkLeave" }, { type: "openSubmit" }];
|
|
228
248
|
}
|
|
229
249
|
if (name === "q") return [{ type: "exit" }];
|
|
250
|
+
|
|
230
251
|
return [];
|
|
231
252
|
}
|
|
232
253
|
|
|
@@ -235,6 +256,7 @@ function reviewPanelGrammar(action: string | undefined): Intent[] | null {
|
|
|
235
256
|
if (action === "review_cycle") return [{ type: "cycleReviewPanel" }];
|
|
236
257
|
if (action === "review_wider") return [{ type: "resizeReviewPanel", direction: 1 }];
|
|
237
258
|
if (action === "review_narrower") return [{ type: "resizeReviewPanel", direction: -1 }];
|
|
259
|
+
|
|
238
260
|
return null;
|
|
239
261
|
}
|
|
240
262
|
|
|
@@ -244,20 +266,24 @@ function inboxGrammar(state: KeyState, name: string): Intent[] {
|
|
|
244
266
|
if (name === "k" || name === "up") return [{ type: "inboxMove", to: "up" }];
|
|
245
267
|
if (name === "return" || name === "enter") return [{ type: "openSession" }];
|
|
246
268
|
if (name === "d") return [{ type: "requestDeleteSession" }];
|
|
269
|
+
|
|
247
270
|
return [];
|
|
248
271
|
}
|
|
249
272
|
|
|
250
273
|
function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
|
|
251
274
|
const navigation = navigationIntent(action);
|
|
275
|
+
|
|
252
276
|
if (navigation) return navigation;
|
|
253
277
|
if (action === "walk") {
|
|
254
278
|
// marking viewed writes the session record, so a resolved review answers
|
|
255
279
|
if (state.resolved) return status("review submitted - read-only");
|
|
280
|
+
|
|
256
281
|
return [{ type: "walkStart" }];
|
|
257
282
|
}
|
|
258
283
|
if (action === "comment") {
|
|
259
284
|
if (state.resolved) return status("review submitted - read-only");
|
|
260
285
|
if (!state.cursorAnnotatable) return status("move to a code line to comment");
|
|
286
|
+
|
|
261
287
|
return [{ type: "openCompose", kind: "comment", from: "cursor" }];
|
|
262
288
|
}
|
|
263
289
|
// cut rejects the change under the cursor, reject_hunk the whole hunk; both
|
|
@@ -265,19 +291,23 @@ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
|
|
|
265
291
|
if (action === "cut" || action === "reject_hunk") {
|
|
266
292
|
if (state.resolved) return status("review submitted - read-only");
|
|
267
293
|
if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
|
|
294
|
+
|
|
268
295
|
return [{ type: action === "reject_hunk" ? "rejectHunk" : "rejectChange" }];
|
|
269
296
|
}
|
|
270
297
|
// restore un-does a curated-out rejection from the rail; same owner gate as reject
|
|
271
298
|
if (action === "restore_curation") {
|
|
272
299
|
if (state.resolved) return status("review submitted - read-only");
|
|
273
300
|
if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
|
|
301
|
+
|
|
274
302
|
return [{ type: "restoreCuration" }];
|
|
275
303
|
}
|
|
276
304
|
const shared = annotationCluster(state, action);
|
|
305
|
+
|
|
277
306
|
if (shared) return shared;
|
|
278
307
|
if (action === "span" || action === "edit") {
|
|
279
308
|
return status("plan-only verb - diff review uses c on a line");
|
|
280
309
|
}
|
|
310
|
+
|
|
281
311
|
return [];
|
|
282
312
|
}
|
|
283
313
|
|
|
@@ -293,13 +323,16 @@ function spanGrammar(state: KeyState, name: string): Intent[] {
|
|
|
293
323
|
// partial-span cut is not in the working-copy model, so cut removes the
|
|
294
324
|
// whole block the span sits in; owner-only, like plan cut
|
|
295
325
|
if (state.canEditPlan === false) return [];
|
|
326
|
+
|
|
296
327
|
return [{ type: "spanCut" }];
|
|
297
328
|
}
|
|
329
|
+
|
|
298
330
|
return [];
|
|
299
331
|
}
|
|
300
332
|
|
|
301
333
|
function planGrammar(state: KeyState, action: string | undefined, name: string): Intent[] {
|
|
302
334
|
const navigation = navigationIntent(action);
|
|
335
|
+
|
|
303
336
|
if (navigation) return navigation;
|
|
304
337
|
if (action === "walk") return status("the guided walk is a diff-review mode");
|
|
305
338
|
if (name === "escape") return [{ type: "deselect" }];
|
|
@@ -307,12 +340,14 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
|
|
|
307
340
|
if (action === "comment") {
|
|
308
341
|
if (state.resolved) return status("review submitted - read-only");
|
|
309
342
|
if (!state.cursorAnnotatable) return status("text is cut - restore it first");
|
|
343
|
+
|
|
310
344
|
return [{ type: "openCompose", kind: "comment", from: "cursor" }];
|
|
311
345
|
}
|
|
312
346
|
// restore un-does a rail removal (a cut block); a plan edit, so owner-only
|
|
313
347
|
if (action === "restore_curation") {
|
|
314
348
|
if (state.resolved) return status("review submitted - read-only");
|
|
315
349
|
if (state.canEditPlan === false) return [];
|
|
350
|
+
|
|
316
351
|
return [{ type: "restoreCuration" }];
|
|
317
352
|
}
|
|
318
353
|
if (action === "cut" || action === "edit") {
|
|
@@ -324,8 +359,10 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
|
|
|
324
359
|
// editing the plan itself is the owner's verb; a share viewer only annotates,
|
|
325
360
|
// and has no edit affordance, so the key is silent rather than a nag
|
|
326
361
|
if (state.canEditPlan === false) return [];
|
|
362
|
+
|
|
327
363
|
return [{ type: action }];
|
|
328
364
|
}
|
|
365
|
+
|
|
329
366
|
return annotationCluster(state, action) ?? [];
|
|
330
367
|
}
|
|
331
368
|
|
|
@@ -334,6 +371,7 @@ function navigationIntent(action: string | undefined): Intent[] | null {
|
|
|
334
371
|
if (action === "down" || action === "up" || action === "top" || action === "bottom") {
|
|
335
372
|
return [{ type: "move", to: action }];
|
|
336
373
|
}
|
|
374
|
+
|
|
337
375
|
return null;
|
|
338
376
|
}
|
|
339
377
|
|
|
@@ -341,21 +379,26 @@ function navigationIntent(action: string | undefined): Intent[] | null {
|
|
|
341
379
|
function annotationCluster(state: KeyState, action: string | undefined): Intent[] | null {
|
|
342
380
|
if (action === "next_annotation" || action === "prev_annotation") {
|
|
343
381
|
if (!state.annotationCount) return status("no annotations");
|
|
382
|
+
|
|
344
383
|
return [{ type: action === "next_annotation" ? "nextAnnotation" : "prevAnnotation" }];
|
|
345
384
|
}
|
|
346
385
|
if (action === "delete_annotation") {
|
|
347
386
|
if (state.resolved || !state.hasFocusedAnnotation) return [];
|
|
387
|
+
|
|
348
388
|
return [{ type: "removeAnnotation" }];
|
|
349
389
|
}
|
|
350
390
|
if (action === "rename") {
|
|
351
391
|
if (!state.hasFocusedAnnotation) return status("select a collaborator's note to rename them");
|
|
392
|
+
|
|
352
393
|
return [{ type: "openRename" }];
|
|
353
394
|
}
|
|
354
395
|
if (action === "submit") {
|
|
355
396
|
// a collaborator's notes union back as they go; there is no verdict to submit
|
|
356
397
|
if (state.canSubmitVerdict === false)
|
|
357
398
|
return status("shared view - your notes save as you go; q to leave");
|
|
399
|
+
|
|
358
400
|
return state.resolved ? [] : [{ type: "openSubmit" }];
|
|
359
401
|
}
|
|
402
|
+
|
|
360
403
|
return null;
|
|
361
404
|
}
|
package/src/kitty-image.ts
CHANGED
|
@@ -49,12 +49,14 @@ let privateTempDir: string | null = null;
|
|
|
49
49
|
*/
|
|
50
50
|
export function resolveTransmitMedium(env: NodeJS.ProcessEnv = process.env): TransmitMedium {
|
|
51
51
|
if (env.CUELOOP_KITTY_FILE === "1" && !env.SSH_CONNECTION && !env.SSH_TTY) return "file";
|
|
52
|
+
|
|
52
53
|
return "base64";
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
/** Save the cursor, run the placement at the region's corner, restore it. */
|
|
56
57
|
function atRegion(region: CellRegion, body: string): string {
|
|
57
58
|
const moveToCorner = `\x1b[${region.row + 1};${region.column + 1}H`;
|
|
59
|
+
|
|
58
60
|
return `\x1b7${moveToCorner}${body}\x1b8`;
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -71,7 +73,9 @@ function placementKeys(imageId: number, region: CellRegion): string {
|
|
|
71
73
|
function writeTempPng(png: Uint8Array): string {
|
|
72
74
|
if (!privateTempDir) privateTempDir = mkdtempSync(join(tmpdir(), "cueloop-prototype-"));
|
|
73
75
|
const path = join(privateTempDir, `${tempFileCounter++}.png`);
|
|
76
|
+
|
|
74
77
|
writeFileSync(path, png, { flag: "wx" });
|
|
78
|
+
|
|
75
79
|
return path;
|
|
76
80
|
}
|
|
77
81
|
|
|
@@ -79,20 +83,24 @@ function base64Transmit(png: Uint8Array, region: CellRegion, imageId: number): s
|
|
|
79
83
|
const base64 = Buffer.from(png).toString("base64");
|
|
80
84
|
const controlKeys = `a=T,f=100,${placementKeys(imageId, region)}`;
|
|
81
85
|
let sequence = "";
|
|
86
|
+
|
|
82
87
|
for (let offset = 0; offset < base64.length; offset += CHUNK_BYTES) {
|
|
83
88
|
const chunk = base64.slice(offset, offset + CHUNK_BYTES);
|
|
84
89
|
const isMore = offset + CHUNK_BYTES < base64.length ? 1 : 0;
|
|
90
|
+
|
|
85
91
|
sequence +=
|
|
86
92
|
offset === 0
|
|
87
93
|
? graphicsCommand(`${controlKeys},m=${isMore}`, chunk)
|
|
88
94
|
: graphicsCommand(`m=${isMore}`, chunk);
|
|
89
95
|
}
|
|
96
|
+
|
|
90
97
|
return sequence;
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
function fileTransmit(png: Uint8Array, region: CellRegion, imageId: number): string {
|
|
94
101
|
const path = writeTempPng(png);
|
|
95
102
|
const payload = Buffer.from(path).toString("base64");
|
|
103
|
+
|
|
96
104
|
return graphicsCommand(`a=T,f=100,t=t,${placementKeys(imageId, region)}`, payload);
|
|
97
105
|
}
|
|
98
106
|
|
|
@@ -114,6 +122,7 @@ export function transmitKittyImage(
|
|
|
114
122
|
if (region.columns < 1 || region.rows < 1) return;
|
|
115
123
|
const body =
|
|
116
124
|
medium === "file" ? fileTransmit(png, region, imageId) : base64Transmit(png, region, imageId);
|
|
125
|
+
|
|
117
126
|
write(SYNCHRONIZED_UPDATE_START + atRegion(region, body) + SYNCHRONIZED_UPDATE_END);
|
|
118
127
|
}
|
|
119
128
|
|
|
@@ -49,6 +49,7 @@ describe("cssBoxToCell", () => {
|
|
|
49
49
|
test("a zero-size rect still reserves at least one cell", () => {
|
|
50
50
|
// Assert
|
|
51
51
|
const cell = cssBoxToCell({ x: 0, y: 0, width: 0, height: 0 }, IMAGE, VIEWPORT);
|
|
52
|
+
|
|
52
53
|
expect(cell.columns).toBe(1);
|
|
53
54
|
expect(cell.rows).toBe(1);
|
|
54
55
|
});
|
package/src/prototype-browser.ts
CHANGED
|
@@ -52,6 +52,7 @@ export function imageCellToCss(
|
|
|
52
52
|
): { x: number; y: number } | null {
|
|
53
53
|
const columnInImage = event.x - image.x;
|
|
54
54
|
const rowInImage = event.y - image.y;
|
|
55
|
+
|
|
55
56
|
if (
|
|
56
57
|
columnInImage < 0 ||
|
|
57
58
|
rowInImage < 0 ||
|
|
@@ -60,6 +61,7 @@ export function imageCellToCss(
|
|
|
60
61
|
) {
|
|
61
62
|
return null;
|
|
62
63
|
}
|
|
64
|
+
|
|
63
65
|
return {
|
|
64
66
|
x: ((columnInImage + 0.5) / image.width) * viewport.width,
|
|
65
67
|
y: ((rowInImage + 0.5) / image.height) * viewport.height,
|
|
@@ -74,6 +76,7 @@ export function cssBoxToCell(
|
|
|
74
76
|
): { column: number; row: number; columns: number; rows: number } {
|
|
75
77
|
const scaleColumn = image.width / viewport.width;
|
|
76
78
|
const scaleRow = image.height / viewport.height;
|
|
79
|
+
|
|
77
80
|
return {
|
|
78
81
|
column: image.x + Math.floor(box.x * scaleColumn),
|
|
79
82
|
row: image.y + Math.floor(box.y * scaleRow),
|
|
@@ -107,6 +110,7 @@ async function warmBrowser(executablePath: string | undefined): Promise<Puppetee
|
|
|
107
110
|
// disconnected Chromium is dropped so the next open relaunches instead of
|
|
108
111
|
// calling newPage on a dead process forever
|
|
109
112
|
const cached = sharedBrowser ? await sharedBrowser.catch(() => null) : null;
|
|
113
|
+
|
|
110
114
|
if (cached && cached.connected) return cached;
|
|
111
115
|
const puppeteer = (await import("puppeteer-core")).default;
|
|
112
116
|
const launched = puppeteer.launch({
|
|
@@ -114,14 +118,17 @@ async function warmBrowser(executablePath: string | undefined): Promise<Puppetee
|
|
|
114
118
|
headless: true,
|
|
115
119
|
args: ["--no-sandbox", "--hide-scrollbars", "--force-color-profile=srgb"],
|
|
116
120
|
});
|
|
121
|
+
|
|
117
122
|
sharedBrowser = launched;
|
|
118
123
|
const browser = await launched.catch((error) => {
|
|
119
124
|
if (sharedBrowser === launched) sharedBrowser = null;
|
|
120
125
|
throw error;
|
|
121
126
|
});
|
|
127
|
+
|
|
122
128
|
browser.once("disconnected", () => {
|
|
123
129
|
if (sharedBrowser === launched) sharedBrowser = null;
|
|
124
130
|
});
|
|
131
|
+
|
|
125
132
|
return browser;
|
|
126
133
|
}
|
|
127
134
|
|
|
@@ -131,6 +138,7 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
|
|
|
131
138
|
// close only the page on teardown, never the shared browser, so the next open
|
|
132
139
|
// reuses the warm Chromium
|
|
133
140
|
const page = await browser.newPage();
|
|
141
|
+
|
|
134
142
|
try {
|
|
135
143
|
await page.setViewport({
|
|
136
144
|
width: options.viewport.width,
|
|
@@ -165,6 +173,7 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
|
|
|
165
173
|
encoding: "binary",
|
|
166
174
|
omitBackground: true,
|
|
167
175
|
});
|
|
176
|
+
|
|
168
177
|
return new Uint8Array(buffer as Buffer);
|
|
169
178
|
},
|
|
170
179
|
async elementAt(cssX, cssY) {
|
|
@@ -198,6 +207,7 @@ function elementAtScript(x: number, y: number): unknown {
|
|
|
198
207
|
const componentRoot = (start: Element): Element => {
|
|
199
208
|
let namedFallback: Element | null = null;
|
|
200
209
|
let current: Element | null = start;
|
|
210
|
+
|
|
201
211
|
while (current && current !== document.body) {
|
|
202
212
|
if (isNamed(current)) {
|
|
203
213
|
if (current.childElementCount > 1) return current;
|
|
@@ -205,28 +215,35 @@ function elementAtScript(x: number, y: number): unknown {
|
|
|
205
215
|
}
|
|
206
216
|
current = current.parentElement;
|
|
207
217
|
}
|
|
218
|
+
|
|
208
219
|
return namedFallback ?? start;
|
|
209
220
|
};
|
|
210
221
|
const selectorFor = (start: Element): string => {
|
|
211
222
|
const parts: string[] = [];
|
|
212
223
|
let current: Element | null = start;
|
|
224
|
+
|
|
213
225
|
while (current && current.nodeType === 1 && current !== document.documentElement) {
|
|
214
226
|
let part = current.tagName.toLowerCase();
|
|
227
|
+
|
|
215
228
|
if (current.id) {
|
|
216
229
|
parts.unshift(part + "#" + CSS.escape(current.id));
|
|
217
230
|
break;
|
|
218
231
|
}
|
|
219
232
|
const parent: Element | null = current.parentElement;
|
|
233
|
+
|
|
220
234
|
if (parent) {
|
|
221
235
|
const twins = [...parent.children].filter((child) => child.tagName === current!.tagName);
|
|
236
|
+
|
|
222
237
|
if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
|
|
223
238
|
}
|
|
224
239
|
parts.unshift(part);
|
|
225
240
|
current = current.parentElement;
|
|
226
241
|
}
|
|
242
|
+
|
|
227
243
|
return parts.join(" > ");
|
|
228
244
|
};
|
|
229
245
|
const hit = document.elementFromPoint(x, y);
|
|
246
|
+
|
|
230
247
|
if (!(hit instanceof Element)) return null;
|
|
231
248
|
// A click on an interactive control anchors to that control - a button in a
|
|
232
249
|
// grid is the target, not the grid. Everything else climbs to its component.
|
|
@@ -235,6 +252,7 @@ function elementAtScript(x: number, y: number): unknown {
|
|
|
235
252
|
);
|
|
236
253
|
const node = control ?? componentRoot(hit);
|
|
237
254
|
const selector = selectorFor(node);
|
|
255
|
+
|
|
238
256
|
if (!selector) return null;
|
|
239
257
|
const text = (node.textContent || "").replace(/\s+/g, " ").trim();
|
|
240
258
|
const tag = node.tagName.toLowerCase();
|
|
@@ -246,12 +264,15 @@ function elementAtScript(x: number, y: number): unknown {
|
|
|
246
264
|
? tag + "." + node.classList[0]
|
|
247
265
|
: tag;
|
|
248
266
|
const rect = node.getBoundingClientRect();
|
|
267
|
+
|
|
249
268
|
return { selector, quote, box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
|
|
250
269
|
}
|
|
251
270
|
|
|
252
271
|
function scrollByScript(deltaY: number): boolean {
|
|
253
272
|
const before = window.scrollY;
|
|
273
|
+
|
|
254
274
|
window.scrollBy(0, deltaY);
|
|
275
|
+
|
|
255
276
|
return window.scrollY !== before;
|
|
256
277
|
}
|
|
257
278
|
|
|
@@ -263,5 +284,6 @@ function chromeExecutable(): string {
|
|
|
263
284
|
if (process.platform === "win32") {
|
|
264
285
|
return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
|
|
265
286
|
}
|
|
287
|
+
|
|
266
288
|
return "/usr/bin/google-chrome";
|
|
267
289
|
}
|