@cueloop/client 0.1.0-alpha.66 → 0.1.0-alpha.68

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 (117) hide show
  1. package/package.json +6 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +71 -39
  4. package/src/App.plan-review.test.tsx +20 -21
  5. package/src/App.readonly.test.tsx +7 -3
  6. package/src/App.test.tsx +73 -14
  7. package/src/App.tsx +375 -129
  8. package/src/App.walk.test.tsx +14 -15
  9. package/src/App.workbench.test.tsx +390 -0
  10. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  11. package/src/annotation-palette.ts +61 -0
  12. package/src/app-screens.tsx +176 -45
  13. package/src/app-view-model.ts +7 -65
  14. package/src/components/AnnotationCard.tsx +1 -1
  15. package/src/components/AnnotationCards.stories.tsx +87 -0
  16. package/src/components/AnnotationCards.tsx +303 -0
  17. package/src/components/AppHeader.stories.tsx +50 -0
  18. package/src/components/AppHeader.tsx +83 -0
  19. package/src/components/AppShell.stories.tsx +47 -42
  20. package/src/components/AppShell.tsx +212 -100
  21. package/src/components/AppShell.zoom.test.tsx +94 -0
  22. package/src/components/ChangesColumn.tsx +49 -26
  23. package/src/components/DiffSheet.scroll.test.tsx +38 -55
  24. package/src/components/DiffSheet.split.test.tsx +140 -0
  25. package/src/components/DiffSheet.stories.tsx +55 -53
  26. package/src/components/DiffSheet.syntax.test.tsx +24 -7
  27. package/src/components/DiffSheet.tsx +607 -276
  28. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  29. package/src/components/DiscussionMarkerRail.tsx +181 -0
  30. package/src/components/EditorGrid.stories.tsx +59 -0
  31. package/src/components/EditorGrid.tabs.test.tsx +105 -0
  32. package/src/components/EditorGrid.tsx +338 -0
  33. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  34. package/src/components/FileContentsView.stories.tsx +16 -0
  35. package/src/components/FileContentsView.test.tsx +29 -0
  36. package/src/components/FileContentsView.tsx +78 -0
  37. package/src/components/InboxList.tsx +159 -30
  38. package/src/components/PaneGrid.stories.tsx +84 -0
  39. package/src/components/PaneGrid.tsx +128 -0
  40. package/src/components/PanelColumn.stories.tsx +25 -0
  41. package/src/components/PanelColumn.tsx +95 -0
  42. package/src/components/ProjectTreeView.stories.tsx +25 -0
  43. package/src/components/ProjectTreeView.test.tsx +32 -0
  44. package/src/components/ProjectTreeView.tsx +82 -0
  45. package/src/components/PromptDialog.tsx +8 -1
  46. package/src/components/RootOverlay.stories.tsx +44 -0
  47. package/src/components/RootOverlay.tsx +35 -0
  48. package/src/components/SettingsDialog.tsx +2 -1
  49. package/src/components/SurfaceScrollbar.stories.tsx +34 -0
  50. package/src/components/SurfaceScrollbar.test.tsx +111 -0
  51. package/src/components/SurfaceScrollbar.tsx +93 -0
  52. package/src/components/ThreadFooter.stories.tsx +2 -2
  53. package/src/components/ThreadFooter.test.tsx +31 -0
  54. package/src/components/ThreadFooter.tsx +12 -7
  55. package/src/components/ThreadView.tsx +62 -1440
  56. package/src/components/ThreadsSidebar.tsx +14 -1
  57. package/src/components/Tooltip.stories.tsx +26 -0
  58. package/src/components/Tooltip.tsx +72 -0
  59. package/src/components/WelcomeSurface.stories.tsx +12 -0
  60. package/src/components/WelcomeSurface.tsx +52 -0
  61. package/src/components/__snapshots__/stories.test.tsx.snap +509 -559
  62. package/src/components/diff-sheet-layout.test.ts +125 -55
  63. package/src/components/diff-sheet-layout.ts +3 -103
  64. package/src/components/editor-grid.test.ts +86 -0
  65. package/src/components/editor-grid.ts +138 -0
  66. package/src/components/fade-title.test.ts +49 -0
  67. package/src/components/fade-title.ts +61 -0
  68. package/src/components/file-tree.test.ts +15 -0
  69. package/src/components/file-tree.ts +59 -4
  70. package/src/components/pages/Workspace.stories.tsx +2 -2
  71. package/src/components/primitives/IconButton.tsx +21 -2
  72. package/src/components/primitives/Tree.tsx +13 -4
  73. package/src/components/primitives/icons.ts +27 -4
  74. package/src/components/session-tree.test.ts +20 -0
  75. package/src/components/session-tree.ts +16 -2
  76. package/src/components/stories-app.tsx +2 -2
  77. package/src/config.test.ts +20 -49
  78. package/src/config.ts +26 -28
  79. package/src/diff-fold.test.ts +78 -0
  80. package/src/diff-fold.ts +126 -0
  81. package/src/diff-syntax.test.ts +16 -0
  82. package/src/diff-syntax.ts +6 -3
  83. package/src/discussions.ts +79 -0
  84. package/src/ghostty-terminal.test.ts +2 -1
  85. package/src/ghostty-terminal.ts +27 -4
  86. package/src/intent-dispatch.test.ts +31 -11
  87. package/src/intent-dispatch.ts +93 -48
  88. package/src/key-bindings.test.ts +1 -1
  89. package/src/keymap.test.ts +3 -25
  90. package/src/keymap.ts +17 -18
  91. package/src/mark-runs.ts +120 -0
  92. package/src/pty.ts +10 -2
  93. package/src/run.ts +17 -7
  94. package/src/serve.test.ts +4 -8
  95. package/src/session-controller.curate.test.ts +75 -0
  96. package/src/session-controller.share.test.ts +1 -0
  97. package/src/session-controller.tree.test.ts +26 -0
  98. package/src/session-controller.ts +153 -29
  99. package/src/slash-palette.ts +83 -0
  100. package/src/split-diff.test.ts +110 -0
  101. package/src/split-diff.ts +149 -0
  102. package/src/test-support.test.ts +15 -3
  103. package/src/test-support.ts +66 -15
  104. package/src/thread-chords.test.ts +25 -9
  105. package/src/thread-chords.ts +33 -8
  106. package/src/use-annotation-surface.tsx +837 -0
  107. package/src/use-changes-workbench.ts +151 -0
  108. package/src/use-frame-measure.ts +38 -0
  109. package/src/use-settings-dialog.tsx +14 -24
  110. package/src/use-terminal-virtualizer.ts +233 -0
  111. package/src/view-diff.ts +114 -0
  112. package/src/components/ReviewPanel.stories.tsx +0 -100
  113. package/src/components/ReviewPanel.tsx +0 -103
  114. package/src/components/ReviewRail.stories.tsx +0 -232
  115. package/src/components/ReviewRail.tsx +0 -327
  116. package/src/review-panel.test.ts +0 -113
  117. package/src/review-panel.ts +0 -83
@@ -91,6 +91,7 @@ function fakeClient(session: ReviewSession) {
91
91
  parentSessionId: id,
92
92
  })),
93
93
  sessionSetViewed: unimplemented("sessionSetViewed"),
94
+ sessionSetTitle: unimplemented("sessionSetTitle"),
94
95
  sessionSetShareId: mock(async (_id: string, _shareId: string) => session),
95
96
  sessionMergeShared: unimplemented("sessionMergeShared"),
96
97
  sessionDelete: unimplemented("sessionDelete"),
@@ -116,6 +117,31 @@ async function connected(session = sessionFixture()) {
116
117
  return { controller, client, session };
117
118
  }
118
119
 
120
+ describe("read-only guards", () => {
121
+ test("an observer's rename and delete never reach the daemon", async () => {
122
+ // Arrange - a read-only controller over a client whose mutations would throw
123
+ const session = sessionFixture();
124
+ const client = fakeClient(session);
125
+ const controller = createReviewController({
126
+ sessionId: session.id,
127
+ openClient: async () => client,
128
+ readOnly: true,
129
+ shareTransport,
130
+ });
131
+
132
+ controller.connect();
133
+ await tick();
134
+
135
+ // Act - the sidebar's rename and delete, invoked directly
136
+ controller.renameSession(session.id, "hijacked");
137
+ controller.deleteSession(session.id);
138
+ await tick();
139
+
140
+ // Assert - both answer read-only and the throwing client stubs were never called
141
+ expect(controller.getSnapshot().status).toBe("observer - read-only");
142
+ });
143
+ });
144
+
119
145
  describe("tree primitives", () => {
120
146
  test("a move back shows the earlier path at once and asks the daemon for the same move", async () => {
121
147
  // Arrange
@@ -27,6 +27,7 @@ import {
27
27
  switchBranch,
28
28
  viewOfPath,
29
29
  type Annotation,
30
+ type DiffFileContents,
30
31
  type ReviewSession,
31
32
  type SessionHistory,
32
33
  type VerdictKind,
@@ -43,7 +44,9 @@ import {
43
44
  } from "./share";
44
45
  import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
45
46
  import { entryTarget, treeRows, type TreeRow } from "./tree-view";
46
- import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
47
+ import { diffRowBlocks, diffRows, fileChangeCounts, type DiffRow } from "./view-diff";
48
+ import { applyFold } from "./diff-fold";
49
+ import { copyToClipboard } from "./clipboard";
47
50
  import {
48
51
  changeRejectionForRow,
49
52
  hunkRejectionForRow,
@@ -57,14 +60,7 @@ import type { FileDiffMetadata } from "@pierre/diffs";
57
60
  import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
58
61
  import { editInEditor } from "./editor";
59
62
  import { focusHerdrPane } from "./herdr";
60
- import {
61
- persistAutoClose,
62
- persistReviewState,
63
- persistReviewWidth,
64
- type AutoClose,
65
- type CueloopConfig,
66
- } from "./config";
67
- import type { ReviewPanelMode } from "./review-panel";
63
+ import { persistAutoClose, type AutoClose, type CueloopConfig } from "./config";
68
64
 
69
65
  /**
70
66
  * Post-submit lifecycle (a review pane should hand you back to the agent,
@@ -167,6 +163,8 @@ const DEFAULT_SHARE_TRANSPORT: ShareTransport = {
167
163
  export interface ReviewControllerOptions {
168
164
  home?: string;
169
165
  sessionId?: string;
166
+ /** The directory the client launched in; its git repo backs the no-session welcome tree. Defaults to process.cwd(). */
167
+ cwd?: string;
170
168
  /** Observer mode: stored for the key reducer's read-only gate. */
171
169
  readOnly?: boolean;
172
170
  onExit?: (code: number) => void;
@@ -197,13 +195,37 @@ export interface ReviewController {
197
195
  /** Derived projections, cached per session identity. */
198
196
  display(): DisplayBlock[];
199
197
  rows(): DiffRow[];
198
+ /** Fold a file's diff body to just its band, or unfold it; rows() reflects the change. */
199
+ setFileCollapsed(file: string, collapsed: boolean): void;
200
+ isFileCollapsed(file: string): boolean;
201
+ /** Weave a file's full contents inline (unchanged lines as context), or fold back to hunks. */
202
+ setFileExpanded(file: string, expanded: boolean): void;
203
+ isFileExpanded(file: string): boolean;
204
+ /** Whether the file carries the full contents weaving needs (curatable diffs do). */
205
+ canExpandFile(file: string): boolean;
206
+ /** Copy a file's path to the clipboard; reports the outcome via status. */
207
+ copyFilePath(file: string): void;
208
+ /** Per-file added/removed line counts from the base rows (survives collapse). */
209
+ fileStats(): Map<string, { additions: number; deletions: number }>;
200
210
  /** The walk's step list, derived from the diff rows. */
201
211
  files(): WalkFile[];
202
212
  working(): string;
213
+ /** The workspace's tracked files (git ls-files) for the Project tree; empty when unavailable. */
214
+ projectFiles(): Promise<string[]>;
215
+ /** Read a workspace file's contents for a Changes file tab; null when it cannot be read. */
216
+ readFile(path: string): Promise<string | null>;
217
+ /** The launch repo's tracked files for the no-session welcome Project tree; empty when not a repo. */
218
+ repoFiles(): Promise<string[]>;
219
+ /** Read a launch-repo file's contents for a welcome file tab; null when it cannot be read. */
220
+ repoReadFile(path: string): Promise<string | null>;
221
+ /** The launch repo's working-tree changed files for the no-session welcome Changes tree. */
222
+ repoChanges(): Promise<readonly DiffFileContents[]>;
203
223
  /** Open a session from the inbox. */
204
224
  open(id: string): void;
205
225
  /** Delete a session for good (inbox delete); the inbox refreshes on the event. */
206
226
  deleteSession(id: string): void;
227
+ /** Rename a session's title; the inbox refreshes on the event. */
228
+ renameSession(id: string, title: string): void;
207
229
  /** Record the viewer's own name into the share's participant registry (collaborator self-naming). */
208
230
  setSelfName(name: string): void;
209
231
  /** Cut the block under the cursor, or restore a cut one. */
@@ -279,12 +301,6 @@ export interface ReviewController {
279
301
  dismissCompletion(): void;
280
302
  /** From the completion prompt: persist auto-close and start the countdown. */
281
303
  optInAutoClose(): void;
282
- /**
283
- * Persist the review-panel layout (client view state) to the user config so
284
- * the collapse mode and rail width survive a restart. A read-only config dir
285
- * never blocks the review.
286
- */
287
- saveReviewPanel(layout: { mode?: ReviewPanelMode; width?: number }): void;
288
304
  }
289
305
 
290
306
  export function createReviewController(options: ReviewControllerOptions): ReviewController {
@@ -332,6 +348,10 @@ class Controller implements ReviewController {
332
348
  models: new Map(),
333
349
  tree: [],
334
350
  };
351
+ /** File paths whose diff body is folded to just the file band. */
352
+ private collapsedFiles = new Set<string>();
353
+ /** File paths woven to their full contents (unchanged lines shown as context). */
354
+ private expandedFiles = new Set<string>();
335
355
  /** The diff's reject decisions live on the session record; the daemon curates from them. */
336
356
  private get rejections(): HunkRejection[] {
337
357
  return this.snapshot.session?.curation ?? EMPTY_REJECTIONS;
@@ -367,12 +387,13 @@ class Controller implements ReviewController {
367
387
  if (this.closed) return void client.close();
368
388
  this.client = client;
369
389
  client.onEvent((event) => {
370
- // another controller/observer changed state: re-fetch
390
+ // another controller/observer changed state: re-fetch the active session's content
371
391
  const session = this.snapshot.session;
372
392
 
373
393
  if (session && event.sessionId === session.id) void this.refreshSession(session.id);
374
- // the Threads sidebar shows the inbox even on a direct open, so keep it fresh
375
- void this.refreshInbox();
394
+ // only the list-changing events touch the Threads sidebar; refreshing on every
395
+ // content edit (session.updated) would amplify an active review into a request storm
396
+ if (event.event !== "session.updated") void this.refreshInbox();
376
397
  });
377
398
  await client.subscribe();
378
399
  // load the inbox in both cases so the sidebar can jump between threads
@@ -428,6 +449,9 @@ class Controller implements ReviewController {
428
449
 
429
450
  if (this.derivedFor === session) return;
430
451
  this.derivedFor = session;
452
+ // a new session starts fully expanded, never inheriting the prior diff's fold state
453
+ this.collapsedFiles.clear();
454
+ this.expandedFiles.clear();
431
455
  const rows =
432
456
  session && session.artifact.type === "diff" ? diffRows(session.artifact.content) : [];
433
457
  const models = new Map<string, FileDiffMetadata>();
@@ -456,8 +480,58 @@ class Controller implements ReviewController {
456
480
 
457
481
  rows(): DiffRow[] {
458
482
  this.ensureDerived();
483
+ if (this.collapsedFiles.size === 0 && this.expandedFiles.size === 0) return this.derived.rows;
484
+
485
+ return applyFold(
486
+ this.derived.rows,
487
+ this.collapsedFiles,
488
+ this.expandedFiles,
489
+ this.snapshot.session?.artifact.files,
490
+ );
491
+ }
492
+
493
+ setFileCollapsed(file: string, collapsed: boolean): void {
494
+ if (collapsed) {
495
+ this.collapsedFiles.add(file);
496
+ this.expandedFiles.delete(file);
497
+ } else {
498
+ this.collapsedFiles.delete(file);
499
+ }
500
+ this.update({});
501
+ }
502
+
503
+ isFileCollapsed(file: string): boolean {
504
+ return this.collapsedFiles.has(file);
505
+ }
506
+
507
+ setFileExpanded(file: string, expanded: boolean): void {
508
+ if (expanded) {
509
+ this.expandedFiles.add(file);
510
+ this.collapsedFiles.delete(file);
511
+ } else {
512
+ this.expandedFiles.delete(file);
513
+ }
514
+ this.update({});
515
+ }
516
+
517
+ isFileExpanded(file: string): boolean {
518
+ return this.expandedFiles.has(file);
519
+ }
520
+
521
+ canExpandFile(file: string): boolean {
522
+ return (this.snapshot.session?.artifact.files ?? []).some((entry) => entry.path === file);
523
+ }
524
+
525
+ copyFilePath(file: string): void {
526
+ void copyToClipboard(file).then((copied) =>
527
+ this.setStatus(copied ? `copied ${file}` : "no clipboard tool available"),
528
+ );
529
+ }
530
+
531
+ fileStats(): Map<string, { additions: number; deletions: number }> {
532
+ this.ensureDerived();
459
533
 
460
- return this.derived.rows;
534
+ return fileChangeCounts(this.derived.rows);
461
535
  }
462
536
 
463
537
  files(): WalkFile[] {
@@ -472,6 +546,52 @@ class Controller implements ReviewController {
472
546
  return session ? (session.workingCopy ?? session.artifact.content) : "";
473
547
  }
474
548
 
549
+ async projectFiles(): Promise<string[]> {
550
+ const session = this.snapshot.session;
551
+ if (this.client?.projectFiles === undefined || !session) return [];
552
+
553
+ return this.client.projectFiles(session.id);
554
+ }
555
+
556
+ async readFile(path: string): Promise<string | null> {
557
+ const session = this.snapshot.session;
558
+ if (this.client?.fileContents === undefined || !session) return null;
559
+
560
+ return this.client.fileContents(session.id, path);
561
+ }
562
+
563
+ /** The repo the sidebar tree/changes point at: the thread's own repo, else the launch directory. */
564
+ private sidebarRepoRoot(): string {
565
+ return this.snapshot.session?.workspace.repoRoot || this.options.cwd || process.cwd();
566
+ }
567
+
568
+ async repoFiles(): Promise<string[]> {
569
+ if (this.client?.repoFiles === undefined) return [];
570
+
571
+ return this.client.repoFiles(this.sidebarRepoRoot());
572
+ }
573
+
574
+ async repoReadFile(path: string): Promise<string | null> {
575
+ if (this.client?.repoFileContents === undefined) return null;
576
+
577
+ return this.client.repoFileContents(this.sidebarRepoRoot(), path);
578
+ }
579
+
580
+ async repoChanges(): Promise<readonly DiffFileContents[]> {
581
+ // a diff review pins its captured snapshot; every other thread reflects the live working tree
582
+ const session = this.snapshot.session;
583
+ if (session?.artifact.type === "diff") return session.artifact.files ?? [];
584
+ if (this.client?.repoChanges === undefined) return [];
585
+ const changes = await this.client.repoChanges(this.sidebarRepoRoot());
586
+
587
+ return changes.map((change) => ({
588
+ path: change.path,
589
+ status: change.status,
590
+ oldContents: "",
591
+ newContents: "",
592
+ }));
593
+ }
594
+
475
595
  // Refreshes race the connection teardown: an event can arrive while close()
476
596
  // is rejecting in-flight requests, and a fire-and-forget refresh must never
477
597
  // surface that as an unhandled rejection.
@@ -526,6 +646,7 @@ class Controller implements ReviewController {
526
646
  }
527
647
 
528
648
  deleteSession(id: string): void {
649
+ if (this.readOnly) return this.setStatus("observer - read-only");
529
650
  this.client
530
651
  ?.sessionDelete(id)
531
652
  .then(() => this.setStatus("plan deleted"))
@@ -534,6 +655,16 @@ class Controller implements ReviewController {
534
655
  );
535
656
  }
536
657
 
658
+ renameSession(id: string, title: string): void {
659
+ if (this.readOnly) return this.setStatus("observer - read-only");
660
+ this.client
661
+ ?.sessionSetTitle(id, title)
662
+ .then(() => this.setStatus("thread renamed"))
663
+ .catch((cause: unknown) =>
664
+ this.setStatus(`rename failed: ${cause instanceof Error ? cause.message : String(cause)}`),
665
+ );
666
+ }
667
+
537
668
  setSelfName(name: string): void {
538
669
  const session = this.snapshot.session;
539
670
 
@@ -823,7 +954,9 @@ class Controller implements ReviewController {
823
954
  let anchor;
824
955
 
825
956
  if (session.artifact.type === "diff") {
826
- anchor = { ...diffRowAnchor(this.rows(), displayIndex), blockIndex: displayIndex };
957
+ // rows are the diff's blocks: a span over one or more code rows anchors with the
958
+ // same quote, context, and position selectors a plan span does
959
+ anchor = makeAnchor(diffRowBlocks(this.rows()), displayIndex, start, end, endDisplayIndex);
827
960
  } else {
828
961
  const display = this.display();
829
962
  const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
@@ -1262,13 +1395,4 @@ class Controller implements ReviewController {
1262
1395
  }
1263
1396
  this.autoClose = DEFAULT_AUTO_CLOSE;
1264
1397
  }
1265
-
1266
- saveReviewPanel(layout: { mode?: ReviewPanelMode; width?: number }): void {
1267
- try {
1268
- if (layout.mode !== undefined) persistReviewState(layout.mode);
1269
- if (layout.width !== undefined) persistReviewWidth(layout.width);
1270
- } catch {
1271
- // a read-only config dir must never block collapsing or resizing the rail
1272
- }
1273
- }
1274
1398
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The "/" quick-action palette behind a composer: the skill list a leading
3
+ * slash opens, its prefix > substring > subsequence filter, and the inline
4
+ * "/word" completion offered mid-sentence. Pure; the surfaces render it.
5
+ */
6
+
7
+ import { quickActionBody, type QuickAction } from "./config";
8
+
9
+ export interface SlashItem {
10
+ name: string;
11
+ description: string;
12
+ body: string;
13
+ }
14
+
15
+ export function slashItemsFrom(quickActions: QuickAction[]): SlashItem[] {
16
+ return quickActions.map((action) => ({
17
+ name: action.prompt
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, "-")
20
+ .replace(/^-|-$/g, ""),
21
+ description: action.metadata ?? action.prompt,
22
+ body: quickActionBody(action),
23
+ }));
24
+ }
25
+
26
+ /** Prefix beats substring beats subsequence. */
27
+ export function slashFilter(items: SlashItem[], query: string): SlashItem[] {
28
+ const needle = query.toLowerCase();
29
+
30
+ if (needle.length === 0) return items;
31
+ const scored: Array<{ item: SlashItem; score: number }> = [];
32
+
33
+ for (const item of items) {
34
+ const name = item.name.toLowerCase();
35
+ let score = 0;
36
+
37
+ if (name.startsWith(needle)) score = 3;
38
+ else if (name.includes(needle)) score = 2;
39
+ else {
40
+ let matched = 0;
41
+
42
+ for (const character of name) {
43
+ if (character === needle[matched]) matched++;
44
+ }
45
+ if (matched === needle.length) score = 1;
46
+ }
47
+ if (score > 0) scored.push({ item, score });
48
+ }
49
+
50
+ return scored.toSorted((left, right) => right.score - left.score).map((entry) => entry.item);
51
+ }
52
+
53
+ /**
54
+ * A skill invoked mid-sentence: the trailing "/word" token when text already
55
+ * precedes it (a draft that starts with "/" is the palette, not an inline
56
+ * completion). Newline-safe, since the token may sit at the start of a new line.
57
+ */
58
+ export function inlineSlashToken(text: string): string | null {
59
+ if (text.startsWith("/")) return null;
60
+ const match = /(?:^|\s)(\/[a-zA-Z0-9-]*)$/.exec(text);
61
+
62
+ return match ? match[1]! : null;
63
+ }
64
+
65
+ export interface InlineSlash {
66
+ token: string;
67
+ suggestion: SlashItem;
68
+ }
69
+
70
+ /** The inline completion state: the trailing "/word" and its closest skill, or null. */
71
+ export function resolveInlineSuggestion(
72
+ slashActive: boolean,
73
+ text: string,
74
+ quickActions: QuickAction[],
75
+ ): InlineSlash | null {
76
+ if (slashActive) return null;
77
+ const token = inlineSlashToken(text);
78
+
79
+ if (token === null) return null;
80
+ const suggestion = slashFilter(slashItemsFrom(quickActions), token.slice(1))[0];
81
+
82
+ return suggestion ? { token, suggestion } : null;
83
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { diffRows } from "./view-diff";
3
+ import { splitDiffRows, splitRowOffsets, type SplitRow } from "./split-diff";
4
+
5
+ const PATCH = `diff --git a/greet.ts b/greet.ts
6
+ --- a/greet.ts
7
+ +++ b/greet.ts
8
+ @@ -1,4 +1,4 @@
9
+ const name = "world";
10
+ -console.log("hi " + name);
11
+ -console.log("done");
12
+ +console.log("hello " + name);
13
+ +console.log("finished");
14
+ export {};
15
+ `;
16
+
17
+ function pairs(rows: SplitRow[]): SplitRow[] {
18
+ return rows.filter((row) => row.kind === "pair");
19
+ }
20
+
21
+ describe("splitDiffRows", () => {
22
+ test("carries the file and hunk headers through unchanged", () => {
23
+ // Act
24
+ const split = splitDiffRows(diffRows(PATCH));
25
+
26
+ // Assert
27
+ expect(split[0]).toMatchObject({ kind: "file", file: "greet.ts", text: "greet.ts" });
28
+ expect(split[1]?.kind).toBe("hunk");
29
+ });
30
+
31
+ test("a context line sits on both sides with its own line numbers", () => {
32
+ // Act
33
+ const first = pairs(splitDiffRows(diffRows(PATCH)))[0]!;
34
+
35
+ // Assert
36
+ expect(first.left).toMatchObject({ kind: "ctx", lineNumber: 1 });
37
+ expect(first.right).toMatchObject({ kind: "ctx", lineNumber: 1 });
38
+ expect(first.left?.text).toBe(first.right?.text);
39
+ });
40
+
41
+ test("a balanced change block pairs each deletion against an addition", () => {
42
+ // Act - the two deletions zip against the two additions, one per row
43
+ const changed = pairs(splitDiffRows(diffRows(PATCH))).filter(
44
+ (row) => row.left?.kind === "del" || row.right?.kind === "add",
45
+ );
46
+
47
+ // Assert
48
+ expect(changed).toHaveLength(2);
49
+ expect(changed[0]).toMatchObject({
50
+ left: { kind: "del", lineNumber: 2 },
51
+ right: { kind: "add", lineNumber: 2 },
52
+ });
53
+ expect(changed[1]).toMatchObject({
54
+ left: { kind: "del", lineNumber: 3 },
55
+ right: { kind: "add", lineNumber: 3 },
56
+ });
57
+ });
58
+
59
+ test("an unbalanced block blank-fills the shorter side", () => {
60
+ // Arrange - one deletion, three additions
61
+ const patch = `diff --git a/x.ts b/x.ts
62
+ --- a/x.ts
63
+ +++ b/x.ts
64
+ @@ -1,2 +1,4 @@
65
+ keep;
66
+ -old;
67
+ +new1;
68
+ +new2;
69
+ +new3;
70
+ `;
71
+
72
+ // Act
73
+ const changed = pairs(splitDiffRows(diffRows(patch))).filter(
74
+ (row) => row.left?.kind === "del" || row.right?.kind === "add",
75
+ );
76
+
77
+ // Assert - three rows tall, only the first has a left deletion
78
+ expect(changed).toHaveLength(3);
79
+ expect(changed[0]?.left?.kind).toBe("del");
80
+ expect(changed[1]?.left).toBeUndefined();
81
+ expect(changed[2]?.left).toBeUndefined();
82
+ expect(changed.map((row) => row.right?.text.trim())).toEqual(["new1;", "new2;", "new3;"]);
83
+ });
84
+
85
+ test("each side keeps its originating unified row for anchoring", () => {
86
+ // Act
87
+ const base = diffRows(PATCH);
88
+ const changed = pairs(splitDiffRows(base)).find((row) => row.right?.kind === "add")!;
89
+
90
+ // Assert - the split line points back at a row from the base list
91
+ expect(base[changed.right!.rowIndex]).toBe(changed.right!.row);
92
+ });
93
+ });
94
+
95
+ describe("splitRowOffsets", () => {
96
+ test("maps both sides of a pair to the same y and steps a file band by three", () => {
97
+ // Arrange
98
+ const base = diffRows(PATCH);
99
+ const split = splitDiffRows(base);
100
+
101
+ // Act
102
+ const offsets = splitRowOffsets(split);
103
+
104
+ // Assert - the file band is three rows and the hunk one, so the first context pair sits at y 4
105
+ const firstContext = split.find((row) => row.kind === "pair" && row.left?.kind === "ctx")!;
106
+
107
+ expect(offsets.get(firstContext.left!.rowIndex)).toBe(4);
108
+ expect(offsets.get(firstContext.right!.rowIndex)).toBe(4);
109
+ });
110
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Side-by-side diff projection: fold the unified DiffRow list into paired rows,
3
+ * old on the left and new on the right. A context line sits on both sides; a
4
+ * change block zips its deletions against its additions to the taller side, with
5
+ * a blank filler where one side runs out. Each side keeps its originating unified
6
+ * row, so the cursor, anchors, and curation keep working through the base rows.
7
+ */
8
+
9
+ import type { DiffRow } from "./view-diff";
10
+
11
+ /** One code line on one side of the split, carrying its originating unified row. */
12
+ export interface SplitLine {
13
+ /** The unified diff row this side renders; the anchor and curation target. */
14
+ row: DiffRow;
15
+ /** Index of the originating row in the base list; the cursor and curation index. */
16
+ rowIndex: number;
17
+ kind: "ctx" | "del" | "add";
18
+ text: string;
19
+ /** Old line number for the left column, new line number for the right column. */
20
+ lineNumber?: number;
21
+ }
22
+
23
+ /** A rendered split row: a file band, a hunk header, or an old|new line pair. */
24
+ export interface SplitRow {
25
+ kind: "file" | "hunk" | "pair";
26
+ file: string;
27
+ /** Header text for file and hunk rows. */
28
+ text?: string;
29
+ /** Index of the header in the base list, for file and hunk rows. */
30
+ rowIndex?: number;
31
+ /** Old side: a context or deletion line, or absent (blank filler). */
32
+ left?: SplitLine;
33
+ /** New side: a context or addition line, or absent (blank filler). */
34
+ right?: SplitLine;
35
+ }
36
+
37
+ /** A base row paired with its index in the base list. */
38
+ interface IndexedRow {
39
+ row: DiffRow;
40
+ index: number;
41
+ }
42
+
43
+ function contextLine({ row, index }: IndexedRow, side: "left" | "right"): SplitLine {
44
+ return {
45
+ row,
46
+ rowIndex: index,
47
+ kind: "ctx",
48
+ text: row.text,
49
+ lineNumber: side === "left" ? row.oldLine : row.newLine,
50
+ };
51
+ }
52
+
53
+ function deletionLine({ row, index }: IndexedRow): SplitLine {
54
+ return { row, rowIndex: index, kind: "del", text: row.text, lineNumber: row.oldLine };
55
+ }
56
+
57
+ function additionLine({ row, index }: IndexedRow): SplitLine {
58
+ return { row, rowIndex: index, kind: "add", text: row.text, lineNumber: row.newLine };
59
+ }
60
+
61
+ /** Zip a change block's deletions against its additions, blank-filling the shorter side. */
62
+ function zipChangeBlock(
63
+ deletions: IndexedRow[],
64
+ additions: IndexedRow[],
65
+ file: string,
66
+ ): SplitRow[] {
67
+ const pairs: SplitRow[] = [];
68
+ const height = Math.max(deletions.length, additions.length);
69
+
70
+ for (let index = 0; index < height; index++) {
71
+ const del = deletions[index];
72
+ const add = additions[index];
73
+
74
+ pairs.push({
75
+ kind: "pair",
76
+ file,
77
+ left: del ? deletionLine(del) : undefined,
78
+ right: add ? additionLine(add) : undefined,
79
+ });
80
+ }
81
+
82
+ return pairs;
83
+ }
84
+
85
+ /** Project unified diff rows into side-by-side split rows. */
86
+ export function splitDiffRows(rows: DiffRow[]): SplitRow[] {
87
+ const split: SplitRow[] = [];
88
+ let pendingDeletions: IndexedRow[] = [];
89
+ let pendingAdditions: IndexedRow[] = [];
90
+ let blockFile = "";
91
+
92
+ const flushChangeBlock = (): void => {
93
+ if (pendingDeletions.length === 0 && pendingAdditions.length === 0) return;
94
+ split.push(...zipChangeBlock(pendingDeletions, pendingAdditions, blockFile));
95
+ pendingDeletions = [];
96
+ pendingAdditions = [];
97
+ };
98
+
99
+ rows.forEach((row, index) => {
100
+ if (row.kind === "add") {
101
+ blockFile = row.file;
102
+ pendingAdditions.push({ row, index });
103
+ return;
104
+ }
105
+ if (row.kind === "del") {
106
+ blockFile = row.file;
107
+ pendingDeletions.push({ row, index });
108
+ return;
109
+ }
110
+ // any non-change row closes the current change block before it renders
111
+ flushChangeBlock();
112
+ if (row.kind === "file" || row.kind === "hunk") {
113
+ split.push({ kind: row.kind, file: row.file, text: row.text, rowIndex: index });
114
+ } else
115
+ split.push({
116
+ kind: "pair",
117
+ file: row.file,
118
+ left: contextLine({ row, index }, "left"),
119
+ right: contextLine({ row, index }, "right"),
120
+ });
121
+ });
122
+ flushChangeBlock();
123
+
124
+ return split;
125
+ }
126
+
127
+ /**
128
+ * Visual y of each base row index in the split layout, so the cursor scrolls into
129
+ * view. A file band spans three rows (rule, name, rule), a hunk one, a pair one; a
130
+ * pair's two sides share the row, so both base indices map to the same y.
131
+ */
132
+ export function splitRowOffsets(splitRows: SplitRow[]): Map<number, number> {
133
+ const offsets = new Map<number, number>();
134
+ let contentY = 0;
135
+
136
+ for (const row of splitRows) {
137
+ if (row.kind === "file") {
138
+ contentY += 3;
139
+ } else if (row.kind === "hunk") {
140
+ contentY += 1;
141
+ } else {
142
+ if (row.left) offsets.set(row.left.rowIndex, contentY);
143
+ if (row.right) offsets.set(row.right.rowIndex, contentY);
144
+ contentY += 1;
145
+ }
146
+ }
147
+
148
+ return offsets;
149
+ }