@cueloop/client 0.1.0-alpha.77 → 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 (63) 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.plan-review.test.tsx +16 -0
  6. package/src/App.submit-confirm.test.tsx +34 -5
  7. package/src/App.test.tsx +2 -2
  8. package/src/App.tsx +4 -0
  9. package/src/App.walk.test.tsx +2 -2
  10. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  11. package/src/ansi-screen.test.ts +28 -0
  12. package/src/ansi-screen.ts +72 -0
  13. package/src/app-screens.tsx +7 -3
  14. package/src/app-view-model.ts +3 -0
  15. package/src/components/AnnotatableFileView.tsx +1 -1
  16. package/src/components/AnnotationCards.tsx +18 -2
  17. package/src/components/AnsiScreen.stories.tsx +12 -0
  18. package/src/components/AnsiScreen.tsx +28 -0
  19. package/src/components/CompletionOverlay.stories.tsx +15 -1
  20. package/src/components/CompletionOverlay.tsx +22 -5
  21. package/src/components/ConfirmCard.stories.tsx +2 -0
  22. package/src/components/ConfirmCard.tsx +45 -18
  23. package/src/components/ConfirmDialog.tsx +11 -12
  24. package/src/components/DiffContentView.tsx +4 -4
  25. package/src/components/FileContentsView.tsx +1 -1
  26. package/src/components/ManageAccessDialog.tsx +13 -11
  27. package/src/components/PromptDialog.test.tsx +2 -2
  28. package/src/components/PromptDialog.tsx +9 -10
  29. package/src/components/ShareChoiceDialog.tsx +0 -2
  30. package/src/components/SlashComposer.stories.tsx +37 -0
  31. package/src/components/SlashComposer.tsx +87 -0
  32. package/src/components/ThreadTree.tsx +11 -6
  33. package/src/components/ThreadView.selection.test.tsx +88 -1
  34. package/src/components/ThreadView.slash.test.tsx +0 -1
  35. package/src/components/ThreadView.tsx +66 -7
  36. package/src/components/Toast.tsx +0 -5
  37. package/src/components/WalkWizard.stories.tsx +2 -2
  38. package/src/components/WalkWizard.tsx +10 -12
  39. package/src/components/__snapshots__/stories.test.tsx.snap +219 -89
  40. package/src/components/primitives/Card.tsx +3 -0
  41. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  42. package/src/components/primitives/DialogActions.tsx +39 -0
  43. package/src/components/session-tree.ts +7 -6
  44. package/src/components/stories-app.tsx +13 -4
  45. package/src/components/stories.test.tsx +8 -1
  46. package/src/components/story-tree.test.ts +32 -24
  47. package/src/components/story.ts +32 -15
  48. package/src/diff-intraline.test.ts +75 -2
  49. package/src/diff-intraline.ts +95 -26
  50. package/src/keymap.test.ts +2 -2
  51. package/src/keymap.ts +0 -1
  52. package/src/pasted-image.test.ts +67 -0
  53. package/src/pasted-image.ts +60 -0
  54. package/src/run.ts +31 -19
  55. package/src/serve.ts +1 -1
  56. package/src/share.ts +1 -1
  57. package/src/slash-palette.test.ts +17 -0
  58. package/src/slash-palette.ts +11 -0
  59. package/src/thread-controller.derivation.test.ts +62 -0
  60. package/src/thread-controller.optimistic.test.ts +69 -0
  61. package/src/thread-controller.switch-cache.test.ts +146 -0
  62. package/src/thread-controller.ts +157 -24
  63. package/src/use-annotation-surface.tsx +43 -27
@@ -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
@@ -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
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
3
+ import { sameDerivationInputs } from "./thread-controller";
4
+
5
+ function threadFixture(overrides: Partial<Thread> = {}): Thread {
6
+ return {
7
+ schemaVersion: SCHEMA_VERSION,
8
+ id: "ses_1",
9
+ workspace: { repoRoot: "/repo", branch: "main" },
10
+ artifact: { type: "plan", content: "# Plan\n\nShip it.\n", meta: {} },
11
+ revisions: [
12
+ { revision: 1, content: "# Plan\n\nShip it.\n", submittedAt: "2026-01-01T00:00:00.000Z" },
13
+ ],
14
+ annotations: [],
15
+ verdict: null,
16
+ status: "pending",
17
+ createdAt: "2026-01-01T00:00:00.000Z",
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ const annotation: Annotation = {
23
+ id: "ann_1",
24
+ kind: "comment",
25
+ anchor: { quote: "Ship it.", prefix: "", suffix: "" },
26
+ body: "nit",
27
+ createdAt: "2026-01-01T00:00:00.000Z",
28
+ };
29
+
30
+ describe("sameDerivationInputs", () => {
31
+ test("an annotation-only update reuses the projection (a fresh record, identical inputs)", () => {
32
+ // Arrange - the daemon re-serializes the whole thread on any change, so the record is all-new
33
+ const before = threadFixture();
34
+ const after = threadFixture({ annotations: [annotation] });
35
+
36
+ // Assert - the artifact and working copy are unchanged, so the projection can be reused
37
+ expect(sameDerivationInputs(before, after)).toBe(true);
38
+ });
39
+
40
+ test("a content edit forces a re-derivation", () => {
41
+ const before = threadFixture();
42
+ const after = threadFixture({
43
+ artifact: { type: "plan", content: "# Plan\n\nShip it soon.\n", meta: {} },
44
+ });
45
+
46
+ expect(sameDerivationInputs(before, after)).toBe(false);
47
+ });
48
+
49
+ test("a working-copy change forces a re-derivation", () => {
50
+ const before = threadFixture();
51
+ const after = threadFixture({ workingCopy: "# Plan\n\nShip it.\n\nedited\n" });
52
+
53
+ expect(sameDerivationInputs(before, after)).toBe(false);
54
+ });
55
+
56
+ test("a different thread is never a reuse", () => {
57
+ const before = threadFixture();
58
+ const after = threadFixture({ id: "ses_2" });
59
+
60
+ expect(sameDerivationInputs(before, after)).toBe(false);
61
+ });
62
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
3
+ import { withAnnotationUpserted } from "./thread-controller";
4
+
5
+ function threadFixture(annotations: Annotation[] = []): Thread {
6
+ return {
7
+ schemaVersion: SCHEMA_VERSION,
8
+ id: "ses_1",
9
+ workspace: { repoRoot: "/repo", branch: "main" },
10
+ artifact: { type: "plan", content: "# Plan\n\nShip it.\n", meta: {} },
11
+ revisions: [
12
+ { revision: 1, content: "# Plan\n\nShip it.\n", submittedAt: "2026-01-01T00:00:00.000Z" },
13
+ ],
14
+ annotations,
15
+ verdict: null,
16
+ status: "pending",
17
+ createdAt: "2026-01-01T00:00:00.000Z",
18
+ };
19
+ }
20
+
21
+ const existing: Annotation = {
22
+ id: "ann_1",
23
+ kind: "comment",
24
+ anchor: { quote: "Ship it.", prefix: "", suffix: "" },
25
+ body: "first",
26
+ createdAt: "2026-01-01T00:00:00.000Z",
27
+ };
28
+
29
+ describe("withAnnotationUpserted", () => {
30
+ test("a new comment is appended so it paints immediately", () => {
31
+ // Arrange
32
+ const session = threadFixture([existing]);
33
+ const wire = { id: "ann_2", kind: "comment", anchor: existing.anchor, body: "second" };
34
+
35
+ // Act
36
+ const next = withAnnotationUpserted(session, wire);
37
+
38
+ // Assert
39
+ expect(next.annotations.map((entry) => entry.id)).toEqual(["ann_1", "ann_2"]);
40
+ expect(next.annotations[1]!.body).toBe("second");
41
+ expect(next.annotations[1]!.createdAt).not.toBe("");
42
+ });
43
+
44
+ test("an edit replaces the note in place and keeps its timestamp", () => {
45
+ // Arrange
46
+ const session = threadFixture([existing]);
47
+ const wire = { id: "ann_1", kind: "comment", anchor: existing.anchor, body: "edited" };
48
+
49
+ // Act
50
+ const next = withAnnotationUpserted(session, wire);
51
+
52
+ // Assert - same position, same createdAt, new body (no reorder or timestamp flash)
53
+ expect(next.annotations.map((entry) => entry.id)).toEqual(["ann_1"]);
54
+ expect(next.annotations[0]!.body).toBe("edited");
55
+ expect(next.annotations[0]!.createdAt).toBe(existing.createdAt);
56
+ });
57
+
58
+ test("the input session is not mutated", () => {
59
+ // Arrange
60
+ const session = threadFixture([existing]);
61
+ const wire = { id: "ann_2", kind: "comment", anchor: existing.anchor, body: "second" };
62
+
63
+ // Act
64
+ withAnnotationUpserted(session, wire);
65
+
66
+ // Assert
67
+ expect(session.annotations).toHaveLength(1);
68
+ });
69
+ });
@@ -0,0 +1,146 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ SCHEMA_VERSION,
4
+ type DiffFileContents,
5
+ type HunkRejection,
6
+ type Thread,
7
+ } from "@cueloop/schema";
8
+ import { curateDiff } from "@cueloop/daemon/curate";
9
+ import type { SessionClient } from "@cueloop/daemon/client";
10
+ import { createReviewController } from "./thread-controller";
11
+
12
+ const PATCH_A = `diff --git a/src/a.ts b/src/a.ts
13
+ index 111..222 100644
14
+ --- a/src/a.ts
15
+ +++ b/src/a.ts
16
+ @@ -1,3 +1,3 @@
17
+ export class A {
18
+ - value = 1;
19
+ + value = 2;
20
+ }
21
+ `;
22
+
23
+ const PATCH_B = `diff --git a/src/b.ts b/src/b.ts
24
+ index 333..444 100644
25
+ --- a/src/b.ts
26
+ +++ b/src/b.ts
27
+ @@ -1,3 +1,3 @@
28
+ export class B {
29
+ - flag = false;
30
+ + flag = true;
31
+ }
32
+ `;
33
+
34
+ function diffSession(id: string, patch: string, files: DiffFileContents[]): Thread {
35
+ return {
36
+ schemaVersion: SCHEMA_VERSION,
37
+ id,
38
+ workspace: { repoRoot: "/repo", branch: "main" },
39
+ artifact: { type: "diff", content: patch, meta: { title: id }, files },
40
+ revisions: [{ revision: 1, content: patch, submittedAt: "2026-01-01T00:00:00.000Z" }],
41
+ annotations: [],
42
+ verdict: null,
43
+ status: "pending",
44
+ createdAt: "2026-01-01T00:00:00.000Z",
45
+ };
46
+ }
47
+
48
+ const FILES_A: DiffFileContents[] = [
49
+ {
50
+ path: "src/a.ts",
51
+ oldContents: "export class A {\n value = 1;\n}\n",
52
+ newContents: "export class A {\n value = 2;\n}\n",
53
+ status: "modified",
54
+ },
55
+ ];
56
+ const FILES_B: DiffFileContents[] = [
57
+ {
58
+ path: "src/b.ts",
59
+ oldContents: "export class B {\n flag = false;\n}\n",
60
+ newContents: "export class B {\n flag = true;\n}\n",
61
+ status: "modified",
62
+ },
63
+ ];
64
+
65
+ const unimplemented = (member: string) => () =>
66
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
67
+
68
+ /** A fake client over a fixed set of sessions, so open(id) can switch between them from the inbox. */
69
+ function fakeClient(sessions: Thread[]): SessionClient {
70
+ const byId = new Map(sessions.map((session) => [session.id, session]));
71
+
72
+ return {
73
+ onEvent: () => () => {},
74
+ subscribe: async () => {},
75
+ sessionGet: async (id: string) => byId.get(id)!,
76
+ sessionList: async () => [...byId.values()],
77
+ sessionCurate: mock(async (id: string, rejections: HunkRejection[]) => {
78
+ const session = byId.get(id)!;
79
+ const content = rejections.length
80
+ ? curateDiff(session.artifact.files!, rejections)
81
+ : undefined;
82
+ const next = {
83
+ ...session,
84
+ workingCopy: content,
85
+ curation: rejections.length ? rejections : undefined,
86
+ };
87
+
88
+ byId.set(id, next);
89
+
90
+ return next;
91
+ }),
92
+ sessionComment: unimplemented("sessionComment"),
93
+ sessionAnnotate: unimplemented("sessionAnnotate"),
94
+ sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
95
+ sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
96
+ sessionCutBlock: unimplemented("sessionCutBlock"),
97
+ sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
98
+ sessionSetAccess: unimplemented("sessionSetAccess"),
99
+ sessionNavigate: unimplemented("sessionNavigate"),
100
+ sessionBranch: unimplemented("sessionBranch"),
101
+ sessionSwitch: unimplemented("sessionSwitch"),
102
+ sessionLabel: unimplemented("sessionLabel"),
103
+ sessionFork: unimplemented("sessionFork"),
104
+ sessionSetViewed: unimplemented("sessionSetViewed"),
105
+ sessionSetTitle: unimplemented("sessionSetTitle"),
106
+ sessionSetShareId: unimplemented("sessionSetShareId"),
107
+ sessionMergeShared: unimplemented("sessionMergeShared"),
108
+ sessionDelete: unimplemented("sessionDelete"),
109
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
110
+ sessionResolve: unimplemented("sessionResolve"),
111
+ close: () => {},
112
+ };
113
+ }
114
+
115
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
116
+
117
+ // rows for each single-change patch: file(0), hunk(1), ctx(2), del(3), add(4), ctx(5)
118
+ describe("switching threads keeps the cached projection curatable", () => {
119
+ test("curation still works after switching away and back to a diff thread", async () => {
120
+ // Arrange - two diff threads in one inbox
121
+ const controller = createReviewController({
122
+ sessionId: "ses_a",
123
+ openClient: async () =>
124
+ fakeClient([
125
+ diffSession("ses_a", PATCH_A, FILES_A),
126
+ diffSession("ses_b", PATCH_B, FILES_B),
127
+ ]),
128
+ });
129
+
130
+ controller.connect();
131
+ await tick();
132
+
133
+ // Act - visit B, then return to A (A's projection now comes from the per-thread cache)
134
+ controller.open("ses_b");
135
+ await tick();
136
+ controller.open("ses_a");
137
+ await tick();
138
+
139
+ // Assert - the returned thread's file model parses lazily and curation lands
140
+ controller.toggleRejectChange(4);
141
+ await tick();
142
+
143
+ expect(controller.getSnapshot().status).toContain("change rejected");
144
+ expect(controller.getSnapshot().session?.workingCopy).toBe("");
145
+ });
146
+ });