@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
@@ -33,7 +33,7 @@ export function projectName(workspace: WorkspaceKey): string {
33
33
  export type InboxRow =
34
34
  | { kind: "section"; id: string; label: string }
35
35
  | { kind: "project"; id: string; label: string }
36
- | { kind: "thread"; id: string; session: Thread; selectionIndex: number };
36
+ | { kind: "thread"; id: string; session: Thread; selectionIndex: number; depth: number };
37
37
 
38
38
  export interface GroupedInbox {
39
39
  rows: InboxRow[];
@@ -78,14 +78,14 @@ export function groupInbox(
78
78
 
79
79
  const rows: InboxRow[] = [];
80
80
  const ordered: Thread[] = [];
81
- const pushThread = (session: Thread): void => {
82
- rows.push({ kind: "thread", id: session.id, session, selectionIndex: ordered.length });
81
+ const pushThread = (session: Thread, depth: number): void => {
82
+ rows.push({ kind: "thread", id: session.id, session, selectionIndex: ordered.length, depth });
83
83
  ordered.push(session);
84
84
  };
85
85
 
86
86
  if (pinned.length > 0) {
87
87
  rows.push({ kind: "section", id: "section:pinned", label: "Starred" });
88
- for (const session of pinned) pushThread(session);
88
+ for (const session of pinned) pushThread(session, 1);
89
89
  }
90
90
 
91
91
  const projectEntries = [...projects.entries()].sort((a, b) => a[1].name.localeCompare(b[1].name));
@@ -93,13 +93,14 @@ export function groupInbox(
93
93
  rows.push({ kind: "section", id: "section:projects", label: "Projects" });
94
94
  for (const [key, project] of projectEntries) {
95
95
  rows.push({ kind: "project", id: `project:${key}`, label: project.name });
96
- for (const session of project.sessions) pushThread(session);
96
+ // a project's threads cascade one level under its name
97
+ for (const session of project.sessions) pushThread(session, 2);
97
98
  }
98
99
  }
99
100
 
100
101
  if (standalone.length > 0) {
101
102
  rows.push({ kind: "section", id: "section:threads", label: "Threads" });
102
- for (const session of standalone) pushThread(session);
103
+ for (const session of standalone) pushThread(session, 1);
103
104
  }
104
105
 
105
106
  return { rows, ordered };
@@ -13,9 +13,10 @@ import { ThemeProvider } from "./theme-context";
13
13
  import { buildStoryTree, loadStories, type LoadedStory } from "./story";
14
14
  import { PaneGrid } from "./PaneGrid";
15
15
  import { ShellHeader } from "./ShellHeader";
16
+ import { AnsiScreen } from "./AnsiScreen";
16
17
  import { Tree } from "./primitives/Tree";
17
18
  import { NERD } from "./primitives/icons";
18
- import { allFolderIds, flattenTree } from "./primitives/tree-model";
19
+ import { flattenTree } from "./primitives/tree-model";
19
20
 
20
21
  interface StoriesAppProps {
21
22
  stories: LoadedStory[];
@@ -30,8 +31,8 @@ function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
30
31
  );
31
32
  const firstStoryId = `${stories[0]!.moduleTitle}/${stories[0]!.storyName}`;
32
33
 
33
- const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(() =>
34
- allFolderIds(treeNodes),
34
+ const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(
35
+ () => new Set(treeNodes.filter((node) => node.children !== undefined).map((node) => node.id)),
35
36
  );
36
37
  const [selectedId, setSelectedId] = useState(firstStoryId);
37
38
  const [openedId, setOpenedId] = useState(firstStoryId);
@@ -126,7 +127,15 @@ function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
126
127
  <scrollbox style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }} focused={false}>
127
128
  {/* remount per story so component-local state never leaks across */}
128
129
  <box key={openedId} style={{ flexDirection: "column" }}>
129
- {opened.story.render()}
130
+ {opened.story.ansi ? (
131
+ <AnsiScreen
132
+ ansi={opened.story.ansi()}
133
+ cols={opened.story.size?.width ?? 80}
134
+ rows={opened.story.size?.height ?? 24}
135
+ />
136
+ ) : (
137
+ opened.story.render?.()
138
+ )}
130
139
  </box>
131
140
  </scrollbox>
132
141
  ) : (
@@ -5,10 +5,12 @@
5
5
  * stories file fails the harness, so the safety net cannot silently thin.
6
6
  */
7
7
 
8
+ import React from "react";
8
9
  import { describe, expect, test } from "bun:test";
9
10
  import { testRender } from "@opentui/react/test-utils";
10
11
  import { RGBA } from "@opentui/core";
11
12
  import { componentFilesMissingStories, loadStories } from "./story";
13
+ import { AnsiScreen } from "./AnsiScreen";
12
14
  import { allowEventLoopUpdates } from "../test-support";
13
15
 
14
16
  const stories = await loadStories();
@@ -34,9 +36,14 @@ describe("stories catalog", () => {
34
36
  test(`${moduleTitle}/${storyName} renders, snapshots, and carries its colors`, async () => {
35
37
  // Arrange
36
38
  const size = story.size ?? { width: 80, height: 24 };
39
+ const node = story.ansi ? (
40
+ <AnsiScreen ansi={story.ansi()} cols={size.width} rows={size.height} />
41
+ ) : (
42
+ story.render!()
43
+ );
37
44
 
38
45
  // Act
39
- const setup = await testRender(story.render(), size);
46
+ const setup = await testRender(node, size);
40
47
 
41
48
  allowEventLoopUpdates();
42
49
  await setup.waitForVisualIdle();
@@ -1,12 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { buildStoryTree, type LoadedStory } from "./story";
2
+ import { buildStoryTree, type LoadedStory, type StorySection } from "./story";
3
3
  import type { TreeNode } from "./primitives/tree-model";
4
4
 
5
5
  /** A story object whose render is never invoked in these structural tests. */
6
6
  const STUB = { render: () => null };
7
7
 
8
- function story(moduleTitle: string, storyName: string): LoadedStory {
9
- return { moduleTitle, storyName, story: STUB };
8
+ function story(moduleTitle: string, storyName: string, section: StorySection = "App"): LoadedStory {
9
+ return { section, moduleTitle, storyName, story: STUB };
10
10
  }
11
11
 
12
12
  function childLabels(node: TreeNode): string[] {
@@ -14,19 +14,31 @@ function childLabels(node: TreeNode): string[] {
14
14
  }
15
15
 
16
16
  describe("buildStoryTree", () => {
17
- test("splits moduleTitle on / into nested folders keyed by their path", () => {
17
+ test("groups the top level by section", () => {
18
+ const tree = buildStoryTree([
19
+ story("Primitives/Tree", "Default"),
20
+ story("Collaborator Join", "JoinSplash", "SSH"),
21
+ ]);
22
+
23
+ expect(tree.map((node) => [node.id, node.label])).toEqual([
24
+ ["App", "App"],
25
+ ["SSH", "SSH"],
26
+ ]);
27
+ });
28
+
29
+ test("splits moduleTitle on / into nested folders under the section", () => {
18
30
  // Arrange
19
31
  const stories = [story("Primitives/Tree", "Default"), story("Layout/AppShell", "Default")];
20
32
 
21
33
  // Act
22
- const tree = buildStoryTree(stories);
34
+ const app = buildStoryTree(stories)[0]!;
23
35
 
24
36
  // Assert
25
- expect(tree.map((node) => [node.id, node.label])).toEqual([
26
- ["Primitives", "Primitives"],
27
- ["Layout", "Layout"],
37
+ expect(app.children!.map((node) => [node.id, node.label])).toEqual([
38
+ ["App/Primitives", "Primitives"],
39
+ ["App/Layout", "Layout"],
28
40
  ]);
29
- expect(tree[0]!.children!.map((node) => node.id)).toEqual(["Primitives/Tree"]);
41
+ expect(app.children![0]!.children!.map((node) => node.id)).toEqual(["App/Primitives/Tree"]);
30
42
  });
31
43
 
32
44
  test("groups sibling components under one shared parent folder", () => {
@@ -34,11 +46,11 @@ describe("buildStoryTree", () => {
34
46
  const stories = [story("Primitives/Tree", "Default"), story("Primitives/Card", "Default")];
35
47
 
36
48
  // Act
37
- const tree = buildStoryTree(stories);
49
+ const app = buildStoryTree(stories)[0]!;
38
50
 
39
51
  // Assert
40
- expect(tree).toHaveLength(1);
41
- expect(childLabels(tree[0]!)).toEqual(["Tree", "Card"]);
52
+ expect(app.children).toHaveLength(1);
53
+ expect(childLabels(app.children![0]!)).toEqual(["Tree", "Card"]);
42
54
  });
43
55
 
44
56
  test("nests to the full depth of the moduleTitle path", () => {
@@ -46,13 +58,12 @@ describe("buildStoryTree", () => {
46
58
  const stories = [story("A/B/C", "Only")];
47
59
 
48
60
  // Act
49
- const tree = buildStoryTree(stories);
50
- const a = tree[0]!;
61
+ const a = buildStoryTree(stories)[0]!.children![0]!;
51
62
  const b = a.children![0]!;
52
63
 
53
64
  // Assert
54
- expect([a.id, b.id]).toEqual(["A", "A/B"]);
55
- expect(b.children![0]!.id).toBe("A/B/C");
65
+ expect([a.id, b.id]).toEqual(["App/A", "App/A/B"]);
66
+ expect(b.children![0]!.id).toBe("App/A/B/C");
56
67
  });
57
68
 
58
69
  test("adds each story as a leaf keyed `${moduleTitle}/${storyName}` under its component", () => {
@@ -60,11 +71,10 @@ describe("buildStoryTree", () => {
60
71
  const stories = [story("Primitives/Tree", "Empty"), story("Primitives/Tree", "Deep")];
61
72
 
62
73
  // Act
63
- const tree = buildStoryTree(stories);
64
- const component = tree[0]!.children![0]!;
74
+ const component = buildStoryTree(stories)[0]!.children![0]!.children![0]!;
65
75
 
66
76
  // Assert
67
- expect(component.id).toBe("Primitives/Tree");
77
+ expect(component.id).toBe("App/Primitives/Tree");
68
78
  expect(component.children!.map((leaf) => [leaf.id, leaf.label])).toEqual([
69
79
  ["Primitives/Tree/Empty", "Empty"],
70
80
  ["Primitives/Tree/Deep", "Deep"],
@@ -76,8 +86,7 @@ describe("buildStoryTree", () => {
76
86
  const stories = [story("Primitives/Card", "Card")];
77
87
 
78
88
  // Act
79
- const tree = buildStoryTree(stories);
80
- const hoisted = tree[0]!.children![0]!;
89
+ const hoisted = buildStoryTree(stories)[0]!.children![0]!.children![0]!;
81
90
 
82
91
  // Assert
83
92
  expect(hoisted.children).toBeUndefined();
@@ -90,11 +99,10 @@ describe("buildStoryTree", () => {
90
99
  const stories = [story("Primitives/Card", "Default")];
91
100
 
92
101
  // Act
93
- const tree = buildStoryTree(stories);
94
- const component = tree[0]!.children![0]!;
102
+ const component = buildStoryTree(stories)[0]!.children![0]!.children![0]!;
95
103
 
96
104
  // Assert
97
- expect(component.id).toBe("Primitives/Card");
105
+ expect(component.id).toBe("App/Primitives/Card");
98
106
  expect(component.children!.map((leaf) => leaf.id)).toEqual(["Primitives/Card/Default"]);
99
107
  });
100
108
  });
@@ -15,7 +15,9 @@ export interface StoryMeta {
15
15
  }
16
16
 
17
17
  export interface Story {
18
- render: () => React.ReactNode;
18
+ render?: () => React.ReactNode;
19
+ /** Raw ANSI to paint through AnsiScreen instead of a React tree; exactly one of render or ansi is set. */
20
+ ansi?: () => string;
19
21
  /**
20
22
  * Colors (hex tokens) that must appear among the rendered frame's styled
21
23
  * spans - the color regression net for color-bearing stories.
@@ -25,18 +27,21 @@ export interface Story {
25
27
  size?: { width: number; height: number };
26
28
  }
27
29
 
30
+ export type StorySection = "App" | "SSH";
31
+
28
32
  export interface LoadedStory {
33
+ section: StorySection;
29
34
  moduleTitle: string;
30
35
  storyName: string;
31
36
  story: Story;
32
37
  }
33
38
 
34
39
  export function isStory(value: unknown): value is Story {
40
+ if (typeof value !== "object" || value === null) return false;
41
+
35
42
  return (
36
- typeof value === "object" &&
37
- value !== null &&
38
- "render" in value &&
39
- typeof value.render === "function"
43
+ ("render" in value && typeof value.render === "function") ||
44
+ ("ansi" in value && typeof value.ansi === "function")
40
45
  );
41
46
  }
42
47
 
@@ -49,22 +54,34 @@ function isStoryMeta(value: unknown): value is StoryMeta {
49
54
  );
50
55
  }
51
56
 
52
- /** Import every *.stories.tsx next to the components and flatten the exports. */
53
- export async function loadStories(): Promise<LoadedStory[]> {
54
- const glob = new Bun.Glob("**/*.stories.tsx");
55
- const files = [...glob.scanSync({ cwd: import.meta.dir })].sort();
56
- const loaded: LoadedStory[] = [];
57
+ const GATEWAY_SRC_DIR = `${import.meta.dir}/../../../gateway/src`;
58
+
59
+ async function collectStories(
60
+ dir: string,
61
+ pattern: string,
62
+ section: StorySection,
63
+ loaded: LoadedStory[],
64
+ ): Promise<void> {
65
+ const glob = new Bun.Glob(pattern);
66
+ const files = [...glob.scanSync({ cwd: dir })].sort();
57
67
 
58
68
  for (const file of files) {
59
- const moduleExports = await import(`${import.meta.dir}/${file}`);
69
+ const moduleExports = await import(`${dir}/${file}`);
60
70
  const meta = moduleExports["meta"];
61
- const moduleTitle = isStoryMeta(meta) ? meta.title : file.replace(/\.stories\.tsx$/, "");
71
+ const moduleTitle = isStoryMeta(meta) ? meta.title : file.replace(/\.stories\.tsx?$/, "");
62
72
 
63
73
  for (const [exportName, exported] of Object.entries(moduleExports)) {
64
74
  if (exportName === "meta" || !isStory(exported)) continue;
65
- loaded.push({ moduleTitle, storyName: exportName, story: exported });
75
+ loaded.push({ section, moduleTitle, storyName: exportName, story: exported });
66
76
  }
67
77
  }
78
+ }
79
+
80
+ export async function loadStories(): Promise<LoadedStory[]> {
81
+ const loaded: LoadedStory[] = [];
82
+
83
+ await collectStories(import.meta.dir, "**/*.stories.tsx", "App", loaded);
84
+ await collectStories(GATEWAY_SRC_DIR, "**/*.stories.ts", "SSH", loaded);
68
85
 
69
86
  return loaded;
70
87
  }
@@ -102,11 +119,11 @@ export function buildStoryTree(stories: LoadedStory[]): TreeNode[] {
102
119
  const roots: TreeNode[] = [];
103
120
  const foldersByPath = new Map<string, StoryTreeFolder>();
104
121
 
105
- for (const { moduleTitle, storyName } of stories) {
122
+ for (const { section, moduleTitle, storyName } of stories) {
106
123
  let siblings = roots;
107
124
  let path = "";
108
125
 
109
- for (const segment of moduleTitle.split("/")) {
126
+ for (const segment of [section, ...moduleTitle.split("/")]) {
110
127
  path = path === "" ? segment : `${path}/${segment}`;
111
128
  let folder = foldersByPath.get(path);
112
129
  if (folder === undefined) {
@@ -1,5 +1,11 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { intralineRunsByRow, wordLevelChanges, type IntralineRun } from "./diff-intraline";
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import * as intraline from "./diff-intraline";
3
+ import {
4
+ createIntralineResolver,
5
+ intralineRunsByRow,
6
+ wordLevelChanges,
7
+ type IntralineRun,
8
+ } from "./diff-intraline";
3
9
  import type { DiffRow } from "./view-diff";
4
10
 
5
11
  function line(kind: DiffRow["kind"], text: string): DiffRow {
@@ -163,3 +169,70 @@ describe("intralineRunsByRow", () => {
163
169
  expect(changedText(runsByRow.get(3)!)).toBe("FOUR");
164
170
  });
165
171
  });
172
+
173
+ describe("createIntralineResolver", () => {
174
+ test("resolves each row to the same runs the eager pass would, on demand", () => {
175
+ // Arrange - context around two separate change blocks
176
+ const rows = [
177
+ line("ctx", "header"),
178
+ line("del", "one two"),
179
+ line("add", "one TWO"),
180
+ line("ctx", "middle"),
181
+ line("del", "alpha beta"),
182
+ line("add", "alpha GAMMA"),
183
+ line("ctx", "footer"),
184
+ ];
185
+ const eager = intralineRunsByRow(rows);
186
+
187
+ // Act
188
+ const resolver = createIntralineResolver(rows);
189
+
190
+ // Assert - lazy row-by-row output matches the eager map, including undefined for context rows
191
+ for (let index = 0; index < rows.length; index++) {
192
+ expect(resolver.runsForRow(index)).toEqual(eager.get(index));
193
+ }
194
+ });
195
+
196
+ test("a second call for the same row returns the cached runs", () => {
197
+ const rows = [line("del", "one two"), line("add", "one TWO")];
198
+ const resolver = createIntralineResolver(rows);
199
+
200
+ expect(resolver.runsForRow(0)).toBe(resolver.runsForRow(0));
201
+ });
202
+
203
+ // The performance guard: a virtualized diff must word-diff only the change blocks whose rows are on
204
+ // screen. This is deterministic (a call count, not a wall-clock), so it fails on any machine if a
205
+ // future change makes the resolver eager again - the regression the nav-latency benchmark would show.
206
+ test("word-diffs only the accessed block, not the whole diff", () => {
207
+ // Arrange - 50 independent change blocks, each a single del/add pair separated by context
208
+ const rows: DiffRow[] = [];
209
+
210
+ for (let block = 0; block < 50; block++) {
211
+ rows.push(
212
+ line("ctx", `ctx ${block}`),
213
+ line("del", `old ${block}`),
214
+ line("add", `new ${block}`),
215
+ );
216
+ }
217
+ const spy = spyOn(intraline, "wordLevelChanges");
218
+
219
+ try {
220
+ // Act - the eager pass word-diffs every block
221
+ spy.mockClear();
222
+ intralineRunsByRow(rows);
223
+ const eagerCalls = spy.mock.calls.length;
224
+
225
+ // Act - the resolver word-diffs only the one block whose row is asked for
226
+ spy.mockClear();
227
+ const resolver = createIntralineResolver(rows);
228
+ resolver.runsForRow(1); // a row in block 0
229
+ const oneBlockCalls = spy.mock.calls.length;
230
+
231
+ // Assert - eager pays for all 50 blocks; lazy pays for one
232
+ expect(eagerCalls).toBe(50);
233
+ expect(oneBlockCalls).toBe(1);
234
+ } finally {
235
+ spy.mockRestore();
236
+ }
237
+ });
238
+ });
@@ -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;
@@ -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
@@ -193,7 +193,6 @@ function submitOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
193
193
  const name = key.name;
194
194
 
195
195
  if (name === "escape") return [{ type: "closeOverlay" }];
196
- if (name === "return" || name === "enter") return [{ type: "submitVerdict" }];
197
196
  if (name === "left" || name === "right") {
198
197
  return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
199
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
+ });