@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.
- package/package.json +7 -6
- package/src/App.appearance.test.tsx +8 -6
- package/src/App.diff.test.tsx +3 -3
- package/src/App.export.test.tsx +9 -3
- package/src/App.plan-review.test.tsx +10 -8
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +17 -28
- package/src/App.readonly.test.tsx +3 -3
- package/src/App.rendering.test.tsx +3 -4
- package/src/App.test.tsx +14 -18
- package/src/App.tsx +117 -42
- package/src/App.walk.test.tsx +2 -2
- package/src/App.workbench.test.tsx +85 -14
- package/src/app-screens.tsx +26 -10
- package/src/app-view-model.ts +17 -3
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.tsx +6 -6
- package/src/components/AppShell.tsx +62 -15
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +69 -0
- package/src/components/ChangesColumn.tsx +3 -2
- package/src/components/{DiffSheet.scroll.test.tsx → DiffContentView.scroll.test.tsx} +2 -2
- package/src/components/{DiffSheet.split.test.tsx → DiffContentView.split.test.tsx} +3 -3
- package/src/components/{DiffSheet.stories.tsx → DiffContentView.stories.tsx} +3 -3
- package/src/components/{DiffSheet.syntax.test.tsx → DiffContentView.syntax.test.tsx} +2 -2
- package/src/components/{DiffSheet.tsx → DiffContentView.tsx} +187 -28
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tabs.test.tsx +17 -4
- package/src/components/EditorGrid.tsx +49 -19
- package/src/components/FileContentsView.test.tsx +3 -3
- package/src/components/FileContentsView.tsx +37 -17
- package/src/components/{SurfaceScrollbar.stories.tsx → OverlayScrollbar.stories.tsx} +5 -6
- package/src/components/{SurfaceScrollbar.test.tsx → OverlayScrollbar.test.tsx} +41 -13
- package/src/components/{SurfaceScrollbar.tsx → OverlayScrollbar.tsx} +49 -13
- package/src/components/ProjectTreeView.tsx +3 -2
- package/src/components/{PrototypeSheet.stories.tsx → PrototypeContentView.stories.tsx} +4 -5
- package/src/components/{PrototypeSheet.tsx → PrototypeContentView.tsx} +58 -58
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.tsx +3 -2
- package/src/components/{InboxList.stories.tsx → ThreadTree.stories.tsx} +5 -5
- package/src/components/{InboxList.tsx → ThreadTree.tsx} +6 -6
- package/src/components/ThreadsSidebar.tsx +5 -4
- package/src/components/TreePane.tsx +3 -2
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +163 -283
- package/src/components/{diff-sheet-layout.test.ts → diff-content-view-layout.test.ts} +1 -1
- package/src/components/{diff-sheet-layout.ts → diff-content-view-layout.ts} +1 -1
- package/src/config.test.ts +19 -6
- package/src/config.ts +26 -6
- package/src/intent-dispatch.test.ts +2 -0
- package/src/intent-dispatch.ts +1 -1
- package/src/key-bindings.test.ts +1 -1
- package/src/key-bindings.ts +5 -1
- package/src/keymap.ts +1 -1
- package/src/prototype-browser.ts +8 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/session-controller.live-diff.test.ts +198 -0
- package/src/session-controller.ts +141 -26
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +32 -18
- package/src/test-support.ts +42 -3
- package/src/theme-presets.ts +20 -0
- package/src/theme.ts +18 -0
- package/src/thread-chords.ts +1 -1
- package/src/use-annotation-surface.tsx +12 -2
- package/src/use-settings-dialog.tsx +4 -4
- package/src/view-diff.test.ts +44 -1
- package/src/view-diff.ts +81 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +0 -49
- package/src/components/AnnotationCard.stories.tsx +0 -94
- package/src/components/AnnotationCard.tsx +0 -250
- package/src/components/MarkerPopover.stories.tsx +0 -58
- package/src/components/MarkerPopover.tsx +0 -109
- package/src/components/WelcomeSurface.stories.tsx +0 -12
- 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
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/slash-palette.ts
CHANGED
|
@@ -23,28 +23,42 @@ export function slashItemsFrom(quickActions: QuickAction[]): SlashItem[] {
|
|
|
23
23
|
}));
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
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);
|
package/src/test-support.ts
CHANGED
|
@@ -7,10 +7,49 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
-
import type
|
|
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
|
-
/**
|
|
13
|
-
|
|
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
|
package/src/theme-presets.ts
CHANGED
|
@@ -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
|
};
|
package/src/thread-chords.ts
CHANGED
|
@@ -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: "
|
|
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
|
-
|
|
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]!.
|
|
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
|
|
145
|
+
label: "Diff view (when wide)",
|
|
146
146
|
kind: "cycle",
|
|
147
|
-
options: ["
|
|
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" : "
|
|
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 === "
|
|
204
|
+
const next: DiffViewMode = diffView === "stacked" ? "split" : "stacked";
|
|
205
205
|
|
|
206
206
|
setDiffView(next);
|
|
207
207
|
persistDiffView(next);
|