@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.71
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 +39 -32
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +76 -50
- package/src/App.diff.test.tsx +156 -42
- package/src/App.export.test.tsx +58 -40
- package/src/App.plan-review.test.tsx +363 -0
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +128 -67
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.submit-confirm.test.tsx +191 -0
- package/src/App.test.tsx +310 -114
- package/src/App.toast.test.tsx +89 -0
- package/src/App.tsx +1043 -798
- package/src/App.walk.test.tsx +298 -0
- package/src/App.workbench.test.tsx +495 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +61 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +432 -0
- package/src/app-view-model.ts +159 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -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 +126 -0
- 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 +89 -0
- package/src/components/AppShell.tsx +290 -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 +39 -0
- package/src/components/ChangesColumn.tsx +171 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +60 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +128 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffContentView.scroll.test.tsx +121 -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 +859 -0
- 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 +118 -0
- package/src/components/EditorGrid.tsx +368 -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 +98 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -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 +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +83 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +87 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -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 +195 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +54 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +219 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +158 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +330 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +71 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -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 +152 -0
- 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 +1845 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +50 -0
- package/src/components/agent-launcher.tsx +149 -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/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -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 +52 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +60 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +66 -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/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 +96 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -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 +54 -0
- package/src/components/quick-actions-editor.test.tsx +49 -0
- package/src/components/quick-actions-editor.tsx +150 -0
- package/src/components/session-tree.test.ts +134 -0
- package/src/components/session-tree.ts +108 -0
- package/src/components/stories-app.tsx +184 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +142 -0
- package/src/components/syntax-highlight.ts +110 -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/truncate-text.ts +7 -0
- package/src/config.test.ts +461 -31
- package/src/config.ts +389 -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 +165 -0
- package/src/diff-intraline.ts +146 -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/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +11 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +698 -0
- package/src/intent-dispatch.ts +700 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +465 -0
- package/src/keymap.test.ts +611 -0
- package/src/keymap.ts +410 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/mark-runs.ts +120 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +307 -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 +30 -9
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +20 -9
- package/src/share.ts +215 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +68 -0
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +104 -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 +355 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +214 -0
- package/src/theme.ts +84 -19
- package/src/thread-chords.test.ts +170 -0
- package/src/thread-chords.ts +169 -0
- package/src/thread-controller.curate.test.ts +438 -0
- package/src/thread-controller.live-diff.test.ts +199 -0
- package/src/thread-controller.share.test.ts +472 -0
- package/src/thread-controller.tree.test.ts +255 -0
- package/src/thread-controller.ts +1509 -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 +148 -0
- package/src/use-annotation-surface.tsx +851 -0
- package/src/use-changes-workbench.ts +153 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +274 -0
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +66 -9
- package/src/view-diff.ts +231 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +570 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- 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 -312
package/src/walk.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guided-walk projections: pure helpers over the diff row model - no
|
|
3
|
+
* IO, no React. The walk steps through the changed files in patch order;
|
|
4
|
+
* which files were seen lives on the session record (viewedPaths), which
|
|
5
|
+
* file is on screen is view state in the session controller.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Annotation } from "@cueloop/schema";
|
|
9
|
+
import type { DiffRow } from "./view-diff";
|
|
10
|
+
|
|
11
|
+
/** One wizard step: a changed file with its stats and a short diff preview. */
|
|
12
|
+
export interface WalkFile {
|
|
13
|
+
path: string;
|
|
14
|
+
added: number;
|
|
15
|
+
removed: number;
|
|
16
|
+
/** The first changed lines, signed (+/-), for the card's diff preview. */
|
|
17
|
+
preview: { sign: "+" | "-"; text: string }[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Preview lines per card: enough to recognize the change, never a full diff. */
|
|
21
|
+
export const WALK_PREVIEW_ROWS = 5;
|
|
22
|
+
|
|
23
|
+
/** Collapse the diff rows into one walk step per changed file, in patch order. */
|
|
24
|
+
export function walkFiles(rows: DiffRow[]): WalkFile[] {
|
|
25
|
+
const files: WalkFile[] = [];
|
|
26
|
+
const byPath = new Map<string, WalkFile>();
|
|
27
|
+
|
|
28
|
+
for (const row of rows) {
|
|
29
|
+
let file = byPath.get(row.file);
|
|
30
|
+
|
|
31
|
+
if (!file) {
|
|
32
|
+
file = { path: row.file, added: 0, removed: 0, preview: [] };
|
|
33
|
+
byPath.set(row.file, file);
|
|
34
|
+
files.push(file);
|
|
35
|
+
}
|
|
36
|
+
if (row.kind !== "add" && row.kind !== "del") continue;
|
|
37
|
+
if (row.kind === "add") file.added += 1;
|
|
38
|
+
else file.removed += 1;
|
|
39
|
+
if (file.preview.length < WALK_PREVIEW_ROWS) {
|
|
40
|
+
file.preview.push({
|
|
41
|
+
sign: row.kind === "add" ? "+" : "-",
|
|
42
|
+
text: row.text.replace(/\n$/, ""),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return files;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Where the walk resumes: the first file not yet marked viewed. All files
|
|
52
|
+
* viewed = the end card (index === files.length), so a finished walk reopens
|
|
53
|
+
* on its summary instead of restarting.
|
|
54
|
+
*/
|
|
55
|
+
export function firstUnviewedIndex(files: WalkFile[], viewedPaths: ReadonlySet<string>): number {
|
|
56
|
+
const index = files.findIndex((file) => !viewedPaths.has(file.path));
|
|
57
|
+
|
|
58
|
+
return index === -1 ? files.length : index;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Viewed count against the CURRENT file set - stale paths never inflate it. */
|
|
62
|
+
export function viewedCount(files: WalkFile[], viewedPaths: ReadonlySet<string>): number {
|
|
63
|
+
return files.filter((file) => viewedPaths.has(file.path)).length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The agent's note for a file: an annotation with kind "note" whose anchor
|
|
68
|
+
* quote is the file path (the note contract in the shared review core).
|
|
69
|
+
*/
|
|
70
|
+
export function noteForFile(annotations: Annotation[], path: string): string | undefined {
|
|
71
|
+
return annotations.find(
|
|
72
|
+
(annotation) => annotation.kind === "note" && annotation.anchor.quote === path,
|
|
73
|
+
)?.body;
|
|
74
|
+
}
|
package/src/syntax.test.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { filetypeFor, highlightCode } from "./syntax";
|
|
3
|
-
|
|
4
|
-
const TS = `export function gate(full: number) {
|
|
5
|
-
// threshold
|
|
6
|
-
return full < 0.6 ? "blocked" : "clear";
|
|
7
|
-
}`;
|
|
8
|
-
|
|
9
|
-
describe("highlightCode", () => {
|
|
10
|
-
test("token text concatenates back to the exact source (verbatim rendering)", async () => {
|
|
11
|
-
const lines = await highlightCode(TS, "ts");
|
|
12
|
-
expect(lines).not.toBeNull();
|
|
13
|
-
const rebuilt = lines!.map((l) => l.map((t) => t.content).join("")).join("\n");
|
|
14
|
-
expect(rebuilt).toBe(TS);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test("keywords, strings, and comments get distinct colors", async () => {
|
|
18
|
-
const lines = await highlightCode(TS, "ts");
|
|
19
|
-
const colorOf = (needle: string) => {
|
|
20
|
-
for (const line of lines!) for (const t of line) if (t.content.includes(needle)) return t.color;
|
|
21
|
-
return undefined;
|
|
22
|
-
};
|
|
23
|
-
const kw = colorOf("export");
|
|
24
|
-
const str = colorOf("blocked");
|
|
25
|
-
const comment = colorOf("threshold");
|
|
26
|
-
expect(kw).toBeDefined();
|
|
27
|
-
expect(str).toBeDefined();
|
|
28
|
-
expect(comment).toBeDefined();
|
|
29
|
-
expect(new Set([kw, str, comment]).size).toBe(3);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("unknown languages return null and render unstyled", async () => {
|
|
33
|
-
expect(await highlightCode("whatever", "brainfog")).toBeNull();
|
|
34
|
-
expect(await highlightCode("plain", undefined)).toBeNull();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("fence aliases map to real grammars", async () => {
|
|
38
|
-
expect(await highlightCode("x = 1", "py")).not.toBeNull();
|
|
39
|
-
expect(await highlightCode('echo "hi"', "sh")).not.toBeNull();
|
|
40
|
-
});
|
|
41
|
-
});
|
package/src/syntax.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Code-block syntax highlighting via Shiki (#67): TextMate-grammar tokens
|
|
3
|
-
* colored by a theme built from cueloop's tokens. The highlighter loads
|
|
4
|
-
* lazily on the first code block; unknown languages render unstyled.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { DARK as T } from "./theme";
|
|
8
|
-
|
|
9
|
-
export interface CodeToken {
|
|
10
|
-
content: string;
|
|
11
|
-
color?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
type Highlighter = {
|
|
15
|
-
codeToTokensBase(code: string, opts: { lang: string; theme: string }): { content: string; color?: string }[][];
|
|
16
|
-
getLoadedLanguages(): string[];
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
const LANGS = ["typescript", "tsx", "javascript", "jsx", "json", "bash", "python", "rust", "go", "yaml", "diff", "markdown", "toml", "sql", "html", "css"];
|
|
20
|
-
|
|
21
|
-
/** Markdown fence info → Shiki language id. */
|
|
22
|
-
export function filetypeFor(lang?: string): string {
|
|
23
|
-
const l = (lang ?? "").toLowerCase();
|
|
24
|
-
const map: Record<string, string> = {
|
|
25
|
-
ts: "typescript",
|
|
26
|
-
js: "javascript",
|
|
27
|
-
py: "python",
|
|
28
|
-
rb: "ruby",
|
|
29
|
-
rs: "rust",
|
|
30
|
-
sh: "bash",
|
|
31
|
-
shell: "bash",
|
|
32
|
-
zsh: "bash",
|
|
33
|
-
yml: "yaml",
|
|
34
|
-
};
|
|
35
|
-
return map[l] ?? l;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function cueloopTheme() {
|
|
39
|
-
const scope = (scopes: string, foreground: string, fontStyle?: string) => ({
|
|
40
|
-
scope: scopes.split(", "),
|
|
41
|
-
settings: fontStyle ? { foreground, fontStyle } : { foreground },
|
|
42
|
-
});
|
|
43
|
-
return {
|
|
44
|
-
name: "cueloop",
|
|
45
|
-
type: "dark" as const,
|
|
46
|
-
colors: { "editor.background": T.elevated, "editor.foreground": T.textMuted },
|
|
47
|
-
settings: [
|
|
48
|
-
{ settings: { foreground: T.textMuted } },
|
|
49
|
-
scope("comment, punctuation.definition.comment", T.textDim, "italic"),
|
|
50
|
-
scope("string, string.quoted, punctuation.definition.string", T.green),
|
|
51
|
-
scope("constant.numeric, constant.language, constant.character", T.blue),
|
|
52
|
-
scope("keyword, storage, storage.type, storage.modifier, keyword.control", T.accent),
|
|
53
|
-
scope("entity.name.function, support.function, meta.function-call", T.text),
|
|
54
|
-
scope("entity.name.type, entity.name.class, support.type, support.class", T.blue),
|
|
55
|
-
scope("variable, variable.other, variable.parameter", T.textMuted),
|
|
56
|
-
scope("entity.name.tag", T.accent),
|
|
57
|
-
scope("entity.other.attribute-name", T.blue),
|
|
58
|
-
scope("punctuation, meta.brace", T.textDim),
|
|
59
|
-
scope("keyword.operator", T.textDim),
|
|
60
|
-
],
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
let highlighterPromise: Promise<Highlighter | null> | null = null;
|
|
65
|
-
|
|
66
|
-
function getHighlighter(): Promise<Highlighter | null> {
|
|
67
|
-
highlighterPromise ??= import("shiki")
|
|
68
|
-
.then((shiki) => shiki.createHighlighter({ themes: [cueloopTheme()], langs: LANGS }) as Promise<Highlighter>)
|
|
69
|
-
.catch(() => null); // highlighting is an enhancement, never a blocker
|
|
70
|
-
return highlighterPromise;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Tokenize code into styled lines. Token text concatenates back to the exact
|
|
75
|
-
* source line, so verbatim rendering (indentation, no wrap) is preserved.
|
|
76
|
-
* Returns null when the language is unknown or the highlighter is unavailable.
|
|
77
|
-
*/
|
|
78
|
-
export async function highlightCode(content: string, lang?: string): Promise<CodeToken[][] | null> {
|
|
79
|
-
const language = filetypeFor(lang);
|
|
80
|
-
if (!language) return null;
|
|
81
|
-
const highlighter = await getHighlighter();
|
|
82
|
-
if (!highlighter || !highlighter.getLoadedLanguages().includes(language)) return null;
|
|
83
|
-
try {
|
|
84
|
-
return highlighter.codeToTokensBase(content, { lang: language, theme: "cueloop" });
|
|
85
|
-
} catch {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
}
|
package/src/view.test.ts
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
blockRuns,
|
|
4
|
-
buildDisplay,
|
|
5
|
-
displayText,
|
|
6
|
-
marksByDisplay,
|
|
7
|
-
nextWorkBlock,
|
|
8
|
-
overlayMarks,
|
|
9
|
-
spanKey,
|
|
10
|
-
startSpan,
|
|
11
|
-
wrapRuns,
|
|
12
|
-
} from "./view";
|
|
13
|
-
import { cutBlock, parseBlocks, restoreBlock, restoreLine, type Annotation } from "@cueloop/schema";
|
|
14
|
-
|
|
15
|
-
const BASE = `# Plan
|
|
16
|
-
|
|
17
|
-
## Context
|
|
18
|
-
|
|
19
|
-
The daemon persists sessions to disk atomically.
|
|
20
|
-
|
|
21
|
-
- first item
|
|
22
|
-
- second item
|
|
23
|
-
`;
|
|
24
|
-
|
|
25
|
-
describe("buildDisplay reconciliation", () => {
|
|
26
|
-
test("no working copy: everything is same", () => {
|
|
27
|
-
const d = buildDisplay(BASE);
|
|
28
|
-
expect(d.every((x) => x.type === "same")).toBe(true);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("reworded block pairs as mod (similar)", () => {
|
|
32
|
-
const working = BASE.replace("persists sessions to disk", "writes sessions to disk");
|
|
33
|
-
const d = buildDisplay(BASE, working);
|
|
34
|
-
const mod = d.find((x) => x.type === "mod")!;
|
|
35
|
-
expect(mod.base!.text).toContain("persists");
|
|
36
|
-
expect(mod.work!.text).toContain("writes");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("unrelated cut + insert do NOT merge into a mod", () => {
|
|
40
|
-
const working = BASE.replace("- second item\n", "") + "\nA totally new closing note.\n";
|
|
41
|
-
const d = buildDisplay(BASE, working);
|
|
42
|
-
expect(d.some((x) => x.type === "del" && x.base!.text === "second item")).toBe(true);
|
|
43
|
-
expect(d.some((x) => x.type === "add" && x.work!.text.includes("closing note"))).toBe(true);
|
|
44
|
-
expect(d.some((x) => x.type === "mod")).toBe(false);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("ordered list items get running numbers", () => {
|
|
48
|
-
const d = buildDisplay("1. one\n2. two\n3. three\n");
|
|
49
|
-
expect(d.map((x) => x.oliNum)).toEqual([1, 2, 3]);
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
describe("blockRuns + overlayMarks", () => {
|
|
54
|
-
test("mod blocks emit word-diff runs that reconstruct both sides", () => {
|
|
55
|
-
const d = buildDisplay("plain old text here\n", "plain new text here\n")[0]!;
|
|
56
|
-
const runs = blockRuns(d, true);
|
|
57
|
-
const oldSide = runs.filter((r) => r.role !== "ins").map((r) => r.text).join("");
|
|
58
|
-
const newSide = runs.filter((r) => r.role !== "del").map((r) => r.text).join("");
|
|
59
|
-
expect(oldSide).toBe("plain old text here");
|
|
60
|
-
expect(newSide).toBe("plain new text here");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("marks bind to working-text offsets and skip del runs", () => {
|
|
64
|
-
const d = buildDisplay("old words linger\n", "new words linger\n")[0]!;
|
|
65
|
-
const runs = blockRuns(d, true);
|
|
66
|
-
const marked = overlayMarks(runs, [{ start: 4, end: 9, role: "mark-comment" }]); // "words"
|
|
67
|
-
const markedText = marked.filter((r) => r.role === "mark-comment").map((r) => r.text).join("");
|
|
68
|
-
expect(markedText).toBe("words");
|
|
69
|
-
// del run text untouched
|
|
70
|
-
expect(marked.some((r) => r.role === "del" && r.text === "old")).toBe(true);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("marks never override ins emphasis", () => {
|
|
74
|
-
const d = buildDisplay("keep\n", "keep added\n")[0]!;
|
|
75
|
-
const runs = overlayMarks(blockRuns(d, true), [{ start: 0, end: 10, role: "mark-comment" }]);
|
|
76
|
-
expect(runs.some((r) => r.role === "ins")).toBe(true);
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe("marksByDisplay", () => {
|
|
81
|
-
test("annotations resolve into display coordinates", () => {
|
|
82
|
-
const working = BASE.replace("first item", "first item edited beyond recognition of it");
|
|
83
|
-
const display = buildDisplay(BASE, working);
|
|
84
|
-
const ann: Annotation = {
|
|
85
|
-
id: "a1",
|
|
86
|
-
kind: "comment",
|
|
87
|
-
anchor: { quote: "atomically", prefix: "to disk ", suffix: "." },
|
|
88
|
-
body: "define atomically",
|
|
89
|
-
createdAt: "",
|
|
90
|
-
};
|
|
91
|
-
const map = marksByDisplay([ann], display);
|
|
92
|
-
const [dispIdx, marks] = [...map.entries()][0]!;
|
|
93
|
-
expect(displayText(display[dispIdx]!)).toContain("atomically");
|
|
94
|
-
expect(marks[0]!.annotationId).toBe("a1");
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("orphaned annotations produce no marks", () => {
|
|
98
|
-
const display = buildDisplay(BASE);
|
|
99
|
-
const ann: Annotation = {
|
|
100
|
-
id: "a1",
|
|
101
|
-
kind: "comment",
|
|
102
|
-
anchor: { quote: "text that is gone", prefix: "", suffix: "" },
|
|
103
|
-
body: "",
|
|
104
|
-
createdAt: "",
|
|
105
|
-
};
|
|
106
|
-
expect(marksByDisplay([ann], display).size).toBe(0);
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
describe("wrapRuns", () => {
|
|
111
|
-
test("wraps at word boundaries within width", () => {
|
|
112
|
-
const lines = wrapRuns([{ text: "alpha beta gamma delta", role: "plain", start: 0 }], 11);
|
|
113
|
-
const rendered = lines.map((l) => l.map((r) => r.text).join(""));
|
|
114
|
-
expect(rendered.every((l) => l.length <= 11)).toBe(true);
|
|
115
|
-
expect(rendered.join(" ").replace(/\s+/g, " ").trim()).toBe("alpha beta gamma delta");
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test("hard-splits words longer than the width", () => {
|
|
119
|
-
const lines = wrapRuns([{ text: "abcdefghij", role: "plain", start: 0 }], 4);
|
|
120
|
-
expect(lines.length).toBeGreaterThan(1);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test("newlines force line breaks (code blocks)", () => {
|
|
124
|
-
const lines = wrapRuns([{ text: "line1\nline2", role: "plain", start: 0 }], 80);
|
|
125
|
-
expect(lines.length).toBe(2);
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe("span mode", () => {
|
|
130
|
-
const text = "one two three four";
|
|
131
|
-
test("v selects the first word; l grows; w slides; $ to end", () => {
|
|
132
|
-
let s = startSpan(0, text)!;
|
|
133
|
-
expect(text.slice(s.start, s.end)).toBe("one");
|
|
134
|
-
s = spanKey(s, "l", text);
|
|
135
|
-
expect(text.slice(s.start, s.end)).toBe("one two");
|
|
136
|
-
s = spanKey(s, "w", text);
|
|
137
|
-
expect(text.slice(s.start, s.end)).toBe("two three");
|
|
138
|
-
s = spanKey(s, "$", text);
|
|
139
|
-
expect(text.slice(s.start, s.end)).toBe("two three four");
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
describe("cut / restore round-trip", () => {
|
|
144
|
-
test("cut removes the block lines; restore puts them back", () => {
|
|
145
|
-
const blocks = parseBlocks(BASE);
|
|
146
|
-
const li = blocks.find((b) => b.text === "second item")!;
|
|
147
|
-
const cut = cutBlock(BASE, li);
|
|
148
|
-
expect(cut).not.toContain("second item");
|
|
149
|
-
const display = buildDisplay(BASE, cut);
|
|
150
|
-
const delIdx = display.findIndex((d) => d.type === "del");
|
|
151
|
-
const line = restoreLine(nextWorkBlock(display, delIdx), cut.split("\n").length);
|
|
152
|
-
// undefined = the working copy round-tripped back to the submitted revision
|
|
153
|
-
const restored = restoreBlock(BASE, cut, li, line);
|
|
154
|
-
expect(restored).toBeUndefined();
|
|
155
|
-
expect(buildDisplay(BASE, restored).every((d) => d.type === "same")).toBe(true);
|
|
156
|
-
});
|
|
157
|
-
});
|
package/src/view.ts
DELETED
|
@@ -1,312 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The projection pipeline (#22): parse → display blocks (base vs working
|
|
3
|
-
* copy reconciled) → styled runs → wrapped lines. Pure functions - the TUI
|
|
4
|
-
* renders the output, tests assert on it directly. One planning layer:
|
|
5
|
-
* rendering, navigation, and selection all derive from this module.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
lcsDiff,
|
|
10
|
-
parseBlocks,
|
|
11
|
-
resolveAnchor,
|
|
12
|
-
wordDiff,
|
|
13
|
-
type Annotation,
|
|
14
|
-
type Block,
|
|
15
|
-
} from "@cueloop/schema";
|
|
16
|
-
|
|
17
|
-
// ── display model ─────────────────────────────
|
|
18
|
-
|
|
19
|
-
export type DisplayType = "same" | "mod" | "del" | "add";
|
|
20
|
-
|
|
21
|
-
export interface DisplayBlock {
|
|
22
|
-
type: DisplayType;
|
|
23
|
-
kind: Block["kind"];
|
|
24
|
-
/** Block in the working copy (absent for del). */
|
|
25
|
-
work?: Block;
|
|
26
|
-
/** Block in the submitted revision (absent for add). */
|
|
27
|
-
base?: Block;
|
|
28
|
-
oliNum?: number;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function displayText(d: DisplayBlock): string {
|
|
32
|
-
return (d.work ?? d.base)!.text;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Word-overlap similarity gate: an unrelated cut + insert must not merge. */
|
|
36
|
-
function similar(a: Block, b: Block): boolean {
|
|
37
|
-
if (a.kind !== b.kind) return false;
|
|
38
|
-
const words = (t: string) => new Set(t.toLowerCase().split(/\s+/).filter(Boolean));
|
|
39
|
-
const wa = words(a.text);
|
|
40
|
-
const wb = words(b.text);
|
|
41
|
-
let inter = 0;
|
|
42
|
-
for (const w of wa) if (wb.has(w)) inter++;
|
|
43
|
-
const union = wa.size + wb.size - inter;
|
|
44
|
-
return union === 0 ? true : inter / union >= 0.3;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Reconcile base blocks vs working blocks into the display list. */
|
|
48
|
-
export function buildDisplay(baseContent: string, workingContent?: string): DisplayBlock[] {
|
|
49
|
-
const baseBlocks = parseBlocks(baseContent);
|
|
50
|
-
if (workingContent === undefined) {
|
|
51
|
-
const out: DisplayBlock[] = baseBlocks.map((b) => ({ type: "same", kind: b.kind, work: b, base: b }));
|
|
52
|
-
numberOlis(out);
|
|
53
|
-
return out;
|
|
54
|
-
}
|
|
55
|
-
const workBlocks = parseBlocks(workingContent);
|
|
56
|
-
const sig = (b: Block) => b.kind + "\0" + b.text;
|
|
57
|
-
const ops = lcsDiff(baseBlocks, workBlocks, (x, y) => sig(x) === sig(y));
|
|
58
|
-
const out: DisplayBlock[] = [];
|
|
59
|
-
let k = 0;
|
|
60
|
-
while (k < ops.length) {
|
|
61
|
-
const op = ops[k]!;
|
|
62
|
-
if (op.t === "ctx") {
|
|
63
|
-
out.push({ type: "same", kind: op.b!.kind, work: op.b, base: op.a });
|
|
64
|
-
k++;
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
const dels: Block[] = [];
|
|
68
|
-
const adds: Block[] = [];
|
|
69
|
-
while (k < ops.length && ops[k]!.t !== "ctx") {
|
|
70
|
-
const o = ops[k]!;
|
|
71
|
-
if (o.t === "del") dels.push(o.a!);
|
|
72
|
-
else adds.push(o.b!);
|
|
73
|
-
k++;
|
|
74
|
-
}
|
|
75
|
-
let di = 0;
|
|
76
|
-
let ai = 0;
|
|
77
|
-
while (di < dels.length && ai < adds.length) {
|
|
78
|
-
if (similar(dels[di]!, adds[ai]!)) {
|
|
79
|
-
out.push({ type: "mod", kind: adds[ai]!.kind, base: dels[di]!, work: adds[ai]! });
|
|
80
|
-
di++;
|
|
81
|
-
ai++;
|
|
82
|
-
} else if (dels.length - di >= adds.length - ai) {
|
|
83
|
-
out.push({ type: "del", kind: dels[di]!.kind, base: dels[di]! });
|
|
84
|
-
di++;
|
|
85
|
-
} else {
|
|
86
|
-
out.push({ type: "add", kind: adds[ai]!.kind, work: adds[ai]! });
|
|
87
|
-
ai++;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
while (di < dels.length) out.push({ type: "del", kind: dels[di]!.kind, base: dels[di++]! });
|
|
91
|
-
while (ai < adds.length) out.push({ type: "add", kind: adds[ai]!.kind, work: adds[ai++]! });
|
|
92
|
-
}
|
|
93
|
-
numberOlis(out);
|
|
94
|
-
return out;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function numberOlis(disp: DisplayBlock[]): void {
|
|
98
|
-
let run = 0;
|
|
99
|
-
for (const d of disp) {
|
|
100
|
-
if (d.kind === "oli") d.oliNum = ++run;
|
|
101
|
-
else run = 0;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// ── marks (annotation highlights + keyboard span) ──
|
|
106
|
-
|
|
107
|
-
export type RunRole =
|
|
108
|
-
| "plain"
|
|
109
|
-
| "ins"
|
|
110
|
-
| "del"
|
|
111
|
-
| "mark-comment"
|
|
112
|
-
| "mark-suggestion"
|
|
113
|
-
| "kspan"
|
|
114
|
-
| "mark-focus";
|
|
115
|
-
|
|
116
|
-
export interface StyleRun {
|
|
117
|
-
text: string;
|
|
118
|
-
role: RunRole;
|
|
119
|
-
/** Offset of this run in the block's working text; null for del runs. */
|
|
120
|
-
start: number | null;
|
|
121
|
-
annotationId?: string;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export interface Mark {
|
|
125
|
-
start: number;
|
|
126
|
-
end: number;
|
|
127
|
-
role: RunRole;
|
|
128
|
-
annotationId?: string;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Resolve annotations against the working blocks and group marks per display
|
|
133
|
-
* index. Working blocks are the blocks of (workingCopy ?? artifact) - the
|
|
134
|
-
* same list buildDisplay derived work entries from.
|
|
135
|
-
*/
|
|
136
|
-
export function marksByDisplay(
|
|
137
|
-
annotations: Annotation[],
|
|
138
|
-
display: DisplayBlock[],
|
|
139
|
-
focusedId?: string,
|
|
140
|
-
): Map<number, Mark[]> {
|
|
141
|
-
// block list in "work space": display entries that carry a work block, in order
|
|
142
|
-
const workEntries = display
|
|
143
|
-
.map((d, dispIdx) => ({ d, dispIdx }))
|
|
144
|
-
.filter((e) => e.d.work !== undefined);
|
|
145
|
-
const workBlocks = workEntries.map((e) => e.d.work!);
|
|
146
|
-
const map = new Map<number, Mark[]>();
|
|
147
|
-
for (const a of annotations) {
|
|
148
|
-
const res = resolveAnchor(a.anchor, workBlocks);
|
|
149
|
-
if (!res) continue;
|
|
150
|
-
const entry = workEntries[res.blockIndex];
|
|
151
|
-
if (!entry) continue;
|
|
152
|
-
const marks = map.get(entry.dispIdx) ?? [];
|
|
153
|
-
marks.push({
|
|
154
|
-
start: res.start,
|
|
155
|
-
end: res.end,
|
|
156
|
-
role: a.id === focusedId ? "mark-focus" : a.kind === "suggestion" ? "mark-suggestion" : "mark-comment",
|
|
157
|
-
annotationId: a.id,
|
|
158
|
-
});
|
|
159
|
-
map.set(entry.dispIdx, marks);
|
|
160
|
-
}
|
|
161
|
-
return map;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** Base runs for a display block: plain text, or word-diff for mod blocks. */
|
|
165
|
-
export function blockRuns(d: DisplayBlock, markup: boolean): StyleRun[] {
|
|
166
|
-
if (d.type === "mod" && markup) {
|
|
167
|
-
const ops = wordDiff(d.base!.text, d.work!.text);
|
|
168
|
-
const runs: StyleRun[] = [];
|
|
169
|
-
let off = 0;
|
|
170
|
-
for (const op of ops) {
|
|
171
|
-
if (op.t === "del") {
|
|
172
|
-
runs.push({ text: op.a!, role: "del", start: null });
|
|
173
|
-
} else {
|
|
174
|
-
runs.push({ text: op.b!, role: op.t === "add" ? "ins" : "plain", start: off });
|
|
175
|
-
off += op.b!.length;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
return runs;
|
|
179
|
-
}
|
|
180
|
-
return [{ text: displayText(d), role: "plain", start: 0 }];
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** Split runs at mark boundaries; marks only bind to runs with offsets. */
|
|
184
|
-
export function overlayMarks(runs: StyleRun[], marks: Mark[]): StyleRun[] {
|
|
185
|
-
if (!marks.length) return runs;
|
|
186
|
-
const out: StyleRun[] = [];
|
|
187
|
-
for (const run of runs) {
|
|
188
|
-
if (run.start === null) {
|
|
189
|
-
out.push(run);
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
const bounds = new Set<number>([0, run.text.length]);
|
|
193
|
-
for (const m of marks) {
|
|
194
|
-
const s = Math.max(0, m.start - run.start);
|
|
195
|
-
const e = Math.min(run.text.length, m.end - run.start);
|
|
196
|
-
if (s < e) {
|
|
197
|
-
bounds.add(s);
|
|
198
|
-
bounds.add(e);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
const runStart = run.start;
|
|
202
|
-
const pts = [...bounds].sort((a, b) => a - b);
|
|
203
|
-
for (let i = 0; i < pts.length - 1; i++) {
|
|
204
|
-
const s = pts[i]!;
|
|
205
|
-
const e = pts[i + 1]!;
|
|
206
|
-
if (s >= e) continue;
|
|
207
|
-
const abs = runStart + s;
|
|
208
|
-
const mark = marks.find((m) => m.start <= abs && runStart + e <= m.end);
|
|
209
|
-
out.push({
|
|
210
|
-
text: run.text.slice(s, e),
|
|
211
|
-
// a mark never overrides ins/del emphasis; it rides on plain text
|
|
212
|
-
role: mark && run.role === "plain" ? mark.role : run.role,
|
|
213
|
-
start: abs,
|
|
214
|
-
annotationId: mark?.annotationId ?? run.annotationId,
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
return out;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Wrap styled runs into lines of at most `width` display cells. */
|
|
222
|
-
export function wrapRuns(runs: StyleRun[], width: number): StyleRun[][] {
|
|
223
|
-
const lines: StyleRun[][] = [];
|
|
224
|
-
let line: StyleRun[] = [];
|
|
225
|
-
let used = 0;
|
|
226
|
-
const pushLine = () => {
|
|
227
|
-
lines.push(line);
|
|
228
|
-
line = [];
|
|
229
|
-
used = 0;
|
|
230
|
-
};
|
|
231
|
-
for (const run of runs) {
|
|
232
|
-
// split run into word/space tokens; newlines force breaks
|
|
233
|
-
const tokens = run.text.split(/(\n|\s+)/).filter((t) => t !== "");
|
|
234
|
-
for (const tok of tokens) {
|
|
235
|
-
if (tok === "\n") {
|
|
236
|
-
pushLine();
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
239
|
-
if (used + tok.length > width && used > 0 && tok.trim() !== "") pushLine();
|
|
240
|
-
if (tok.trim() === "" && used === 0 && lines.length > 0) continue; // no leading spaces after wrap
|
|
241
|
-
let slice = tok;
|
|
242
|
-
while (slice.length > width) {
|
|
243
|
-
line.push({ ...run, text: slice.slice(0, width - used) });
|
|
244
|
-
slice = slice.slice(width - used);
|
|
245
|
-
pushLine();
|
|
246
|
-
}
|
|
247
|
-
line.push({ ...run, text: slice });
|
|
248
|
-
used += slice.length;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
if (line.length || !lines.length) lines.push(line);
|
|
252
|
-
return lines;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// ── keyboard span mode ────────────────────────
|
|
256
|
-
|
|
257
|
-
export interface SpanState {
|
|
258
|
-
dispIdx: number;
|
|
259
|
-
wordIdx: number;
|
|
260
|
-
wordEnd: number;
|
|
261
|
-
start: number;
|
|
262
|
-
end: number;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
export function wordRanges(text: string): [number, number][] {
|
|
266
|
-
const out: [number, number][] = [];
|
|
267
|
-
const re = /\S+/g;
|
|
268
|
-
let m: RegExpExecArray | null;
|
|
269
|
-
while ((m = re.exec(text))) out.push([m.index, m.index + m[0]!.length]);
|
|
270
|
-
return out;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
export function startSpan(dispIdx: number, text: string): SpanState | null {
|
|
274
|
-
const words = wordRanges(text);
|
|
275
|
-
if (!words.length) return null;
|
|
276
|
-
return { dispIdx, wordIdx: 0, wordEnd: 0, start: words[0]![0], end: words[0]![1] };
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
export function spanKey(span: SpanState, key: string, text: string): SpanState {
|
|
280
|
-
const words = wordRanges(text);
|
|
281
|
-
const s = { ...span };
|
|
282
|
-
const width = s.wordEnd - s.wordIdx;
|
|
283
|
-
if (key === "l") s.wordEnd = Math.min(words.length - 1, s.wordEnd + 1);
|
|
284
|
-
else if (key === "h") s.wordEnd = Math.max(s.wordIdx, s.wordEnd - 1);
|
|
285
|
-
else if (key === "w") {
|
|
286
|
-
const n = Math.min(words.length - 1 - width, s.wordIdx + 1);
|
|
287
|
-
s.wordIdx = n;
|
|
288
|
-
s.wordEnd = n + width;
|
|
289
|
-
} else if (key === "b") {
|
|
290
|
-
const n = Math.max(0, s.wordIdx - 1);
|
|
291
|
-
s.wordIdx = n;
|
|
292
|
-
s.wordEnd = n + width;
|
|
293
|
-
} else if (key === "$") s.wordEnd = words.length - 1;
|
|
294
|
-
else if (key === "0") {
|
|
295
|
-
s.wordEnd = width;
|
|
296
|
-
s.wordIdx = 0;
|
|
297
|
-
} else return span;
|
|
298
|
-
s.start = words[s.wordIdx]![0];
|
|
299
|
-
s.end = words[s.wordEnd]![1];
|
|
300
|
-
return s;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ── working-copy block operations ─────────────
|
|
304
|
-
|
|
305
|
-
/** The next work block after a display index (a del block re-enters before it). */
|
|
306
|
-
export function nextWorkBlock(display: DisplayBlock[], dispIdx: number): Block | undefined {
|
|
307
|
-
for (let i = dispIdx + 1; i < display.length; i++) {
|
|
308
|
-
const w = display[i]!.work;
|
|
309
|
-
if (w) return w;
|
|
310
|
-
}
|
|
311
|
-
return undefined;
|
|
312
|
-
}
|