@cueloop/client 0.1.0-alpha.66 → 0.1.0-alpha.67
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 +6 -5
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.diff.test.tsx +71 -39
- package/src/App.plan-review.test.tsx +20 -21
- package/src/App.readonly.test.tsx +7 -3
- package/src/App.test.tsx +73 -14
- package/src/App.tsx +375 -129
- package/src/App.walk.test.tsx +14 -15
- package/src/App.workbench.test.tsx +390 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/annotation-palette.ts +61 -0
- package/src/app-screens.tsx +176 -45
- package/src/app-view-model.ts +7 -65
- package/src/components/AnnotationCard.tsx +1 -1
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +47 -42
- package/src/components/AppShell.tsx +212 -100
- package/src/components/AppShell.zoom.test.tsx +94 -0
- package/src/components/ChangesColumn.tsx +49 -26
- package/src/components/DiffSheet.scroll.test.tsx +38 -55
- package/src/components/DiffSheet.split.test.tsx +140 -0
- package/src/components/DiffSheet.stories.tsx +55 -53
- package/src/components/DiffSheet.syntax.test.tsx +24 -7
- package/src/components/DiffSheet.tsx +607 -276
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +105 -0
- package/src/components/EditorGrid.tsx +338 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +78 -0
- package/src/components/InboxList.tsx +159 -30
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +82 -0
- package/src/components/PromptDialog.tsx +8 -1
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/SettingsDialog.tsx +2 -1
- package/src/components/SurfaceScrollbar.stories.tsx +34 -0
- package/src/components/SurfaceScrollbar.test.tsx +111 -0
- package/src/components/SurfaceScrollbar.tsx +93 -0
- package/src/components/ThreadFooter.stories.tsx +2 -2
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +12 -7
- package/src/components/ThreadView.tsx +62 -1440
- package/src/components/ThreadsSidebar.tsx +14 -1
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/WelcomeSurface.stories.tsx +12 -0
- package/src/components/WelcomeSurface.tsx +52 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +509 -559
- package/src/components/diff-sheet-layout.test.ts +125 -55
- package/src/components/diff-sheet-layout.ts +3 -103
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +15 -0
- package/src/components/file-tree.ts +59 -4
- package/src/components/pages/Workspace.stories.tsx +2 -2
- package/src/components/primitives/IconButton.tsx +21 -2
- package/src/components/primitives/Tree.tsx +13 -4
- package/src/components/primitives/icons.ts +27 -4
- package/src/components/session-tree.test.ts +20 -0
- package/src/components/session-tree.ts +16 -2
- package/src/components/stories-app.tsx +2 -2
- package/src/config.test.ts +20 -49
- package/src/config.ts +26 -28
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-syntax.test.ts +16 -0
- package/src/diff-syntax.ts +6 -3
- package/src/discussions.ts +79 -0
- package/src/intent-dispatch.test.ts +31 -11
- package/src/intent-dispatch.ts +93 -48
- package/src/key-bindings.test.ts +1 -1
- package/src/keymap.test.ts +3 -25
- package/src/keymap.ts +17 -18
- package/src/mark-runs.ts +120 -0
- package/src/pty.ts +10 -2
- package/src/run.ts +17 -7
- package/src/serve.test.ts +4 -8
- package/src/session-controller.curate.test.ts +75 -0
- package/src/session-controller.share.test.ts +1 -0
- package/src/session-controller.tree.test.ts +26 -0
- package/src/session-controller.ts +153 -29
- package/src/slash-palette.ts +83 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +3 -3
- package/src/test-support.ts +39 -8
- package/src/thread-chords.test.ts +25 -9
- package/src/thread-chords.ts +33 -8
- package/src/use-annotation-surface.tsx +837 -0
- package/src/use-changes-workbench.ts +151 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +14 -24
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/view-diff.ts +114 -0
- package/src/components/ReviewPanel.stories.tsx +0 -100
- package/src/components/ReviewPanel.tsx +0 -103
- package/src/components/ReviewRail.stories.tsx +0 -232
- package/src/components/ReviewRail.tsx +0 -327
- package/src/review-panel.test.ts +0 -113
- package/src/review-panel.ts +0 -83
|
@@ -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 {
|
|
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
|
|
375
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/test-support.test.ts
CHANGED
|
@@ -25,7 +25,7 @@ describe("isolateUserConfig", () => {
|
|
|
25
25
|
const home = mkdtempSync(join(tmpdir(), "cueloop-isolate-"));
|
|
26
26
|
const userConfigPath = join(home, "config.toml");
|
|
27
27
|
|
|
28
|
-
writeFileSync(userConfigPath,
|
|
28
|
+
writeFileSync(userConfigPath, "[ui]\nauto_close = 5\n");
|
|
29
29
|
const priorEnv = process.env.CUELOOP_CONFIG;
|
|
30
30
|
|
|
31
31
|
process.env.CUELOOP_CONFIG = userConfigPath;
|
|
@@ -39,9 +39,9 @@ describe("isolateUserConfig", () => {
|
|
|
39
39
|
const restored = loadConfig({ repoRoot: home });
|
|
40
40
|
|
|
41
41
|
// Assert
|
|
42
|
-
expect(isolated.ui.
|
|
42
|
+
expect(isolated.ui.autoClose).toBe("off");
|
|
43
43
|
expect(process.env.CUELOOP_CONFIG).toBe(userConfigPath);
|
|
44
|
-
expect(restored.ui.
|
|
44
|
+
expect(restored.ui.autoClose).toBe(5);
|
|
45
45
|
} finally {
|
|
46
46
|
if (priorEnv === undefined) delete process.env.CUELOOP_CONFIG;
|
|
47
47
|
else process.env.CUELOOP_CONFIG = priorEnv;
|