@cueloop/client 0.1.0-alpha.76 → 0.1.0-alpha.78
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 +3 -3
- package/src/App.diff.test.tsx +14 -2
- package/src/App.export.test.tsx +1 -2
- package/src/App.identity.test.tsx +78 -0
- package/src/App.plan-review.test.tsx +16 -0
- package/src/App.share-choice.test.tsx +105 -0
- package/src/App.submit-confirm.test.tsx +34 -5
- package/src/App.test.tsx +2 -2
- package/src/App.toast.test.tsx +3 -1
- package/src/App.tsx +180 -5
- package/src/App.walk.test.tsx +2 -2
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-screens.tsx +20 -3
- package/src/app-view-model.ts +4 -0
- package/src/components/AnnotatableFileView.tsx +10 -2
- package/src/components/AnnotationCards.tsx +40 -6
- 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.tsx +11 -12
- package/src/components/DiffContentView.tsx +9 -5
- package/src/components/FileContentsView.tsx +1 -1
- package/src/components/GridTabContent.tsx +2 -0
- package/src/components/ManageAccessDialog.stories.tsx +36 -0
- package/src/components/ManageAccessDialog.test.tsx +94 -0
- package/src/components/ManageAccessDialog.tsx +122 -0
- package/src/components/PromptDialog.test.tsx +2 -2
- package/src/components/PromptDialog.tsx +9 -10
- package/src/components/ShareChoiceDialog.stories.tsx +34 -0
- package/src/components/ShareChoiceDialog.test.tsx +103 -0
- package/src/components/ShareChoiceDialog.tsx +80 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadTree.tsx +11 -6
- package/src/components/ThreadView.selection.test.tsx +88 -1
- package/src/components/ThreadView.slash.test.tsx +0 -1
- package/src/components/ThreadView.tsx +71 -8
- package/src/components/Toast.tsx +0 -5
- package/src/components/WalkWizard.stories.tsx +2 -2
- package/src/components/WalkWizard.tsx +10 -12
- package/src/components/__snapshots__/stories.test.tsx.snap +277 -61
- package/src/components/primitives/Card.tsx +3 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/session-tree.ts +7 -6
- package/src/components/stories-app.tsx +13 -4
- package/src/components/stories.test.tsx +8 -1
- package/src/components/story-tree.test.ts +32 -24
- package/src/components/story.ts +32 -15
- package/src/config-identity.test.ts +78 -0
- package/src/config.ts +44 -1
- package/src/diff-intraline.test.ts +75 -2
- package/src/diff-intraline.ts +95 -26
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/intent-dispatch.test.ts +17 -2
- package/src/intent-dispatch.ts +7 -1
- package/src/keymap.test.ts +2 -2
- package/src/keymap.ts +1 -1
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/run.ts +31 -19
- package/src/serve.ts +1 -1
- package/src/share.ts +11 -3
- package/src/slash-palette.test.ts +17 -0
- package/src/slash-palette.ts +11 -0
- 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 +1 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +36 -2
- package/src/thread-controller.switch-cache.test.ts +146 -0
- package/src/thread-controller.tree.test.ts +1 -0
- package/src/thread-controller.ts +171 -24
- package/src/use-annotation-surface.tsx +53 -29
- package/src/use-settings-dialog.tsx +24 -0
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;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { resolveGithubIdentity } from "./github-identity";
|
|
6
|
+
|
|
7
|
+
const originalBinary = process.env.CUELOOP_GH;
|
|
8
|
+
const tempDirectories: string[] = [];
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (originalBinary === undefined) delete process.env.CUELOOP_GH;
|
|
12
|
+
else process.env.CUELOOP_GH = originalBinary;
|
|
13
|
+
for (const directory of tempDirectories.splice(0)) {
|
|
14
|
+
rmSync(directory, { recursive: true, force: true });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function stubGithubCli(script: string): string {
|
|
19
|
+
const directory = mkdtempSync(join(tmpdir(), "gh-stub-"));
|
|
20
|
+
|
|
21
|
+
tempDirectories.push(directory);
|
|
22
|
+
const path = join(directory, "gh");
|
|
23
|
+
|
|
24
|
+
writeFileSync(path, `#!/bin/sh\n${script}\n`);
|
|
25
|
+
chmodSync(path, 0o755);
|
|
26
|
+
|
|
27
|
+
return path;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("reads the GitHub login and name from gh", async () => {
|
|
31
|
+
process.env.CUELOOP_GH = stubGithubCli(`printf '{"login":"markus","name":"Markus M"}'`);
|
|
32
|
+
|
|
33
|
+
expect(await resolveGithubIdentity()).toEqual({ login: "markus", name: "Markus M" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("drops a null name to undefined", async () => {
|
|
37
|
+
process.env.CUELOOP_GH = stubGithubCli(`printf '{"login":"markus","name":null}'`);
|
|
38
|
+
|
|
39
|
+
expect(await resolveGithubIdentity()).toEqual({ login: "markus" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("falls back to null when gh exits non-zero (logged out)", async () => {
|
|
43
|
+
process.env.CUELOOP_GH = stubGithubCli(`exit 1`);
|
|
44
|
+
|
|
45
|
+
expect(await resolveGithubIdentity()).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("falls back to null when gh is not installed", async () => {
|
|
49
|
+
process.env.CUELOOP_GH = join(tmpdir(), "cueloop-absent-gh-binary");
|
|
50
|
+
|
|
51
|
+
expect(await resolveGithubIdentity()).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("falls back to null when gh prints an unexpected shape", async () => {
|
|
55
|
+
process.env.CUELOOP_GH = stubGithubCli(`printf 'not json'`);
|
|
56
|
+
|
|
57
|
+
expect(await resolveGithubIdentity()).toBeNull();
|
|
58
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** The signed-in GitHub identity from the local gh tool, or null when gh cannot answer. */
|
|
2
|
+
|
|
3
|
+
import * as v from "valibot";
|
|
4
|
+
|
|
5
|
+
export interface GithubIdentity {
|
|
6
|
+
login: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const GithubUserSchema = v.object({
|
|
11
|
+
login: v.string(),
|
|
12
|
+
name: v.nullish(v.string()),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** The gh binary is injectable so tests stub it without a real GitHub login. */
|
|
16
|
+
function githubCliBinary(): string {
|
|
17
|
+
return process.env.CUELOOP_GH || "gh";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read the authenticated GitHub login and name, or null when gh cannot answer. */
|
|
21
|
+
export async function resolveGithubIdentity(): Promise<GithubIdentity | null> {
|
|
22
|
+
try {
|
|
23
|
+
const githubProcess = Bun.spawn(
|
|
24
|
+
[githubCliBinary(), "api", "user", "--jq", "{login: .login, name: .name}"],
|
|
25
|
+
{ stdout: "pipe", stderr: "ignore" },
|
|
26
|
+
);
|
|
27
|
+
const [output, exitCode] = await Promise.all([
|
|
28
|
+
new Response(githubProcess.stdout).text(),
|
|
29
|
+
githubProcess.exited,
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
if (exitCode !== 0) return null;
|
|
33
|
+
const parsed = v.safeParse(GithubUserSchema, JSON.parse(output));
|
|
34
|
+
|
|
35
|
+
if (!parsed.success) return null;
|
|
36
|
+
|
|
37
|
+
return { login: parsed.output.login, name: parsed.output.name ?? undefined };
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -108,6 +108,7 @@ function baseController(): ReviewController {
|
|
|
108
108
|
walkLeave: mock(),
|
|
109
109
|
submit: mock(),
|
|
110
110
|
share: mock(),
|
|
111
|
+
setShareAccess: mock(),
|
|
111
112
|
treeRows: mock(() => []),
|
|
112
113
|
goToEntry: mock(),
|
|
113
114
|
branch: mock(),
|
|
@@ -143,6 +144,7 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
|
|
|
143
144
|
quickActions: [],
|
|
144
145
|
renameAuthor: mock(),
|
|
145
146
|
renameThread: mock(),
|
|
147
|
+
setLocalIdentityName: mock(),
|
|
146
148
|
liveInput: { current: "" },
|
|
147
149
|
setCursor: mock(),
|
|
148
150
|
setInboxCursor: mock(),
|
|
@@ -383,7 +385,7 @@ describe("openSubmit", () => {
|
|
|
383
385
|
});
|
|
384
386
|
|
|
385
387
|
describe("share", () => {
|
|
386
|
-
test("
|
|
388
|
+
test("opens the public/private choice overlay instead of publishing directly", () => {
|
|
387
389
|
// Arrange
|
|
388
390
|
const deps = makeDeps();
|
|
389
391
|
const dispatch = createIntentDispatch(deps);
|
|
@@ -392,7 +394,8 @@ describe("share", () => {
|
|
|
392
394
|
dispatch({ type: "share" });
|
|
393
395
|
|
|
394
396
|
// Assert
|
|
395
|
-
expect(deps.
|
|
397
|
+
expect(deps.setMode).toHaveBeenCalledWith({ type: "shareChoice", index: 0 });
|
|
398
|
+
expect(deps.controller.share).not.toHaveBeenCalled();
|
|
396
399
|
});
|
|
397
400
|
});
|
|
398
401
|
|
|
@@ -547,6 +550,18 @@ describe("rename author", () => {
|
|
|
547
550
|
expect(deps.renameAuthor).toHaveBeenCalledWith("SHA256:x", "Alex");
|
|
548
551
|
expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
|
|
549
552
|
});
|
|
553
|
+
|
|
554
|
+
test("confirmDialog in renameSelf mode sets the trimmed local identity name and closes", () => {
|
|
555
|
+
// Arrange
|
|
556
|
+
const deps = makeDeps({ mode: { type: "renameSelf", text: " Robin " } });
|
|
557
|
+
|
|
558
|
+
// Act
|
|
559
|
+
createIntentDispatch(deps)({ type: "confirmDialog" });
|
|
560
|
+
|
|
561
|
+
// Assert
|
|
562
|
+
expect(deps.setLocalIdentityName).toHaveBeenCalledWith("Robin");
|
|
563
|
+
expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
|
|
564
|
+
});
|
|
550
565
|
});
|
|
551
566
|
|
|
552
567
|
describe("collaborator self-name", () => {
|
package/src/intent-dispatch.ts
CHANGED
|
@@ -39,6 +39,8 @@ export type Mode =
|
|
|
39
39
|
| { type: "rename"; authorId: string; text: string }
|
|
40
40
|
| { type: "renameThread"; sessionId: string; text: string }
|
|
41
41
|
| { type: "nameSelf"; text: string }
|
|
42
|
+
| { type: "renameSelf"; text: string }
|
|
43
|
+
| { type: "shareChoice"; index: number }
|
|
42
44
|
| { type: "treePrompt"; ask: TreeAsk; entryId?: string; text: string };
|
|
43
45
|
|
|
44
46
|
/** What a tree prompt asks for: a branch name, a checkpoint name, or the summary a move back leaves. */
|
|
@@ -98,6 +100,8 @@ export interface IntentDispatchDeps {
|
|
|
98
100
|
renameAuthor: (id: string, name: string) => void;
|
|
99
101
|
/** Rename a thread's title through the daemon. */
|
|
100
102
|
renameThread: (id: string, title: string) => void;
|
|
103
|
+
/** Set the local reviewer's typed display name and persist it (App-owned). */
|
|
104
|
+
setLocalIdentityName: (name: string) => void;
|
|
101
105
|
|
|
102
106
|
liveInput: MutableRefObject<string>;
|
|
103
107
|
|
|
@@ -238,6 +242,7 @@ function handleConfirmDialog(
|
|
|
238
242
|
else if (mode.type === "rename") deps.renameAuthor(mode.authorId, mode.text.trim());
|
|
239
243
|
else if (mode.type === "renameThread") deps.renameThread(mode.sessionId, mode.text.trim());
|
|
240
244
|
else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
|
|
245
|
+
else if (mode.type === "renameSelf") deps.setLocalIdentityName(mode.text.trim());
|
|
241
246
|
else if (mode.type === "treePrompt") confirmTreePrompt(mode, deps);
|
|
242
247
|
deps.setMode({ type: "normal" });
|
|
243
248
|
}
|
|
@@ -381,8 +386,9 @@ function handleOpenSubmit(_intent: IntentOfType<"openSubmit">, deps: IntentDispa
|
|
|
381
386
|
deps.setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
|
|
382
387
|
}
|
|
383
388
|
|
|
389
|
+
// share opens the public/private choice overlay; the App owns the choice keys and publishes on pick
|
|
384
390
|
function handleShare(_intent: IntentOfType<"share">, deps: IntentDispatchDeps): void {
|
|
385
|
-
deps.
|
|
391
|
+
deps.setMode({ type: "shareChoice", index: 0 });
|
|
386
392
|
}
|
|
387
393
|
|
|
388
394
|
function handleCut(_intent: IntentOfType<"cut">, deps: IntentDispatchDeps): void {
|
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,6 +87,7 @@ export interface KeyState {
|
|
|
87
87
|
| "submit"
|
|
88
88
|
| "confirm"
|
|
89
89
|
| "prompt"
|
|
90
|
+
| "shareChoice"
|
|
90
91
|
| "spanActions"
|
|
91
92
|
| "completion-prompt"
|
|
92
93
|
| "completion-counting";
|
|
@@ -192,7 +193,6 @@ function submitOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
|
192
193
|
const name = key.name;
|
|
193
194
|
|
|
194
195
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
195
|
-
if (name === "return" || name === "enter") return [{ type: "submitVerdict" }];
|
|
196
196
|
if (name === "left" || name === "right") {
|
|
197
197
|
return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
|
|
198
198
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { imagePlaceholder, looksLikeBinaryPaste } from "./pasted-image";
|
|
3
|
+
|
|
4
|
+
function bytesOf(...values: number[]): Uint8Array {
|
|
5
|
+
return Uint8Array.from(values);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe("looksLikeBinaryPaste", () => {
|
|
9
|
+
test("plain text pastes stay text", () => {
|
|
10
|
+
expect(looksLikeBinaryPaste(new TextEncoder().encode("just a normal comment"))).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("multi-line text with tabs and newlines stays text", () => {
|
|
14
|
+
expect(
|
|
15
|
+
looksLikeBinaryPaste(new TextEncoder().encode("line one\n\tline two\r\nline three")),
|
|
16
|
+
).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("unicode text stays text", () => {
|
|
20
|
+
expect(looksLikeBinaryPaste(new TextEncoder().encode("café — naïve — 日本語 — 🎉"))).toBe(
|
|
21
|
+
false,
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("a PNG signature is binary", () => {
|
|
26
|
+
expect(looksLikeBinaryPaste(bytesOf(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))).toBe(
|
|
27
|
+
true,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a JPEG signature is binary", () => {
|
|
32
|
+
expect(looksLikeBinaryPaste(bytesOf(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10))).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a GIF signature is binary", () => {
|
|
36
|
+
expect(looksLikeBinaryPaste(new TextEncoder().encode("GIF89a\x01\x02"))).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a WEBP signature is binary", () => {
|
|
40
|
+
expect(
|
|
41
|
+
looksLikeBinaryPaste(bytesOf(0x52, 0x49, 0x46, 0x46, 1, 2, 3, 4, 0x57, 0x45, 0x42, 0x50)),
|
|
42
|
+
).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a NUL byte marks binary", () => {
|
|
46
|
+
expect(looksLikeBinaryPaste(bytesOf(0x68, 0x69, 0x00, 0x74, 0x68, 0x65, 0x72, 0x65))).toBe(
|
|
47
|
+
true,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a burst of control bytes marks binary", () => {
|
|
52
|
+
expect(looksLikeBinaryPaste(bytesOf(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08))).toBe(
|
|
53
|
+
true,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("an empty paste is not binary", () => {
|
|
58
|
+
expect(looksLikeBinaryPaste(new Uint8Array())).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("imagePlaceholder", () => {
|
|
63
|
+
test("numbers each pasted image", () => {
|
|
64
|
+
expect(imagePlaceholder(1)).toBe("[Image #1]");
|
|
65
|
+
expect(imagePlaceholder(2)).toBe("[Image #2]");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Detects an image or other binary paste so a composer shows an [Image #n] placeholder instead of dumping raw bytes into the draft. */
|
|
2
|
+
|
|
3
|
+
const IMAGE_MAGIC_SIGNATURES: number[][] = [
|
|
4
|
+
[0x89, 0x50, 0x4e, 0x47], // PNG
|
|
5
|
+
[0xff, 0xd8, 0xff], // JPEG
|
|
6
|
+
[0x47, 0x49, 0x46, 0x38], // GIF87a / GIF89a
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const CONTROL_RATIO_THRESHOLD = 0.15;
|
|
10
|
+
const PASTE_DECODER = new TextDecoder("utf-8", { fatal: false });
|
|
11
|
+
|
|
12
|
+
function hasImageMagic(bytes: Uint8Array): boolean {
|
|
13
|
+
for (const signature of IMAGE_MAGIC_SIGNATURES) {
|
|
14
|
+
if (signature.every((byte, index) => bytes[index] === byte)) return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const isRiffWebp =
|
|
18
|
+
bytes.length >= 12 &&
|
|
19
|
+
bytes[0] === 0x52 && // R
|
|
20
|
+
bytes[1] === 0x49 && // I
|
|
21
|
+
bytes[2] === 0x46 && // F
|
|
22
|
+
bytes[3] === 0x46 && // F
|
|
23
|
+
bytes[8] === 0x57 && // W
|
|
24
|
+
bytes[9] === 0x45 && // E
|
|
25
|
+
bytes[10] === 0x42 && // B
|
|
26
|
+
bytes[11] === 0x50; // P
|
|
27
|
+
|
|
28
|
+
return isRiffWebp;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isControlCodePoint(codePoint: number): boolean {
|
|
32
|
+
if (codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d) return false;
|
|
33
|
+
|
|
34
|
+
return codePoint < 0x20 || codePoint === 0x7f;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when a paste is an image or other non-text binary payload and should collapse to a placeholder. */
|
|
38
|
+
export function looksLikeBinaryPaste(bytes: Uint8Array): boolean {
|
|
39
|
+
if (bytes.length === 0) return false;
|
|
40
|
+
if (hasImageMagic(bytes)) return true;
|
|
41
|
+
|
|
42
|
+
const decoded = PASTE_DECODER.decode(bytes);
|
|
43
|
+
let controlCount = 0;
|
|
44
|
+
let total = 0;
|
|
45
|
+
|
|
46
|
+
for (const character of decoded) {
|
|
47
|
+
total += 1;
|
|
48
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
49
|
+
|
|
50
|
+
if (codePoint === 0 || codePoint === 0xfffd) return true;
|
|
51
|
+
if (isControlCodePoint(codePoint)) controlCount += 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return total > 0 && controlCount / total > CONTROL_RATIO_THRESHOLD;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The draft placeholder for the nth image pasted into a composer: [Image #1], [Image #2], … */
|
|
58
|
+
export function imagePlaceholder(index: number): string {
|
|
59
|
+
return `[Image #${index}]`;
|
|
60
|
+
}
|
package/src/run.ts
CHANGED
|
@@ -4,6 +4,7 @@ import React from "react";
|
|
|
4
4
|
import { createCliRenderer } from "@opentui/core";
|
|
5
5
|
import { createRoot } from "@opentui/react";
|
|
6
6
|
import { App } from "./App";
|
|
7
|
+
import type { Appearance } from "./theme-presets";
|
|
7
8
|
import { loadConfig } from "./config";
|
|
8
9
|
import { perfMark } from "./perf/perf-timings";
|
|
9
10
|
import { reportPerfMarks } from "./perf/perf-report";
|
|
@@ -38,12 +39,6 @@ export async function runClient(options: RunClientOptions): Promise<number> {
|
|
|
38
39
|
renderer.useMouse = false;
|
|
39
40
|
renderer.useMouse = true;
|
|
40
41
|
});
|
|
41
|
-
// block first paint on the theme query only briefly, so an answering terminal paints its real theme
|
|
42
|
-
// with no flash while a non-answering one falls back to dark within the budget
|
|
43
|
-
const appearance =
|
|
44
|
-
(await renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
|
|
45
|
-
perfMark("themeQuery");
|
|
46
|
-
|
|
47
42
|
return new Promise<number>((resolve) => {
|
|
48
43
|
let exited = false;
|
|
49
44
|
const shutdown = (code: number): void => {
|
|
@@ -61,19 +56,36 @@ export async function runClient(options: RunClientOptions): Promise<number> {
|
|
|
61
56
|
process.once("SIGHUP", () => shutdown(0));
|
|
62
57
|
process.once("SIGTERM", () => shutdown(0));
|
|
63
58
|
|
|
64
|
-
createRoot(renderer)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
59
|
+
const root = createRoot(renderer);
|
|
60
|
+
const renderApp = (appearance: Appearance): void => {
|
|
61
|
+
root.render(
|
|
62
|
+
React.createElement(App, {
|
|
63
|
+
home: options.home,
|
|
64
|
+
sessionId: options.sessionId,
|
|
65
|
+
appearance,
|
|
66
|
+
layout,
|
|
67
|
+
onExit: shutdown,
|
|
68
|
+
onReady: () => {
|
|
69
|
+
perfMark("firstFrame");
|
|
70
|
+
reportPerfMarks("startup");
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Paint immediately with the default appearance; the OSC theme query resolves off the
|
|
77
|
+
// first-paint path and upgrades the theme in place when the terminal answers, so launch
|
|
78
|
+
// never blocks on it. A non-answering terminal simply stays dark.
|
|
79
|
+
renderApp("dark");
|
|
77
80
|
perfMark("render");
|
|
81
|
+
|
|
82
|
+
void renderer
|
|
83
|
+
.waitForThemeMode(THEME_QUERY_TIMEOUT_MS)
|
|
84
|
+
.then((mode) => {
|
|
85
|
+
perfMark("themeQuery");
|
|
86
|
+
// the query can answer after a fast quit; never render onto a destroyed renderer
|
|
87
|
+
if (!exited && mode && mode !== "dark") renderApp(mode);
|
|
88
|
+
})
|
|
89
|
+
.catch(() => {});
|
|
78
90
|
});
|
|
79
91
|
}
|
package/src/serve.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SSH-served TUI: `cueloop serve` lets teammates join a
|
|
3
|
-
*
|
|
3
|
+
* thread over plain ssh. Every SSH connection renders <App> in
|
|
4
4
|
* observer mode (readOnly) against the same local daemon; the one writable
|
|
5
5
|
* controller stays the local owner's own `cueloop` TUI.
|
|
6
6
|
*
|
package/src/share.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Publish a
|
|
2
|
+
* Publish a thread as a share: pack it, stream it to the gateway's
|
|
3
3
|
* `share` user over SSH, and copy the returned `ssh p_…@host` line to the
|
|
4
4
|
* clipboard. The planner holds no key, so all this side does is upload; the
|
|
5
5
|
* gateway seals and stores. Used by both `cueloop share` and the in-TUI
|
|
@@ -12,7 +12,13 @@ import {
|
|
|
12
12
|
SHARE_UPLOAD_USER,
|
|
13
13
|
packSessionBlob,
|
|
14
14
|
} from "@cueloop/daemon/share-blob";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
removalEntries,
|
|
17
|
+
viewFollowing,
|
|
18
|
+
type Annotation,
|
|
19
|
+
type ShareAccess,
|
|
20
|
+
type Thread,
|
|
21
|
+
} from "@cueloop/schema";
|
|
16
22
|
import { ThreadRecordSchema } from "@cueloop/daemon/validate";
|
|
17
23
|
import type { SharedMerge } from "@cueloop/daemon/client";
|
|
18
24
|
import * as v from "valibot";
|
|
@@ -102,11 +108,13 @@ export function mergeFromShare(remote: Thread): SharedMerge {
|
|
|
102
108
|
export async function pushShare(
|
|
103
109
|
shareId: string,
|
|
104
110
|
annotations: Array<Omit<Annotation, "createdAt">>,
|
|
111
|
+
access?: ShareAccess,
|
|
105
112
|
target: ShareTarget = {},
|
|
106
113
|
): Promise<void> {
|
|
114
|
+
const payload = access ? { shareId, annotations, access } : { shareId, annotations };
|
|
107
115
|
const { stderr, code } = await runShareSsh(
|
|
108
116
|
"cueloop-push",
|
|
109
|
-
Buffer.from(JSON.stringify(
|
|
117
|
+
Buffer.from(JSON.stringify(payload)),
|
|
110
118
|
target,
|
|
111
119
|
);
|
|
112
120
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
activeSlashToken,
|
|
4
|
+
insertSlashItem,
|
|
4
5
|
isStandaloneSlashQuery,
|
|
5
6
|
scoreMatch,
|
|
6
7
|
slashFilter,
|
|
@@ -84,3 +85,19 @@ describe("isStandaloneSlashQuery", () => {
|
|
|
84
85
|
expect(isStandaloneSlashQuery("just prose")).toBe(false);
|
|
85
86
|
});
|
|
86
87
|
});
|
|
88
|
+
|
|
89
|
+
describe("insertSlashItem", () => {
|
|
90
|
+
test("replaces the active slash token with the reference and lands the caret after it", () => {
|
|
91
|
+
const result = insertSlashItem("look at /rev", 12, "review");
|
|
92
|
+
|
|
93
|
+
expect(result.text).toBe("look at /review ");
|
|
94
|
+
expect(result.caret).toBe("look at /review ".length);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("keeps text after the caret intact", () => {
|
|
98
|
+
const result = insertSlashItem("/pl tail", 3, "plan");
|
|
99
|
+
|
|
100
|
+
expect(result.text).toBe("/plan tail");
|
|
101
|
+
expect(result.caret).toBe("/plan ".length);
|
|
102
|
+
});
|
|
103
|
+
});
|
package/src/slash-palette.ts
CHANGED
|
@@ -86,6 +86,17 @@ export function isStandaloneSlashQuery(text: string): boolean {
|
|
|
86
86
|
return /^\/\S*$/.test(text.trim());
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
export function insertSlashItem(text: string, caret: number, itemName: string) {
|
|
90
|
+
const token = activeSlashToken(text, caret) ?? "";
|
|
91
|
+
const cut = caret - token.length;
|
|
92
|
+
const insertion = `/${itemName} `;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
text: text.slice(0, cut) + insertion + text.slice(caret),
|
|
96
|
+
caret: cut + insertion.length,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
89
100
|
/**
|
|
90
101
|
* The character ranges of every completed "/name" in the draft that names a real
|
|
91
102
|
* action or skill, so a surface can paint the references. A half-typed "/na" is
|
|
@@ -109,6 +109,7 @@ function fakeClient(initial: Thread, sink: WorkingCopySink): SessionClient {
|
|
|
109
109
|
|
|
110
110
|
return session;
|
|
111
111
|
}),
|
|
112
|
+
sessionSetAccess: unimplemented("sessionSetAccess"),
|
|
112
113
|
sessionNavigate: unimplemented("sessionNavigate"),
|
|
113
114
|
sessionBranch: unimplemented("sessionBranch"),
|
|
114
115
|
sessionSwitch: unimplemented("sessionSwitch"),
|