@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.
Files changed (82) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +14 -2
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.identity.test.tsx +78 -0
  6. package/src/App.plan-review.test.tsx +16 -0
  7. package/src/App.share-choice.test.tsx +105 -0
  8. package/src/App.submit-confirm.test.tsx +34 -5
  9. package/src/App.test.tsx +2 -2
  10. package/src/App.toast.test.tsx +3 -1
  11. package/src/App.tsx +180 -5
  12. package/src/App.walk.test.tsx +2 -2
  13. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  14. package/src/ansi-screen.test.ts +28 -0
  15. package/src/ansi-screen.ts +72 -0
  16. package/src/app-screens.tsx +20 -3
  17. package/src/app-view-model.ts +4 -0
  18. package/src/components/AnnotatableFileView.tsx +10 -2
  19. package/src/components/AnnotationCards.tsx +40 -6
  20. package/src/components/AnsiScreen.stories.tsx +12 -0
  21. package/src/components/AnsiScreen.tsx +28 -0
  22. package/src/components/CompletionOverlay.stories.tsx +15 -1
  23. package/src/components/CompletionOverlay.tsx +22 -5
  24. package/src/components/ConfirmCard.stories.tsx +2 -0
  25. package/src/components/ConfirmCard.tsx +45 -18
  26. package/src/components/ConfirmDialog.tsx +11 -12
  27. package/src/components/DiffContentView.tsx +9 -5
  28. package/src/components/FileContentsView.tsx +1 -1
  29. package/src/components/GridTabContent.tsx +2 -0
  30. package/src/components/ManageAccessDialog.stories.tsx +36 -0
  31. package/src/components/ManageAccessDialog.test.tsx +94 -0
  32. package/src/components/ManageAccessDialog.tsx +122 -0
  33. package/src/components/PromptDialog.test.tsx +2 -2
  34. package/src/components/PromptDialog.tsx +9 -10
  35. package/src/components/ShareChoiceDialog.stories.tsx +34 -0
  36. package/src/components/ShareChoiceDialog.test.tsx +103 -0
  37. package/src/components/ShareChoiceDialog.tsx +80 -0
  38. package/src/components/SlashComposer.stories.tsx +37 -0
  39. package/src/components/SlashComposer.tsx +87 -0
  40. package/src/components/ThreadTree.tsx +11 -6
  41. package/src/components/ThreadView.selection.test.tsx +88 -1
  42. package/src/components/ThreadView.slash.test.tsx +0 -1
  43. package/src/components/ThreadView.tsx +71 -8
  44. package/src/components/Toast.tsx +0 -5
  45. package/src/components/WalkWizard.stories.tsx +2 -2
  46. package/src/components/WalkWizard.tsx +10 -12
  47. package/src/components/__snapshots__/stories.test.tsx.snap +277 -61
  48. package/src/components/primitives/Card.tsx +3 -0
  49. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  50. package/src/components/primitives/DialogActions.tsx +39 -0
  51. package/src/components/session-tree.ts +7 -6
  52. package/src/components/stories-app.tsx +13 -4
  53. package/src/components/stories.test.tsx +8 -1
  54. package/src/components/story-tree.test.ts +32 -24
  55. package/src/components/story.ts +32 -15
  56. package/src/config-identity.test.ts +78 -0
  57. package/src/config.ts +44 -1
  58. package/src/diff-intraline.test.ts +75 -2
  59. package/src/diff-intraline.ts +95 -26
  60. package/src/github-identity.test.ts +58 -0
  61. package/src/github-identity.ts +41 -0
  62. package/src/intent-dispatch.test.ts +17 -2
  63. package/src/intent-dispatch.ts +7 -1
  64. package/src/keymap.test.ts +2 -2
  65. package/src/keymap.ts +1 -1
  66. package/src/pasted-image.test.ts +67 -0
  67. package/src/pasted-image.ts +60 -0
  68. package/src/run.ts +31 -19
  69. package/src/serve.ts +1 -1
  70. package/src/share.ts +11 -3
  71. package/src/slash-palette.test.ts +17 -0
  72. package/src/slash-palette.ts +11 -0
  73. package/src/thread-controller.curate.test.ts +1 -0
  74. package/src/thread-controller.derivation.test.ts +62 -0
  75. package/src/thread-controller.live-diff.test.ts +1 -0
  76. package/src/thread-controller.optimistic.test.ts +69 -0
  77. package/src/thread-controller.share.test.ts +36 -2
  78. package/src/thread-controller.switch-cache.test.ts +146 -0
  79. package/src/thread-controller.tree.test.ts +1 -0
  80. package/src/thread-controller.ts +171 -24
  81. package/src/use-annotation-surface.tsx +53 -29
  82. package/src/use-settings-dialog.tsx +24 -0
@@ -36,16 +36,16 @@ function stripTrailingNewline(text: string): string {
36
36
  return text.replace(/\n$/, "");
37
37
  }
38
38
 
39
- /**
40
- * Char-precise intra-line runs for modified diff rows. Within a change block - a
41
- * run of deletions immediately followed by additions - lines are aligned to their
42
- * real counterpart (see alignedPairs) and each matched pair is word-diffed so the
43
- * removed/added words are marked changed. Rows with no counterpart (pure add, pure
44
- * delete, or an unmatched line in a shifted block) are omitted, so the caller
45
- * renders those as a single run. Each row's runs concatenate back to that row's text.
46
- */
47
- export function intralineRunsByRow(rows: DiffRow[]): Map<number, IntralineRun[]> {
48
- const runsByRow = new Map<number, IntralineRun[]>();
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
- const deletionTexts = rows
63
- .slice(deletionStart, additionStart)
64
- .map((row) => stripTrailingNewline(row.text));
65
- const additionTexts = rows
66
- .slice(additionStart, index)
67
- .map((row) => stripTrailingNewline(row.text));
68
-
69
- for (const [deletionOffset, additionOffset] of alignedPairs(deletionTexts, additionTexts)) {
70
- const changes = wordLevelChanges(
71
- deletionTexts[deletionOffset]!,
72
- additionTexts[additionOffset]!,
73
- );
74
-
75
- runsByRow.set(deletionStart + deletionOffset, sideRuns(changes, "removed"));
76
- runsByRow.set(additionStart + additionOffset, sideRuns(changes, "added"));
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("dispatches to the controller's share", () => {
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.controller.share).toHaveBeenCalled();
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", () => {
@@ -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.controller.share();
391
+ deps.setMode({ type: "shareChoice", index: 0 });
386
392
  }
387
393
 
388
394
  function handleCut(_intent: IntentOfType<"cut">, deps: IntentDispatchDeps): void {
@@ -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", [{ type: "submitVerdict" }]],
63
- ["enter", [{ type: "submitVerdict" }]],
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).render(
65
- React.createElement(App, {
66
- home: options.home,
67
- sessionId: options.sessionId,
68
- appearance,
69
- layout,
70
- onExit: shutdown,
71
- onReady: () => {
72
- perfMark("firstFrame");
73
- reportPerfMarks("startup");
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
- * review session over plain ssh. Every SSH connection renders <App> in
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 review session as a share: pack it, stream it to the gateway's
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 { removalEntries, viewFollowing, type Annotation, type Thread } from "@cueloop/schema";
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({ shareId, annotations })),
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
+ });
@@ -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"),