@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.90
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/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +42 -34
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +77 -51
- package/src/App.diff.test.tsx +259 -45
- package/src/App.export.test.tsx +58 -41
- package/src/App.focus.test.ts +89 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +330 -161
- package/src/App.prototype-markdown.test.tsx +128 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +129 -67
- package/src/App.regression.test.tsx +94 -0
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +264 -0
- package/src/App.test.tsx +402 -126
- package/src/App.toast.test.tsx +98 -0
- package/src/App.tsx +1402 -1060
- package/src/App.visual.test.tsx +57 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/App.workbench.test.tsx +774 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +62 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +577 -0
- package/src/app-view-model.ts +168 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/client-extension-registry.test.tsx +61 -0
- package/src/client-extension-registry.ts +159 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +134 -0
- package/src/components/AnnotationCards.stories.tsx +86 -0
- package/src/components/AnnotationCards.tsx +341 -0
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.brand.test.tsx +58 -0
- package/src/components/AppShell.extensions.test.tsx +191 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +362 -0
- package/src/components/AppShell.zoom.test.tsx +135 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +44 -0
- package/src/components/ChangesColumn.tsx +199 -0
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +37 -0
- package/src/components/CompletionOverlay.tsx +77 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +150 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +72 -0
- package/src/components/DiffContentView.scroll.test.tsx +149 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +906 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +186 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +426 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/ExtensionZone.stories.tsx +28 -0
- package/src/components/ExtensionZone.tsx +145 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/GridTabContent.stories.tsx +59 -0
- package/src/components/GridTabContent.tsx +182 -0
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- 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 +98 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +75 -0
- package/src/components/ProjectTreeView.tsx +125 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +99 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +45 -0
- package/src/components/RootOverlay.tsx +48 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +186 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +47 -0
- package/src/components/ThreadFooter.tsx +76 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +255 -0
- package/src/components/ThreadView.selection.test.tsx +951 -0
- package/src/components/ThreadView.slash.test.tsx +146 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +525 -0
- package/src/components/Toast.stories.tsx +19 -0
- package/src/components/Toast.tsx +52 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +150 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +49 -0
- package/src/components/WelcomePlayground.tsx +139 -0
- package/src/components/WorkspacePanels.stories.tsx +36 -0
- package/src/components/WorkspacePanels.tsx +116 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +59 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +63 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +71 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +100 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +17 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +58 -0
- package/src/components/quick-actions-editor.test.tsx +117 -0
- package/src/components/quick-actions-editor.tsx +226 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +193 -0
- package/src/components/stories.test.tsx +74 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +108 -0
- package/src/components/story.ts +159 -0
- package/src/components/syntax-highlight.ts +133 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/thread-tree.test.ts +134 -0
- package/src/components/thread-tree.ts +109 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/components/truncate-title.test.ts +31 -0
- package/src/components/truncate-title.ts +15 -0
- package/src/config-identity.test.ts +78 -0
- package/src/config.test.ts +579 -34
- package/src/config.ts +533 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +238 -0
- package/src/diff-intraline.ts +236 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +28 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +724 -0
- package/src/intent-dispatch.ts +690 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +459 -0
- package/src/keymap.test.ts +456 -115
- package/src/keymap.ts +315 -81
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/launch-layout.test.ts +67 -0
- package/src/launch-layout.ts +47 -0
- package/src/mark-runs.ts +121 -0
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/prototype-browser.test.ts +81 -0
- package/src/prototype-browser.ts +329 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +85 -12
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +52 -10
- package/src/share.ts +237 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +75 -0
- package/src/slash-palette.test.ts +103 -0
- package/src/slash-palette.ts +129 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +369 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +219 -0
- package/src/theme.ts +88 -19
- package/src/thread-chords.test.ts +79 -0
- package/src/thread-chords.ts +163 -0
- package/src/thread-code-syntax.ts +45 -0
- package/src/thread-controller.curate.test.ts +476 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +322 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +561 -0
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +261 -0
- package/src/thread-controller.ts +2050 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +153 -0
- package/src/use-annotation-surface.tsx +975 -0
- package/src/use-changes-workbench.ts +190 -0
- package/src/use-frame-measure.ts +41 -0
- package/src/use-remember-layout.ts +31 -0
- package/src/use-repo-changes.ts +87 -0
- package/src/use-settings-dialog.tsx +364 -0
- package/src/use-terminal-virtualizer.ts +259 -0
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +279 -9
- package/src/view-diff.ts +460 -24
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +545 -0
- package/src/view-plan.ts +1019 -0
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/workbench-snapshot.test.ts +99 -0
- package/src/workbench-snapshot.ts +59 -0
- package/src/session-controller.ts +0 -398
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -364
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntax highlighting for diff rows: tree-sitter token colors mapped onto the
|
|
3
|
+
* diff sheet's hand-rolled rows, so annotation cards still interleave. A hunk is
|
|
4
|
+
* highlighted as a contiguous fragment (its context+addition lines as the new
|
|
5
|
+
* side, context+deletion lines as the old side) - never per single line, which
|
|
6
|
+
* would lose the multi-line grammar context - and the resulting token spans are
|
|
7
|
+
* mapped back to each row in line-relative columns. Async: the shared tree-sitter
|
|
8
|
+
* client resolves off the render path, so callers draw unstyled first.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getTreeSitterClient, type SimpleHighlight } from "@opentui/core";
|
|
12
|
+
import type { DiffRow } from "./view-diff";
|
|
13
|
+
import { filetypeForPath } from "./components/syntax-highlight";
|
|
14
|
+
|
|
15
|
+
/** A syntax-colored run within one row, in line-relative columns [start, end). */
|
|
16
|
+
export interface SyntaxSpan {
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
group: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stripTrailingNewline(text: string): string {
|
|
23
|
+
return text.replace(/\n$/, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface HighlightJob {
|
|
27
|
+
filetype: string;
|
|
28
|
+
source: string;
|
|
29
|
+
/** Row index for each line of `source`, in order. */
|
|
30
|
+
rowIndexByLine: number[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Split diff rows into per-hunk highlight jobs, one per side. A hunk's rows are
|
|
35
|
+
* contiguous file lines, so each side reconstructs to valid code; file/hunk
|
|
36
|
+
* header rows and files without a known filetype are skipped.
|
|
37
|
+
*/
|
|
38
|
+
export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
|
|
39
|
+
const jobs: HighlightJob[] = [];
|
|
40
|
+
let oldLines: string[] = [];
|
|
41
|
+
let newLines: string[] = [];
|
|
42
|
+
let oldRowIndexByLine: number[] = [];
|
|
43
|
+
let newRowIndexByLine: number[] = [];
|
|
44
|
+
let filetype: string | undefined;
|
|
45
|
+
|
|
46
|
+
const flush = (): void => {
|
|
47
|
+
if (filetype) {
|
|
48
|
+
if (oldLines.length)
|
|
49
|
+
jobs.push({ filetype, source: oldLines.join("\n"), rowIndexByLine: oldRowIndexByLine });
|
|
50
|
+
if (newLines.length)
|
|
51
|
+
jobs.push({ filetype, source: newLines.join("\n"), rowIndexByLine: newRowIndexByLine });
|
|
52
|
+
}
|
|
53
|
+
oldLines = [];
|
|
54
|
+
newLines = [];
|
|
55
|
+
oldRowIndexByLine = [];
|
|
56
|
+
newRowIndexByLine = [];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
|
60
|
+
const row = rows[rowIndex]!;
|
|
61
|
+
|
|
62
|
+
if (row.kind === "file" || row.kind === "hunk") {
|
|
63
|
+
flush();
|
|
64
|
+
filetype = row.kind === "file" ? filetypeForPath(row.file) : filetype;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const text = stripTrailingNewline(row.text);
|
|
68
|
+
|
|
69
|
+
if (row.kind === "ctx" || row.kind === "del") {
|
|
70
|
+
oldLines.push(text);
|
|
71
|
+
oldRowIndexByLine.push(rowIndex);
|
|
72
|
+
}
|
|
73
|
+
if (row.kind === "ctx" || row.kind === "add") {
|
|
74
|
+
newLines.push(text);
|
|
75
|
+
newRowIndexByLine.push(rowIndex);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
flush();
|
|
79
|
+
|
|
80
|
+
return jobs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve one source's flat, possibly-overlapping token highlights into
|
|
85
|
+
* per-line spans. Offsets are absolute into `source`; later highlights win on
|
|
86
|
+
* overlap, and a highlight is clamped to the line its start falls on.
|
|
87
|
+
*/
|
|
88
|
+
export function spansByLine(source: string, highlights: SimpleHighlight[]): SyntaxSpan[][] {
|
|
89
|
+
const lineStarts: number[] = [0];
|
|
90
|
+
|
|
91
|
+
for (let index = 0; index < source.length; index++) {
|
|
92
|
+
if (source[index] === "\n") lineStarts.push(index + 1);
|
|
93
|
+
}
|
|
94
|
+
const lineOf = (offset: number): number => {
|
|
95
|
+
let low = 0;
|
|
96
|
+
let high = lineStarts.length - 1;
|
|
97
|
+
|
|
98
|
+
while (low < high) {
|
|
99
|
+
const mid = (low + high + 1) >> 1;
|
|
100
|
+
|
|
101
|
+
if (lineStarts[mid]! <= offset) low = mid;
|
|
102
|
+
else high = mid - 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return low;
|
|
106
|
+
};
|
|
107
|
+
// Per-line char->group, later highlights overriding, then coalesce into spans.
|
|
108
|
+
const groupsByLine: Array<Array<string | undefined>> = lineStarts.map((start, line) => {
|
|
109
|
+
const end = line + 1 < lineStarts.length ? lineStarts[line + 1]! - 1 : source.length;
|
|
110
|
+
|
|
111
|
+
return Array.from({ length: Math.max(0, end - start) }, () => undefined);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
for (const [start, rawEnd, group] of highlights) {
|
|
115
|
+
// A capture can span newlines (block comments, template literals); color the
|
|
116
|
+
// covered columns on every line it crosses, not just the starting line. Offsets
|
|
117
|
+
// past the source (byte offsets from a multi-byte line) clamp to its end, or the
|
|
118
|
+
// walk below would sit on the last line forever.
|
|
119
|
+
const end = Math.min(rawEnd, source.length);
|
|
120
|
+
let cursor = Math.max(0, start);
|
|
121
|
+
|
|
122
|
+
while (cursor < end) {
|
|
123
|
+
const line = lineOf(cursor);
|
|
124
|
+
const lineStart = lineStarts[line]!;
|
|
125
|
+
const lineEnd = line + 1 < lineStarts.length ? lineStarts[line + 1]! - 1 : source.length;
|
|
126
|
+
const chars = groupsByLine[line]!;
|
|
127
|
+
const to = Math.min(end, lineEnd) - lineStart;
|
|
128
|
+
|
|
129
|
+
for (let column = cursor - lineStart; column < to; column++) chars[column] = group;
|
|
130
|
+
cursor = lineEnd + 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return groupsByLine.map((chars) => {
|
|
135
|
+
const spans: SyntaxSpan[] = [];
|
|
136
|
+
|
|
137
|
+
for (let column = 0; column < chars.length; column++) {
|
|
138
|
+
const group = chars[column];
|
|
139
|
+
|
|
140
|
+
if (group === undefined) continue;
|
|
141
|
+
const previous = spans[spans.length - 1];
|
|
142
|
+
|
|
143
|
+
if (previous && previous.group === group && previous.end === column)
|
|
144
|
+
previous.end = column + 1;
|
|
145
|
+
else spans.push({ start: column, end: column + 1, group });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return spans;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Highlight the diff's rows and return the token spans per row index. Rows in a
|
|
154
|
+
* file with no known filetype, and any row while highlighting is still resolving,
|
|
155
|
+
* are absent - the caller renders those unstyled.
|
|
156
|
+
*/
|
|
157
|
+
export async function highlightDiffRows(rows: DiffRow[]): Promise<Map<number, SyntaxSpan[]>> {
|
|
158
|
+
const spansByRow = new Map<number, SyntaxSpan[]>();
|
|
159
|
+
const jobs = highlightJobs(rows);
|
|
160
|
+
|
|
161
|
+
if (!jobs.length) return spansByRow;
|
|
162
|
+
const client = getTreeSitterClient();
|
|
163
|
+
|
|
164
|
+
await client.initialize();
|
|
165
|
+
for (const job of jobs) {
|
|
166
|
+
const result = await client.highlightOnce(job.source, job.filetype);
|
|
167
|
+
|
|
168
|
+
if (!result.highlights) continue;
|
|
169
|
+
const lineSpans = spansByLine(job.source, result.highlights);
|
|
170
|
+
|
|
171
|
+
job.rowIndexByLine.forEach((rowIndex, line) => {
|
|
172
|
+
const spans = lineSpans[line];
|
|
173
|
+
|
|
174
|
+
if (spans && spans.length) spansByRow.set(rowIndex, spans);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return spansByRow;
|
|
179
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discussions: the conversations an annotated surface hosts inline. A root
|
|
3
|
+
* annotation, its replies, and the span they mark, grouped so two roots on the
|
|
4
|
+
* very same text read as one conversation. Pure over the resolved marks, so the
|
|
5
|
+
* plan thread view and the diff sheet group the same way.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Annotation, Thread } from "@cueloop/schema";
|
|
9
|
+
import type { Mark } from "./view-plan";
|
|
10
|
+
import { comparePositions, type TextSpan } from "./thread-selection";
|
|
11
|
+
|
|
12
|
+
/** One conversation: a root annotation, its replies, and the span they mark. */
|
|
13
|
+
export interface Discussion {
|
|
14
|
+
key: string;
|
|
15
|
+
/** The annotation replies attach to. */
|
|
16
|
+
rootId: string;
|
|
17
|
+
/** The block the card renders under: where the span ends. */
|
|
18
|
+
blockIndex: number;
|
|
19
|
+
span: TextSpan;
|
|
20
|
+
annotations: Annotation[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function spanKey(span: TextSpan): string {
|
|
24
|
+
return `${span.start.blockIndex}:${span.start.char}:${span.end.blockIndex}:${span.end.char}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Roots group by the span they resolved to (two roots on the very same text
|
|
29
|
+
* read as one conversation); replies join the discussion their replyTo names, or
|
|
30
|
+
* stand as roots when that root is gone.
|
|
31
|
+
*/
|
|
32
|
+
export function discussionsFrom(session: Thread, marks: Map<number, Mark[]>): Discussion[] {
|
|
33
|
+
const byId = new Map(session.annotations.map((annotation) => [annotation.id, annotation]));
|
|
34
|
+
const spanOf = new Map<string, TextSpan>();
|
|
35
|
+
|
|
36
|
+
for (const [displayIndex, blockMarks] of marks) {
|
|
37
|
+
for (const mark of blockMarks) {
|
|
38
|
+
if (!mark.annotationId || spanOf.has(mark.annotationId)) continue;
|
|
39
|
+
spanOf.set(
|
|
40
|
+
mark.annotationId,
|
|
41
|
+
mark.span ?? {
|
|
42
|
+
start: { blockIndex: displayIndex, char: mark.start },
|
|
43
|
+
end: { blockIndex: displayIndex, char: mark.end },
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const resolved = [...spanOf.keys()].map((id) => byId.get(id)!).filter(Boolean);
|
|
49
|
+
const isRoot = (annotation: Annotation): boolean =>
|
|
50
|
+
annotation.replyTo === undefined || !spanOf.has(annotation.replyTo);
|
|
51
|
+
const grouped = new Map<string, Discussion>();
|
|
52
|
+
const discussionOfRoot = new Map<string, Discussion>();
|
|
53
|
+
|
|
54
|
+
for (const annotation of resolved.filter(isRoot)) {
|
|
55
|
+
const span = spanOf.get(annotation.id)!;
|
|
56
|
+
const key = spanKey(span);
|
|
57
|
+
const discussion = grouped.get(key) ?? {
|
|
58
|
+
key,
|
|
59
|
+
rootId: annotation.id,
|
|
60
|
+
blockIndex: span.end.blockIndex,
|
|
61
|
+
span,
|
|
62
|
+
annotations: [],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
discussion.annotations.push(annotation);
|
|
66
|
+
grouped.set(key, discussion);
|
|
67
|
+
discussionOfRoot.set(annotation.id, discussion);
|
|
68
|
+
}
|
|
69
|
+
for (const annotation of resolved.filter((candidate) => !isRoot(candidate))) {
|
|
70
|
+
discussionOfRoot.get(annotation.replyTo!)?.annotations.push(annotation);
|
|
71
|
+
}
|
|
72
|
+
for (const discussion of grouped.values()) {
|
|
73
|
+
discussion.annotations.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [...grouped.values()].toSorted((left, right) =>
|
|
77
|
+
comparePositions(left.span.start, right.span.start),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { editInEditor, resolveEditor, resolveEditorCommand } from "./editor";
|
|
6
|
+
|
|
7
|
+
describe("resolveEditor", () => {
|
|
8
|
+
test("a clean environment falls back to nano so editing always works", () => {
|
|
9
|
+
// Assert
|
|
10
|
+
expect(resolveEditor(undefined, {})).toBe("nano");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("the environment chain wins in order: CUELOOP_EDITOR, VISUAL, EDITOR", () => {
|
|
14
|
+
// Assert
|
|
15
|
+
expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code", CUELOOP_EDITOR: "hx" })).toBe(
|
|
16
|
+
"hx",
|
|
17
|
+
);
|
|
18
|
+
expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code" })).toBe("code");
|
|
19
|
+
expect(resolveEditor(undefined, { EDITOR: "vim" })).toBe("vim");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("the [ui] editor config overrides the environment", () => {
|
|
23
|
+
// Assert
|
|
24
|
+
expect(resolveEditor("code --wait", { EDITOR: "vim" })).toBe("code --wait");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("resolveEditorCommand", () => {
|
|
29
|
+
test("a known GUI editor gets its wait flag appended and is trusted to wait", () => {
|
|
30
|
+
// Assert
|
|
31
|
+
expect(resolveEditorCommand("code")).toEqual({ argv: ["code", "--wait"], waits: true });
|
|
32
|
+
expect(resolveEditorCommand("subl")).toEqual({
|
|
33
|
+
argv: ["subl", "--new-window", "--wait"],
|
|
34
|
+
waits: true,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("an existing wait flag is not duplicated", () => {
|
|
39
|
+
// Assert
|
|
40
|
+
expect(resolveEditorCommand("code --wait").argv).toEqual(["code", "--wait"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a terminal editor is left alone and trusted to hold the tty", () => {
|
|
44
|
+
// Assert
|
|
45
|
+
expect(resolveEditorCommand("vim")).toEqual({ argv: ["vim"], waits: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("an unknown editor is neither flagged nor trusted to wait", () => {
|
|
49
|
+
// Assert
|
|
50
|
+
expect(resolveEditorCommand("my-editor")).toEqual({ argv: ["my-editor"], waits: false });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("editInEditor", () => {
|
|
55
|
+
let dir: string;
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
dir = mkdtempSync(join(tmpdir(), "cueloop-edit-test-"));
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
rmSync(dir, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** A runnable editor stub with a chosen base name and body. */
|
|
65
|
+
function stubEditor(name: string, body: string): string {
|
|
66
|
+
const script = join(dir, name);
|
|
67
|
+
|
|
68
|
+
writeFileSync(script, `#!/bin/sh\n${body}\n`);
|
|
69
|
+
chmodSync(script, 0o755);
|
|
70
|
+
|
|
71
|
+
return script;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const instant = (): (() => number) => {
|
|
75
|
+
let value = 0;
|
|
76
|
+
|
|
77
|
+
return () => (value += 5);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
test("an editor that changes the file reports the new content", () => {
|
|
81
|
+
// Act
|
|
82
|
+
const result = editInEditor("old", "plan.md", {
|
|
83
|
+
editor: stubEditor("writer.sh", `printf 'new' > "$1"`),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Assert
|
|
87
|
+
expect(result).toEqual({ content: "new", changed: true });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a fast, unchanged return opens the gate; confirming re-reads the saved file", () => {
|
|
91
|
+
// Arrange
|
|
92
|
+
let askedLabel = "";
|
|
93
|
+
|
|
94
|
+
// Act
|
|
95
|
+
const result = editInEditor("keep", "plan.md", {
|
|
96
|
+
editor: stubEditor("gui-like.sh", "exit 0"),
|
|
97
|
+
now: instant(),
|
|
98
|
+
confirmSaved: (label, path) => {
|
|
99
|
+
askedLabel = label;
|
|
100
|
+
writeFileSync(path, "saved in the GUI");
|
|
101
|
+
|
|
102
|
+
return true;
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
107
|
+
expect(askedLabel).toContain("gui-like.sh");
|
|
108
|
+
expect(result).toEqual({ content: "saved in the GUI", changed: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("declining the gate keeps the original content", () => {
|
|
112
|
+
// Act
|
|
113
|
+
const result = editInEditor("keep", "plan.md", {
|
|
114
|
+
editor: stubEditor("gui-like.sh", "exit 0"),
|
|
115
|
+
now: instant(),
|
|
116
|
+
confirmSaved: () => false,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
120
|
+
expect(result).toEqual({ content: "keep", changed: false });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a terminal editor that makes no change never opens the gate", () => {
|
|
124
|
+
// Arrange
|
|
125
|
+
let asked = false;
|
|
126
|
+
|
|
127
|
+
// Act
|
|
128
|
+
const result = editInEditor("keep", "plan.md", {
|
|
129
|
+
editor: stubEditor("vi", "exit 0"),
|
|
130
|
+
now: instant(),
|
|
131
|
+
confirmSaved: () => {
|
|
132
|
+
asked = true;
|
|
133
|
+
|
|
134
|
+
return true;
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Assert
|
|
139
|
+
expect(asked).toBeFalse();
|
|
140
|
+
expect(result).toEqual({ content: "keep", changed: false });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a nonzero editor exit throws", () => {
|
|
144
|
+
// Assert
|
|
145
|
+
expect(() => editInEditor("x", "plan.md", { editor: stubEditor("fail.sh", "exit 3") })).toThrow(
|
|
146
|
+
"editor exited 3",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
package/src/editor.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* File-level edit mode = the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* File-level edit mode = the editor hand-off: write the working copy to a temp
|
|
3
|
+
* file, run the reviewer's editor on it, read the result back. Editing must
|
|
4
|
+
* work with zero configuration, so the resolution chain ends in a terminal
|
|
5
|
+
* fallback that is always present. A GUI editor launched without a wait flag
|
|
6
|
+
* forks and returns instantly, losing the edit silently; known GUI editors get
|
|
7
|
+
* their wait flag applied, and any editor that still returns right away with an
|
|
8
|
+
* untouched file drops to a confirm gate rather than discarding the work.
|
|
5
9
|
*/
|
|
6
10
|
|
|
7
11
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -13,20 +17,146 @@ export interface EditResult {
|
|
|
13
17
|
changed: boolean;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
/** Present on every POSIX system, so a clean shell can still edit. */
|
|
21
|
+
const DEFAULT_EDITOR = "nano";
|
|
22
|
+
|
|
23
|
+
/** GUI editors fork and return immediately unless given their wait flag. */
|
|
24
|
+
interface GuiWaitFlags {
|
|
25
|
+
[editor: string]: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const GUI_WAIT_FLAGS: GuiWaitFlags = {
|
|
29
|
+
code: ["--wait"],
|
|
30
|
+
"code-insiders": ["--wait"],
|
|
31
|
+
codium: ["--wait"],
|
|
32
|
+
cursor: ["--wait"],
|
|
33
|
+
windsurf: ["--wait"],
|
|
34
|
+
subl: ["--new-window", "--wait"],
|
|
35
|
+
sublime_text: ["--new-window", "--wait"],
|
|
36
|
+
zed: ["--wait"],
|
|
37
|
+
bbedit: ["--wait"],
|
|
38
|
+
mate: ["--wait"],
|
|
39
|
+
gedit: ["--wait"],
|
|
40
|
+
kate: ["--block"],
|
|
41
|
+
idea: ["--wait"],
|
|
42
|
+
webstorm: ["--wait"],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Editors that hold the terminal themselves - never GUI, never a wait gate. */
|
|
46
|
+
const TERMINAL_EDITORS = new Set([
|
|
47
|
+
"vi",
|
|
48
|
+
"vim",
|
|
49
|
+
"nvim",
|
|
50
|
+
"nano",
|
|
51
|
+
"pico",
|
|
52
|
+
"emacs",
|
|
53
|
+
"emacsclient",
|
|
54
|
+
"micro",
|
|
55
|
+
"hx",
|
|
56
|
+
"helix",
|
|
57
|
+
"kak",
|
|
58
|
+
"ed",
|
|
59
|
+
"joe",
|
|
60
|
+
"mcedit",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
export interface ResolvedEditor {
|
|
64
|
+
argv: string[];
|
|
65
|
+
/** True when this editor blocks the hand-off until the file is closed - a
|
|
66
|
+
* terminal editor, or a known GUI editor with its wait flag applied. */
|
|
67
|
+
waits: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The editor to run: [ui] editor, then the environment chain, then nano. */
|
|
71
|
+
export function resolveEditor(
|
|
72
|
+
configuredEditor: string | undefined,
|
|
73
|
+
env: Record<string, string | undefined> = process.env,
|
|
74
|
+
): string {
|
|
75
|
+
const candidates = [configuredEditor, env.CUELOOP_EDITOR, env.VISUAL, env.EDITOR];
|
|
76
|
+
|
|
77
|
+
return candidates.find((candidate) => candidate && candidate.trim())?.trim() ?? DEFAULT_EDITOR;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function editorBaseName(command: string): string {
|
|
81
|
+
return (command.split(/[\\/]/).pop() ?? command).toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Split the editor string and apply a GUI wait flag when we know one. */
|
|
85
|
+
export function resolveEditorCommand(rawEditor: string): ResolvedEditor {
|
|
86
|
+
const parts = rawEditor.trim().split(/\s+/);
|
|
87
|
+
const base = editorBaseName(parts[0] ?? "");
|
|
88
|
+
|
|
89
|
+
if (TERMINAL_EDITORS.has(base)) return { argv: parts, waits: true };
|
|
90
|
+
const waitFlags = GUI_WAIT_FLAGS[base];
|
|
91
|
+
|
|
92
|
+
if (waitFlags) {
|
|
93
|
+
const alreadyWaits = parts.some(
|
|
94
|
+
(part) => part === "--wait" || part === "-w" || part === "--block",
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return { argv: alreadyWaits ? parts : [...parts, ...waitFlags], waits: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { argv: parts, waits: false };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A fast return with an untouched file means the editor probably did not wait. */
|
|
104
|
+
const NO_WAIT_THRESHOLD_MS = 1000;
|
|
105
|
+
|
|
106
|
+
function suspectsNoWait(resolved: ResolvedEditor, elapsedMs: number, unchanged: boolean): boolean {
|
|
107
|
+
if (resolved.waits) return false; // holds the terminal or has a wait flag - trust the exit
|
|
108
|
+
if (!unchanged) return false; // edits landed, so it waited after all
|
|
109
|
+
|
|
110
|
+
return elapsedMs < NO_WAIT_THRESHOLD_MS;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface EditHandOff {
|
|
114
|
+
/** Editor from [ui] editor config; overrides the environment. */
|
|
115
|
+
editor?: string;
|
|
116
|
+
env?: Record<string, string | undefined>;
|
|
117
|
+
/** Confirm gate for a suspected no-wait return; true re-reads the file. */
|
|
118
|
+
confirmSaved?: (editorLabel: string, path: string) => boolean;
|
|
119
|
+
now?: () => number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Prompt on the released terminal during the suspend window. */
|
|
123
|
+
function promptSaved(editorLabel: string, path: string): boolean {
|
|
124
|
+
const answer = prompt(
|
|
125
|
+
`${editorLabel} returned immediately and the plan is unchanged - a GUI editor needs its wait flag. Save and close ${path}, then press Enter to load your edits (or type n to skip):`,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return answer !== null && answer.trim().toLowerCase() !== "n";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function editInEditor(
|
|
132
|
+
content: string,
|
|
133
|
+
filename = "plan.md",
|
|
134
|
+
handOff: EditHandOff = {},
|
|
135
|
+
): EditResult {
|
|
136
|
+
const env = handOff.env ?? process.env;
|
|
137
|
+
const now = handOff.now ?? Date.now;
|
|
138
|
+
const resolved = resolveEditorCommand(resolveEditor(handOff.editor, env));
|
|
19
139
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-edit-"));
|
|
20
140
|
const path = join(dir, filename);
|
|
141
|
+
|
|
21
142
|
writeFileSync(path, content);
|
|
22
143
|
try {
|
|
23
|
-
const
|
|
144
|
+
const startedAt = now();
|
|
145
|
+
const editorProcess = Bun.spawnSync([...resolved.argv, path], {
|
|
24
146
|
stdin: "inherit",
|
|
25
147
|
stdout: "inherit",
|
|
26
148
|
stderr: "inherit",
|
|
27
149
|
});
|
|
28
|
-
|
|
29
|
-
|
|
150
|
+
|
|
151
|
+
if (editorProcess.exitCode !== 0) throw new Error(`editor exited ${editorProcess.exitCode}`);
|
|
152
|
+
let next = readFileSync(path, "utf8");
|
|
153
|
+
|
|
154
|
+
if (suspectsNoWait(resolved, now() - startedAt, next === content)) {
|
|
155
|
+
const confirm = handOff.confirmSaved ?? promptSaved;
|
|
156
|
+
|
|
157
|
+
if (confirm(resolved.argv[0] ?? "the editor", path)) next = readFileSync(path, "utf8");
|
|
158
|
+
}
|
|
159
|
+
|
|
30
160
|
return { content: next, changed: next !== content };
|
|
31
161
|
} finally {
|
|
32
162
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** The libghostty-vt FFI wrapper round-trips VT: fed bytes come back as decoded cells with colors and attributes. Skipped where no prebuilt dylib ships for the platform. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { loadGhosttyTerminals } from "./ghostty-terminal";
|
|
5
|
+
|
|
6
|
+
const factory = loadGhosttyTerminals();
|
|
7
|
+
|
|
8
|
+
describe("ghostty-terminal FFI", () => {
|
|
9
|
+
test("reports platform support without throwing", () => {
|
|
10
|
+
// Assert - null on unsupported platforms, a factory where a dylib ships
|
|
11
|
+
expect(factory === null || factory.create !== undefined).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test.skipIf(!factory)("writes VT bytes and reads back decoded cells", () => {
|
|
15
|
+
// Arrange
|
|
16
|
+
const term = factory!.create(80, 24)!;
|
|
17
|
+
|
|
18
|
+
expect(term).not.toBeNull();
|
|
19
|
+
|
|
20
|
+
// Act - plain text then bold + palette-green "GO"
|
|
21
|
+
term.write(new TextEncoder().encode("hi\x1b[1;32mGO\x1b[0m"));
|
|
22
|
+
const cells = [0, 1, 2, 3].map((x) => term.readCell(x, 0));
|
|
23
|
+
|
|
24
|
+
// Assert - glyphs decode, the row reads as text, and the green run is bold on ANSI palette index 2
|
|
25
|
+
expect(
|
|
26
|
+
cells.map((c) => (c?.codepoint ? String.fromCodePoint(c.codepoint) : " ")).join(""),
|
|
27
|
+
).toBe("hiGO");
|
|
28
|
+
expect(term.rowText(0, 80)).toBe("hiGO");
|
|
29
|
+
expect(cells[2]!.bold).toBe(true);
|
|
30
|
+
expect(cells[2]!.fg).toEqual({ kind: "palette", index: 2 });
|
|
31
|
+
term.free();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test.skipIf(!factory)("tracks the cursor position as text is written", () => {
|
|
35
|
+
// Arrange
|
|
36
|
+
const term = factory!.create(80, 24)!;
|
|
37
|
+
|
|
38
|
+
// Act
|
|
39
|
+
term.write(new TextEncoder().encode("abc"));
|
|
40
|
+
|
|
41
|
+
// Assert - cursor advanced past the three glyphs on row 0
|
|
42
|
+
const cursor = term.readCursor();
|
|
43
|
+
|
|
44
|
+
expect(cursor.y).toBe(0);
|
|
45
|
+
expect(cursor.x).toBe(3);
|
|
46
|
+
term.free();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test.skipIf(!factory)(
|
|
50
|
+
"preserves the native screen contract across resize and styled text",
|
|
51
|
+
() => {
|
|
52
|
+
// Arrange
|
|
53
|
+
const term = factory!.create(8, 2)!;
|
|
54
|
+
|
|
55
|
+
// Act - resize first, then write RGB colors, every supported style, and a wide glyph
|
|
56
|
+
term.resize(6, 3);
|
|
57
|
+
term.write(
|
|
58
|
+
new TextEncoder().encode(
|
|
59
|
+
"\x1b[38;2;12;34;56;48;2;78;90;123;1;3;4;7;9mA\x1b[0m\x1b[2mF\x1b[0m\u754c",
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Assert - the flat native ABI retains cell data and rejects coordinates outside the viewport
|
|
64
|
+
expect(term.readCell(0, 0)).toEqual({
|
|
65
|
+
codepoint: 0x41,
|
|
66
|
+
fg: { kind: "rgb", r: 12, g: 34, b: 56 },
|
|
67
|
+
bg: { kind: "rgb", r: 78, g: 90, b: 123 },
|
|
68
|
+
width: 0,
|
|
69
|
+
bold: true,
|
|
70
|
+
italic: true,
|
|
71
|
+
underline: true,
|
|
72
|
+
inverse: true,
|
|
73
|
+
faint: false,
|
|
74
|
+
strikethrough: true,
|
|
75
|
+
});
|
|
76
|
+
expect(term.readCell(1, 0)?.faint).toBe(true);
|
|
77
|
+
expect(term.readCell(2, 0)?.codepoint).toBe("\u754c".codePointAt(0));
|
|
78
|
+
expect(term.readCell(2, 0)?.width).toBe(1);
|
|
79
|
+
expect(term.readCell(3, 0)?.width).toBe(2);
|
|
80
|
+
expect(term.readCell(6, 0)).toBeNull();
|
|
81
|
+
expect(term.readCell(0, 3)).toBeNull();
|
|
82
|
+
term.free();
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
});
|