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

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 +7 -6
  2. package/src/App.appearance.test.tsx +8 -6
  3. package/src/App.diff.test.tsx +3 -3
  4. package/src/App.export.test.tsx +9 -3
  5. package/src/App.plan-review.test.tsx +10 -8
  6. package/src/App.prototype-markdown.test.tsx +92 -0
  7. package/src/App.prototype.test.tsx +17 -28
  8. package/src/App.readonly.test.tsx +3 -3
  9. package/src/App.rendering.test.tsx +3 -4
  10. package/src/App.test.tsx +14 -18
  11. package/src/App.tsx +117 -42
  12. package/src/App.walk.test.tsx +2 -2
  13. package/src/App.workbench.test.tsx +85 -14
  14. package/src/app-screens.tsx +26 -10
  15. package/src/app-view-model.ts +17 -3
  16. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  17. package/src/components/AnnotatableFileView.test.tsx +102 -0
  18. package/src/components/AnnotatableFileView.tsx +126 -0
  19. package/src/components/AnnotationCards.tsx +6 -6
  20. package/src/components/AppShell.tsx +62 -15
  21. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  22. package/src/components/BareWorkbenchFileView.tsx +69 -0
  23. package/src/components/ChangesColumn.tsx +3 -2
  24. package/src/components/{DiffSheet.scroll.test.tsx → DiffContentView.scroll.test.tsx} +2 -2
  25. package/src/components/{DiffSheet.split.test.tsx → DiffContentView.split.test.tsx} +3 -3
  26. package/src/components/{DiffSheet.stories.tsx → DiffContentView.stories.tsx} +3 -3
  27. package/src/components/{DiffSheet.syntax.test.tsx → DiffContentView.syntax.test.tsx} +2 -2
  28. package/src/components/{DiffSheet.tsx → DiffContentView.tsx} +187 -28
  29. package/src/components/DiscussionMarkerRail.tsx +2 -2
  30. package/src/components/EditorGrid.tabs.test.tsx +17 -4
  31. package/src/components/EditorGrid.tsx +49 -19
  32. package/src/components/FileContentsView.test.tsx +3 -3
  33. package/src/components/FileContentsView.tsx +37 -17
  34. package/src/components/{SurfaceScrollbar.stories.tsx → OverlayScrollbar.stories.tsx} +5 -6
  35. package/src/components/{SurfaceScrollbar.test.tsx → OverlayScrollbar.test.tsx} +41 -13
  36. package/src/components/{SurfaceScrollbar.tsx → OverlayScrollbar.tsx} +49 -13
  37. package/src/components/ProjectTreeView.tsx +3 -2
  38. package/src/components/{PrototypeSheet.stories.tsx → PrototypeContentView.stories.tsx} +4 -5
  39. package/src/components/{PrototypeSheet.tsx → PrototypeContentView.tsx} +58 -58
  40. package/src/components/ScrollArea.stories.tsx +20 -0
  41. package/src/components/ScrollArea.tsx +44 -0
  42. package/src/components/SettingsDialog.tsx +3 -2
  43. package/src/components/{InboxList.stories.tsx → ThreadTree.stories.tsx} +5 -5
  44. package/src/components/{InboxList.tsx → ThreadTree.tsx} +6 -6
  45. package/src/components/ThreadsSidebar.tsx +5 -4
  46. package/src/components/TreePane.tsx +3 -2
  47. package/src/components/WelcomePlayground.stories.tsx +12 -0
  48. package/src/components/WelcomePlayground.test.tsx +47 -0
  49. package/src/components/WelcomePlayground.tsx +140 -0
  50. package/src/components/__snapshots__/stories.test.tsx.snap +163 -283
  51. package/src/components/{diff-sheet-layout.test.ts → diff-content-view-layout.test.ts} +1 -1
  52. package/src/components/{diff-sheet-layout.ts → diff-content-view-layout.ts} +1 -1
  53. package/src/config.test.ts +19 -6
  54. package/src/config.ts +26 -6
  55. package/src/intent-dispatch.test.ts +2 -0
  56. package/src/intent-dispatch.ts +1 -1
  57. package/src/key-bindings.test.ts +1 -1
  58. package/src/key-bindings.ts +5 -1
  59. package/src/keymap.ts +1 -1
  60. package/src/prototype-browser.ts +8 -0
  61. package/src/prototype-pixels.tsx +35 -0
  62. package/src/ready-signal.test.ts +85 -0
  63. package/src/ready-signal.ts +52 -0
  64. package/src/session-controller.live-diff.test.ts +198 -0
  65. package/src/session-controller.ts +141 -26
  66. package/src/slash-palette.test.ts +54 -0
  67. package/src/slash-palette.ts +32 -18
  68. package/src/test-support.ts +42 -3
  69. package/src/theme-presets.ts +20 -0
  70. package/src/theme.ts +18 -0
  71. package/src/thread-chords.ts +1 -1
  72. package/src/use-annotation-surface.tsx +12 -2
  73. package/src/use-settings-dialog.tsx +4 -4
  74. package/src/view-diff.test.ts +44 -1
  75. package/src/view-diff.ts +81 -0
  76. package/src/components/AnnotationCard.compose-rows.test.ts +0 -49
  77. package/src/components/AnnotationCard.stories.tsx +0 -94
  78. package/src/components/AnnotationCard.tsx +0 -250
  79. package/src/components/MarkerPopover.stories.tsx +0 -58
  80. package/src/components/MarkerPopover.tsx +0 -109
  81. package/src/components/WelcomeSurface.stories.tsx +0 -12
  82. package/src/components/WelcomeSurface.tsx +0 -52
@@ -26,7 +26,9 @@ import {
26
26
  returnPaneFor,
27
27
  switchBranch,
28
28
  viewOfPath,
29
+ type Anchor,
29
30
  type Annotation,
31
+ type AnnotationTarget,
30
32
  type DiffFileContents,
31
33
  type ReviewSession,
32
34
  type SessionHistory,
@@ -44,7 +46,7 @@ import {
44
46
  } from "./share";
45
47
  import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
46
48
  import { entryTarget, treeRows, type TreeRow } from "./tree-view";
47
- import { diffRowBlocks, diffRows, fileChangeCounts, type DiffRow } from "./view-diff";
49
+ import { diffRowBlocks, diffRows, fileChangeCounts, fileRowRange, type DiffRow } from "./view-diff";
48
50
  import { applyFold } from "./diff-fold";
49
51
  import { copyToClipboard } from "./clipboard";
50
52
  import {
@@ -60,7 +62,8 @@ import type { FileDiffMetadata } from "@pierre/diffs";
60
62
  import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
61
63
  import { editInEditor } from "./editor";
62
64
  import { focusHerdrPane } from "./herdr";
63
- import { persistAutoClose, type AutoClose, type CueloopConfig } from "./config";
65
+ import { persistAutoClose, type AutoClose, type CueloopConfig, type QuickAction } from "./config";
66
+ import { slashItemsFrom } from "./slash-palette";
64
67
 
65
68
  /**
66
69
  * Post-submit lifecycle (a review pane should hand you back to the agent,
@@ -254,7 +257,17 @@ export interface ReviewController {
254
257
  end: number,
255
258
  body: string,
256
259
  endDisplayIndex?: number,
260
+ /** The surface being annotated; a `file` target anchors into the Changes diff rows. */
261
+ target?: AnnotationTarget,
257
262
  ): string | undefined;
263
+ /** Persist a comment whose anchor a surface already built (e.g. a file-contents view). */
264
+ addComment(anchor: Anchor, target: AnnotationTarget, body: string): string | undefined;
265
+ /**
266
+ * Comment from the bare launch shell, where no thread is open: find-or-create the per-repo
267
+ * workbench thread, open it (the shell becomes a thread view), then add the comment. This is the
268
+ * first write a browse-only launch makes - nothing is on disk until it runs.
269
+ */
270
+ commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void>;
258
271
  /**
259
272
  * Reply to `rootAnnotationId`: the reply shares the root's anchor and names
260
273
  * it in replyTo, so the discussion stays one conversation. Returns the minted id.
@@ -315,6 +328,12 @@ interface DerivedSessionProjection {
315
328
  tree: TreeRow[];
316
329
  }
317
330
 
331
+ /** A live working-tree diff: the same patch + per-file contents a diff capture pins. */
332
+ interface LiveWorkingDiff {
333
+ patch: string;
334
+ files: readonly DiffFileContents[];
335
+ }
336
+
318
337
  class Controller implements ReviewController {
319
338
  readonly readOnly: boolean;
320
339
  private client: SessionClient | null = null;
@@ -331,6 +350,7 @@ class Controller implements ReviewController {
331
350
  };
332
351
  private listeners = new Set<() => void>();
333
352
  private autoClose: AutoClose = "off";
353
+ private quickActions: QuickAction[] = [];
334
354
  private editor: string | undefined;
335
355
  private exporters: BundledExporter[] = [];
336
356
  private readonly clock: Clock;
@@ -339,8 +359,11 @@ class Controller implements ReviewController {
339
359
  private shareReconnect: TimerHandle | undefined;
340
360
  private shareStop: (() => void) | null = null;
341
361
  private shareRun: object | null = null;
342
- /** Projections keyed by session identity so renders reuse one computation. */
362
+ /** Projections keyed by session identity (and the live diff for non-diff threads) so renders reuse one computation. */
343
363
  private derivedFor: ReviewSession | null = null;
364
+ /** The launch/workspace repo's live working-tree diff, feeding the Changes view for non-diff threads. */
365
+ private liveDiff: LiveWorkingDiff | null = null;
366
+ private derivedForLiveDiff: LiveWorkingDiff | null = null;
344
367
  private derived: DerivedSessionProjection = {
345
368
  display: [],
346
369
  rows: [],
@@ -424,6 +447,7 @@ class Controller implements ReviewController {
424
447
 
425
448
  applyConfig(config: CueloopConfig): void {
426
449
  this.autoClose = config.ui.autoClose;
450
+ this.quickActions = config.actions;
427
451
  this.editor = config.ui.editor;
428
452
  void loadBundledExporters(config.integrations).then((exporters) => {
429
453
  this.exporters = exporters;
@@ -446,17 +470,25 @@ class Controller implements ReviewController {
446
470
  // ── derived projections ─────────────────────
447
471
  private ensureDerived(): void {
448
472
  const session = this.snapshot.session;
449
-
450
- if (this.derivedFor === session) return;
473
+ const isDiff = session?.artifact.type === "diff";
474
+ // a diff thread reads its pinned capture; every other thread reflects the live working tree
475
+ const liveDiff = isDiff ? null : this.liveDiff;
476
+
477
+ if (this.derivedFor === session && this.derivedForLiveDiff === liveDiff) return;
478
+ // fold state belongs to the session, so a live-tree refresh keeps it; only a new session resets it
479
+ if (this.derivedFor !== session) {
480
+ this.collapsedFiles.clear();
481
+ this.expandedFiles.clear();
482
+ }
451
483
  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();
455
- const rows =
456
- session && session.artifact.type === "diff" ? diffRows(session.artifact.content) : [];
484
+ this.derivedForLiveDiff = liveDiff;
485
+
486
+ const content = isDiff ? session!.artifact.content : (liveDiff?.patch ?? "");
487
+ const files = isDiff ? (session?.artifact.files ?? []) : (liveDiff?.files ?? []);
488
+ const rows = content ? diffRows(content) : [];
457
489
  const models = new Map<string, FileDiffMetadata>();
458
490
 
459
- for (const file of session?.artifact.files ?? []) models.set(file.path, parseFileDiff(file));
491
+ for (const file of files) models.set(file.path, parseFileDiff(file));
460
492
  this.derived = {
461
493
  display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
462
494
  rows,
@@ -466,6 +498,13 @@ class Controller implements ReviewController {
466
498
  };
467
499
  }
468
500
 
501
+ /** The per-file contents that back the current rows: a diff thread's capture, else the live working tree. */
502
+ private foldFiles(): readonly DiffFileContents[] | undefined {
503
+ const session = this.snapshot.session;
504
+
505
+ return session?.artifact.type === "diff" ? session.artifact.files : this.liveDiff?.files;
506
+ }
507
+
469
508
  treeRows(): TreeRow[] {
470
509
  this.ensureDerived();
471
510
 
@@ -482,12 +521,7 @@ class Controller implements ReviewController {
482
521
  this.ensureDerived();
483
522
  if (this.collapsedFiles.size === 0 && this.expandedFiles.size === 0) return this.derived.rows;
484
523
 
485
- return applyFold(
486
- this.derived.rows,
487
- this.collapsedFiles,
488
- this.expandedFiles,
489
- this.snapshot.session?.artifact.files,
490
- );
524
+ return applyFold(this.derived.rows, this.collapsedFiles, this.expandedFiles, this.foldFiles());
491
525
  }
492
526
 
493
527
  setFileCollapsed(file: string, collapsed: boolean): void {
@@ -519,7 +553,9 @@ class Controller implements ReviewController {
519
553
  }
520
554
 
521
555
  canExpandFile(file: string): boolean {
522
- return (this.snapshot.session?.artifact.files ?? []).some((entry) => entry.path === file);
556
+ // a live working-tree diff carries the same per-file contents as a captured one,
557
+ // so a non-diff thread can expand its files too
558
+ return (this.foldFiles() ?? []).some((entry) => entry.path === file);
523
559
  }
524
560
 
525
561
  copyFilePath(file: string): void {
@@ -581,6 +617,19 @@ class Controller implements ReviewController {
581
617
  // a diff review pins its captured snapshot; every other thread reflects the live working tree
582
618
  const session = this.snapshot.session;
583
619
  if (session?.artifact.type === "diff") return session.artifact.files ?? [];
620
+ // eager: capture the live working-tree diff so the Changes navigator and its file tabs render a real diff
621
+ if (this.client?.repoDiff !== undefined) {
622
+ const diff = await this.client.repoDiff(this.sidebarRepoRoot());
623
+ // a thread switch during the request would let this response overwrite the new
624
+ // thread's diff, showing changes from the wrong repo; drop it when the session moved on
625
+ if (this.snapshot.session !== session) return diff.files;
626
+ this.liveDiff = diff;
627
+ // rows() derive from the fresh patch; re-render so an open diff tab repaints
628
+ this.update({});
629
+
630
+ return diff.files;
631
+ }
632
+ // a peer without the live-diff capability still lists changes, just without a diff body
584
633
  if (this.client?.repoChanges === undefined) return [];
585
634
  const changes = await this.client.repoChanges(this.sidebarRepoRoot());
586
635
 
@@ -947,13 +996,37 @@ class Controller implements ReviewController {
947
996
  end: number,
948
997
  body: string,
949
998
  endDisplayIndex: number = displayIndex,
999
+ target: AnnotationTarget = { kind: "artifact" },
950
1000
  ): string | undefined {
951
1001
  const session = this.snapshot.session;
952
1002
 
953
1003
  if (!session) return undefined;
954
1004
  let anchor;
1005
+ let resolvedTarget = target;
1006
+ // a file target (a Changes-panel diff note) and a diff artifact both anchor into the diff rows
1007
+ const onDiff = target.kind === "file" || session.artifact.type === "diff";
1008
+
1009
+ if (target.kind === "file") {
1010
+ // a Changes-panel note anchors within its own file's rows, so a quote never attaches across
1011
+ // files; a selection that spills into the next file is clamped back to the file it started in
1012
+ const rows = this.rows();
1013
+ const startRow = rows[displayIndex];
1014
+ const path = startRow?.file ?? "";
1015
+ const range = fileRowRange(rows, path);
1016
+ const base = range?.start ?? 0;
1017
+ const fileRows = range ? rows.slice(range.start, range.end) : rows;
1018
+ const lastInFile = fileRows.length - 1;
1019
+ const clamp = (index: number): number => Math.max(0, Math.min(index - base, lastInFile));
955
1020
 
956
- if (session.artifact.type === "diff") {
1021
+ anchor = makeAnchor(
1022
+ diffRowBlocks(fileRows),
1023
+ clamp(displayIndex),
1024
+ start,
1025
+ end,
1026
+ clamp(endDisplayIndex),
1027
+ );
1028
+ resolvedTarget = { kind: "file", path, rev: startRow?.kind === "del" ? "head" : "worktree" };
1029
+ } else if (onDiff) {
957
1030
  // rows are the diff's blocks: a span over one or more code rows anchors with the
958
1031
  // same quote, context, and position selectors a plan span does
959
1032
  anchor = makeAnchor(diffRowBlocks(this.rows()), displayIndex, start, end, endDisplayIndex);
@@ -971,34 +1044,74 @@ class Controller implements ReviewController {
971
1044
  workIndexOf(endDisplayIndex),
972
1045
  );
973
1046
  }
974
- const wire = { id: newAnnotationId(), kind, anchor, body };
1047
+ // absent target means the reviewed artifact, so keep artifact notes free of the field
1048
+ const wire =
1049
+ resolvedTarget.kind === "artifact"
1050
+ ? { id: newAnnotationId(), kind, anchor, body }
1051
+ : { id: newAnnotationId(), kind, anchor, body, target: resolvedTarget };
975
1052
  const persisted = this.client!.sessionAnnotate(session.id, wire);
976
1053
 
977
1054
  this.apply(persisted);
978
1055
  this.mirrorAnnotation(persisted, wire);
979
- this.setStatus("comment added");
980
1056
 
981
1057
  return wire.id;
982
1058
  }
983
1059
 
1060
+ addComment(anchor: Anchor, target: AnnotationTarget, body: string): string | undefined {
1061
+ const session = this.snapshot.session;
1062
+
1063
+ if (!session) return undefined;
1064
+ const wire =
1065
+ target.kind === "artifact"
1066
+ ? { id: newAnnotationId(), kind: "comment", anchor, body }
1067
+ : { id: newAnnotationId(), kind: "comment", anchor, body, target };
1068
+ const persisted = this.client!.sessionAnnotate(session.id, wire);
1069
+
1070
+ this.apply(persisted);
1071
+ this.mirrorAnnotation(persisted, wire);
1072
+
1073
+ return wire.id;
1074
+ }
1075
+
1076
+ async commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void> {
1077
+ if (!this.snapshot.session) {
1078
+ if (this.client?.sessionWorkbench === undefined) return;
1079
+ let workbench;
1080
+
1081
+ try {
1082
+ workbench = await this.client.sessionWorkbench(this.options.cwd ?? process.cwd());
1083
+ } catch (cause) {
1084
+ // the composer already closed, so a lost first comment must at least surface, not vanish
1085
+ this.setStatus(String(cause instanceof Error ? cause.message : cause));
1086
+
1087
+ return;
1088
+ }
1089
+ // adopt the thread as active (the shell flips to the thread view) and let the inbox catch up
1090
+ this.update({ session: workbench });
1091
+ void this.refreshInbox();
1092
+ }
1093
+ this.addComment(anchor, target, body);
1094
+ }
1095
+
984
1096
  reply(rootAnnotationId: string, body: string): string | undefined {
985
1097
  const session = this.snapshot.session;
986
1098
  const root = session?.annotations.find((annotation) => annotation.id === rootAnnotationId);
987
1099
 
988
1100
  if (!session || !root) return undefined;
989
- // a reply to a reply still hangs off the discussion's root comment
990
- const wire = {
1101
+ // a reply to a reply still hangs off the discussion's root comment, and shares its target
1102
+ // so it renders and resolves on the same surface
1103
+ const base = {
991
1104
  id: newAnnotationId(),
992
1105
  kind: "comment",
993
1106
  anchor: root.anchor,
994
1107
  body,
995
1108
  replyTo: root.replyTo ?? root.id,
996
1109
  };
1110
+ const wire = root.target ? { ...base, target: root.target } : base;
997
1111
  const persisted = this.client!.sessionAnnotate(session.id, wire);
998
1112
 
999
1113
  this.apply(persisted);
1000
1114
  this.mirrorAnnotation(persisted, wire);
1001
- this.setStatus("reply added");
1002
1115
 
1003
1116
  return wire.id;
1004
1117
  }
@@ -1013,7 +1126,6 @@ class Controller implements ReviewController {
1013
1126
 
1014
1127
  this.apply(persisted);
1015
1128
  this.mirrorAnnotation(persisted, wire);
1016
- this.setStatus("comment added");
1017
1129
 
1018
1130
  return wire.id;
1019
1131
  }
@@ -1125,7 +1237,10 @@ class Controller implements ReviewController {
1125
1237
 
1126
1238
  if (!session) return;
1127
1239
  this.walkLeave();
1128
- this.client!.sessionResolve(session.id, verdict, summary)
1240
+ const actionBodies = Object.fromEntries(
1241
+ slashItemsFrom(this.quickActions).map((item) => [item.name, item.body]),
1242
+ );
1243
+ this.client!.sessionResolve(session.id, verdict, summary, actionBodies)
1129
1244
  .then((resolved) => {
1130
1245
  // The completion overlay heading already states the verdict, so the
1131
1246
  // status line stays empty here - only export/error messages fill it.
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { inlineSlashToken, scoreMatch, slashFilter, type SlashItem } from "./slash-palette";
3
+
4
+ function item(name: string): SlashItem {
5
+ return { name, description: name, body: name };
6
+ }
7
+
8
+ const ITEMS: SlashItem[] = [
9
+ item("zoom-out-research-in-depth"),
10
+ item("restate-simplified"),
11
+ item("out-of-scope"),
12
+ item("lgtm"),
13
+ ];
14
+
15
+ describe("scoreMatch", () => {
16
+ test("an exact name scores highest, then a prefix, then a subsequence", () => {
17
+ const exact = scoreMatch("lgtm", "lgtm")!;
18
+ const prefix = scoreMatch("lgtm-please", "lgtm")!;
19
+ const subsequence = scoreMatch("looking-good-to-me", "lgtm")!;
20
+
21
+ expect(exact).toBeGreaterThan(prefix);
22
+ expect(prefix).toBeGreaterThan(subsequence);
23
+ });
24
+
25
+ test("a query whose characters are not all present, in order, does not match", () => {
26
+ expect(scoreMatch("restate", "zzz")).toBeNull();
27
+ expect(scoreMatch("abc", "acb")).toBeNull();
28
+ });
29
+
30
+ test("an empty query matches everything", () => {
31
+ expect(scoreMatch("anything", "")).toBe(0);
32
+ });
33
+ });
34
+
35
+ describe("slashFilter", () => {
36
+ test("ranks a prefix match above a mere subsequence match", () => {
37
+ const ranked = slashFilter(ITEMS, "out");
38
+
39
+ expect(ranked[0]!.name).toBe("out-of-scope");
40
+ expect(ranked.map((entry) => entry.name)).toContain("zoom-out-research-in-depth");
41
+ });
42
+
43
+ test("drops items that do not match and returns all on an empty query", () => {
44
+ expect(slashFilter(ITEMS, "zzzz")).toEqual([]);
45
+ expect(slashFilter(ITEMS, "")).toHaveLength(ITEMS.length);
46
+ });
47
+ });
48
+
49
+ describe("inlineSlashToken", () => {
50
+ test("finds a trailing /word only when text precedes it", () => {
51
+ expect(inlineSlashToken("use /vitest-patt")).toBe("/vitest-patt");
52
+ expect(inlineSlashToken("/vitest-patt")).toBeNull();
53
+ });
54
+ });
@@ -23,28 +23,42 @@ export function slashItemsFrom(quickActions: QuickAction[]): SlashItem[] {
23
23
  }));
24
24
  }
25
25
 
26
- /** Prefix beats substring beats subsequence. */
27
- export function slashFilter(items: SlashItem[], query: string): SlashItem[] {
28
- const needle = query.toLowerCase();
26
+ const WORD_BOUNDARY = /[\s\-_./:]/;
27
+
28
+ /** Order-preserving subsequence score, or null when a query character is missing; higher is better. */
29
+ export function scoreMatch(name: string, query: string): number | null {
30
+ if (query.length === 0) return 0;
31
+ const lowerName = name.toLowerCase();
32
+ const lowerQuery = query.toLowerCase();
33
+
34
+ if (lowerName === lowerQuery) return 1000;
35
+ if (lowerName.startsWith(lowerQuery)) return 500 - name.length;
36
+ let score = 0;
37
+ let cursor = 0;
38
+ let run = 0;
39
+
40
+ for (const character of lowerQuery) {
41
+ const found = lowerName.indexOf(character, cursor);
42
+
43
+ if (found === -1) return null;
44
+ run = found === cursor ? run + 1 : 0;
45
+ score += found === cursor ? 5 + run : 1;
46
+ if (found === 0 || WORD_BOUNDARY.test(lowerName[found - 1]!)) score += 10;
47
+ score -= found - cursor;
48
+ cursor = found + 1;
49
+ }
50
+
51
+ return score;
52
+ }
29
53
 
30
- if (needle.length === 0) return items;
54
+ export function slashFilter(items: SlashItem[], query: string): SlashItem[] {
55
+ if (query.length === 0) return items;
31
56
  const scored: Array<{ item: SlashItem; score: number }> = [];
32
57
 
33
58
  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 });
59
+ const score = scoreMatch(item.name, query);
60
+
61
+ if (score !== null) scored.push({ item, score });
48
62
  }
49
63
 
50
64
  return scored.toSorted((left, right) => right.score - left.score).map((entry) => entry.item);
@@ -7,10 +7,49 @@
7
7
  */
8
8
 
9
9
  import { join } from "node:path";
10
- import type { TestRendererSetup } from "@opentui/core/testing";
10
+ import { cloneElement, isValidElement, type ReactNode } from "react";
11
+ import type { TestRendererOptions, TestRendererSetup } from "@opentui/core/testing";
12
+ import { testRender } from "@opentui/react/test-utils";
13
+ import type { AppProps } from "./App";
11
14
 
12
- /** Pass budget for waits that include daemon or subprocess round-trips. */
13
- export const WAIT_PASSES = { maxPasses: 400 };
15
+ /**
16
+ * A probe for the App's ready signal in an in-process suite: pass `onReady`
17
+ * to the App and await `ready` before the first interaction, instead of
18
+ * waiting for a piece of text and hoping the keyboard is bound by then.
19
+ */
20
+ export interface AppReadyProbe {
21
+ /** Pass as the App's `onReady` prop. */
22
+ onReady: () => void;
23
+ /** Resolves once the App has fired its ready signal. */
24
+ ready: Promise<void>;
25
+ }
26
+
27
+ export function appReadyProbe(): AppReadyProbe {
28
+ let resolve: () => void = () => {};
29
+ const ready = new Promise<void>((done) => {
30
+ resolve = done;
31
+ });
32
+
33
+ return { onReady: () => resolve(), ready };
34
+ }
35
+
36
+ /**
37
+ * Boot an App element and resolve once it has fired its ready signal: the
38
+ * first usable screen is painted and its keyboard handlers are subscribed.
39
+ * Every App suite starts here so no boot waits on a piece of text and hopes.
40
+ */
41
+ export async function renderReadyApp(
42
+ element: ReactNode,
43
+ options: TestRendererOptions,
44
+ ): Promise<TestRendererSetup> {
45
+ if (!isValidElement<AppProps>(element)) throw new Error("renderReadyApp needs an App element");
46
+ const probe = appReadyProbe();
47
+ const setup = await testRender(cloneElement(element, { onReady: probe.onReady }), options);
48
+
49
+ await probe.ready;
50
+
51
+ return setup;
52
+ }
14
53
 
15
54
  /**
16
55
  * The CUELOOP_CONFIG path that isolates a test from the developer's real user
@@ -50,6 +50,10 @@ const ROSE_PINE_MOON: Theme = {
50
50
  markCommentBackground: "#382a37",
51
51
  insertedForeground: "#9ccfd8",
52
52
  deletedForeground: "#eb6f92",
53
+ insertedBackground: "#243a3f",
54
+ insertedEmphasisBackground: "#31555c",
55
+ deletedBackground: "#3a2530",
56
+ deletedEmphasisBackground: "#563643",
53
57
  backdrop: "transparent",
54
58
  };
55
59
 
@@ -71,6 +75,10 @@ const CATPPUCCIN_MOCHA: Theme = {
71
75
  markCommentBackground: "#33222b",
72
76
  insertedForeground: "#a6e3a1",
73
77
  deletedForeground: "#f38ba8",
78
+ insertedBackground: "#26372b",
79
+ insertedEmphasisBackground: "#375140",
80
+ deletedBackground: "#382530",
81
+ deletedEmphasisBackground: "#553844",
74
82
  backdrop: "transparent",
75
83
  };
76
84
 
@@ -92,6 +100,10 @@ const TOKYO_NIGHT: Theme = {
92
100
  markCommentBackground: "#2f2230",
93
101
  insertedForeground: "#9ece6a",
94
102
  deletedForeground: "#f7768e",
103
+ insertedBackground: "#233028",
104
+ insertedEmphasisBackground: "#33493a",
105
+ deletedBackground: "#32232e",
106
+ deletedEmphasisBackground: "#4d3543",
95
107
  backdrop: "transparent",
96
108
  };
97
109
 
@@ -113,6 +125,10 @@ const GRUVBOX_DARK: Theme = {
113
125
  markCommentBackground: "#3a2823",
114
126
  insertedForeground: "#b8bb26",
115
127
  deletedForeground: "#fb4934",
128
+ insertedBackground: "#333521",
129
+ insertedEmphasisBackground: "#4a4d2c",
130
+ deletedBackground: "#3c2a24",
131
+ deletedEmphasisBackground: "#5a3a30",
116
132
  backdrop: "transparent",
117
133
  };
118
134
 
@@ -134,6 +150,10 @@ const NORD: Theme = {
134
150
  markCommentBackground: "#3a2f33",
135
151
  insertedForeground: "#a3be8c",
136
152
  deletedForeground: "#bf616a",
153
+ insertedBackground: "#333d33",
154
+ insertedEmphasisBackground: "#454f42",
155
+ deletedBackground: "#3c2f33",
156
+ deletedEmphasisBackground: "#544045",
137
157
  backdrop: "transparent",
138
158
  };
139
159
 
package/src/theme.ts CHANGED
@@ -22,6 +22,12 @@ export interface Theme {
22
22
  markCommentBackground: string;
23
23
  insertedForeground: string;
24
24
  deletedForeground: string;
25
+ /** The band behind a whole added line, and the stronger tint under its intra-line changed words. */
26
+ insertedBackground: string;
27
+ insertedEmphasisBackground: string;
28
+ /** The band behind a whole removed line, and the stronger tint under its intra-line changed words. */
29
+ deletedBackground: string;
30
+ deletedEmphasisBackground: string;
25
31
  /** Layer behind centered dialogs; transparent keeps the session visible. */
26
32
  backdrop: string;
27
33
  }
@@ -42,6 +48,10 @@ export function dimmedTheme(theme: Theme): Theme {
42
48
  blue: theme.textDim,
43
49
  insertedForeground: theme.textDim,
44
50
  deletedForeground: theme.textDim,
51
+ insertedBackground: theme.panel,
52
+ insertedEmphasisBackground: theme.elevated,
53
+ deletedBackground: theme.panel,
54
+ deletedEmphasisBackground: theme.elevated,
45
55
  };
46
56
  }
47
57
 
@@ -63,6 +73,10 @@ export const DARK: Theme = {
63
73
  markCommentBackground: "#3d2a2e",
64
74
  insertedForeground: "#62d96b",
65
75
  deletedForeground: "#ff6b6b",
76
+ insertedBackground: "#16321d",
77
+ insertedEmphasisBackground: "#245c30",
78
+ deletedBackground: "#341c1f",
79
+ deletedEmphasisBackground: "#5a2a2e",
66
80
  backdrop: "transparent",
67
81
  };
68
82
 
@@ -89,5 +103,9 @@ export const LIGHT: Theme = {
89
103
  markCommentBackground: "#f2e2e8",
90
104
  insertedForeground: "#2f8a3e",
91
105
  deletedForeground: "#c0392b",
106
+ insertedBackground: "#d8f5dd",
107
+ insertedEmphasisBackground: "#a9e6b6",
108
+ deletedBackground: "#fbdcdc",
109
+ deletedEmphasisBackground: "#f5b7b7",
92
110
  backdrop: "transparent",
93
111
  };
@@ -33,7 +33,7 @@ export const DIFF_CHORD_ENTRIES = [
33
33
  { keys: "⌥x / ⌥X", label: "reject the change / the hunk" },
34
34
  { keys: "⌥u", label: "restore the last rejection" },
35
35
  { keys: "⌥c", label: "collapse the file to its band" },
36
- { keys: "⌥d", label: "unified / split (when zoomed)" },
36
+ { keys: "⌥d", label: "split / stacked (when wide)" },
37
37
  { keys: "⌥k", label: "start the guided walk" },
38
38
  ] as const;
39
39
 
@@ -73,6 +73,12 @@ export interface AnnotationSurfaceOptions {
73
73
  quickActions: QuickAction[];
74
74
  tokens: Theme;
75
75
  observer: boolean;
76
+ /**
77
+ * Whether this surface accepts comments. False for a non-diff thread's live
78
+ * working-tree diff, which is view-only: its rows are diff rows, but the
79
+ * thread anchors comments in plan coordinates, so a draft here would misanchor.
80
+ */
81
+ commentsEnabled?: boolean;
76
82
  /** A verdict is in: no draft may open; the app answers with its read-only status. */
77
83
  resolved: boolean;
78
84
  /** True while a menu, dialog, or overlay owns the keyboard. */
@@ -152,6 +158,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
152
158
  quickActions,
153
159
  tokens,
154
160
  observer,
161
+ commentsEnabled = true,
155
162
  resolved,
156
163
  suspended,
157
164
  onComposingChange,
@@ -283,6 +290,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
283
290
  }));
284
291
 
285
292
  const openCompose = (state: ComposeState): void => {
293
+ // a view-only surface (a non-diff thread's live diff) never opens a draft
294
+ if (!commentsEnabled) return;
286
295
  if (observer) return onObserverBlocked?.("observer");
287
296
  if (resolved) return onObserverBlocked?.("resolved");
288
297
  composerReady.current = false;
@@ -296,7 +305,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
296
305
  setCompose(null);
297
306
  setComposeText("");
298
307
  };
299
- const slashActive = compose !== null && composeText.startsWith("/");
308
+ // open only while typing the token: a leading "/" with no space yet
309
+ const slashActive = compose !== null && /^\/\S*$/.test(composeText);
300
310
  const slashItems = slashActive
301
311
  ? slashFilter(slashItemsFrom(quickActions), composeText.slice(1).trim())
302
312
  : [];
@@ -417,7 +427,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
417
427
  return true;
418
428
  }
419
429
  if (key.name === "return" || key.name === "tab") {
420
- openCompose({ ...activeCompose, seed: slashItems[selected]!.body });
430
+ openCompose({ ...activeCompose, seed: `/${slashItems[selected]!.name} ` });
421
431
 
422
432
  return true;
423
433
  }
@@ -142,9 +142,9 @@ export function useSettingsDialog(params: {
142
142
  },
143
143
  {
144
144
  key: "diffView",
145
- label: "Diff view (when zoomed)",
145
+ label: "Diff view (when wide)",
146
146
  kind: "cycle",
147
- options: ["Unified", "Split"],
147
+ options: ["Split", "Stacked"],
148
148
  },
149
149
  ],
150
150
  },
@@ -191,7 +191,7 @@ export function useSettingsDialog(params: {
191
191
  ];
192
192
  const settingsValues = {
193
193
  autoClose: autoClose === "off" ? "off" : `${autoClose}s`,
194
- diffView: diffView === "split" ? "Split" : "Unified",
194
+ diffView: diffView === "split" ? "Split" : "Stacked",
195
195
  theme: THEME_LABELS[themeName],
196
196
  };
197
197
  const cycleSetting = (rowKey: string): void => {
@@ -201,7 +201,7 @@ export function useSettingsDialog(params: {
201
201
  setAutoClose(next);
202
202
  persistAutoClose(next);
203
203
  } else if (rowKey === "diffView") {
204
- const next: DiffViewMode = diffView === "unified" ? "split" : "unified";
204
+ const next: DiffViewMode = diffView === "stacked" ? "split" : "stacked";
205
205
 
206
206
  setDiffView(next);
207
207
  persistDiffView(next);