@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.41
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.sh +56 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/shim.c +107 -0
- package/package.json +34 -24
- package/src/App.autoclose.test.tsx +137 -0
- package/src/App.diff.test.tsx +158 -22
- package/src/App.export.test.tsx +44 -37
- package/src/App.plan-review.test.tsx +632 -0
- package/src/App.readonly.test.tsx +60 -36
- package/src/App.rendering.test.tsx +132 -0
- package/src/App.review-panel.test.tsx +136 -0
- package/src/App.submit-confirm.test.tsx +220 -0
- package/src/App.test.tsx +128 -69
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +288 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +38 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +30 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +228 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +83 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +59 -0
- package/src/components/ConfirmCard.stories.tsx +56 -0
- package/src/components/ConfirmCard.tsx +135 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +355 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +62 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +389 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +220 -0
- package/src/components/ReviewRail.tsx +324 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +141 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +108 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +21 -0
- package/src/components/agent-launcher.tsx +180 -0
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +48 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +64 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +98 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +136 -0
- package/src/components/stories-app.tsx +123 -0
- package/src/components/stories.test.tsx +62 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +69 -0
- package/src/components/syntax-highlight.ts +92 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +174 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +36 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +430 -31
- package/src/config.ts +304 -26
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +143 -0
- package/src/editor.ts +123 -9
- package/src/ghostty-terminal.test.ts +45 -0
- package/src/ghostty-terminal.ts +168 -0
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +27 -0
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +30 -0
- package/src/intent-dispatch.test.ts +492 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +424 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +24 -9
- package/src/serve.ts +7 -7
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +309 -0
- package/src/session-controller.ts +900 -0
- package/src/share.ts +113 -0
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +187 -0
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +36 -15
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +34 -23
- package/src/view-plan.test.ts +294 -0
- package/src/view-plan.ts +408 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +70 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
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,130 @@ 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
|
+
const GUI_WAIT_FLAGS: Record<string, string[]> = {
|
|
25
|
+
code: ["--wait"],
|
|
26
|
+
"code-insiders": ["--wait"],
|
|
27
|
+
codium: ["--wait"],
|
|
28
|
+
cursor: ["--wait"],
|
|
29
|
+
windsurf: ["--wait"],
|
|
30
|
+
subl: ["--new-window", "--wait"],
|
|
31
|
+
sublime_text: ["--new-window", "--wait"],
|
|
32
|
+
zed: ["--wait"],
|
|
33
|
+
bbedit: ["--wait"],
|
|
34
|
+
mate: ["--wait"],
|
|
35
|
+
gedit: ["--wait"],
|
|
36
|
+
kate: ["--block"],
|
|
37
|
+
idea: ["--wait"],
|
|
38
|
+
webstorm: ["--wait"],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Editors that hold the terminal themselves - never GUI, never a wait gate. */
|
|
42
|
+
const TERMINAL_EDITORS = new Set([
|
|
43
|
+
"vi",
|
|
44
|
+
"vim",
|
|
45
|
+
"nvim",
|
|
46
|
+
"nano",
|
|
47
|
+
"pico",
|
|
48
|
+
"emacs",
|
|
49
|
+
"emacsclient",
|
|
50
|
+
"micro",
|
|
51
|
+
"hx",
|
|
52
|
+
"helix",
|
|
53
|
+
"kak",
|
|
54
|
+
"ed",
|
|
55
|
+
"joe",
|
|
56
|
+
"mcedit",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
export interface ResolvedEditor {
|
|
60
|
+
argv: string[];
|
|
61
|
+
/** True when this editor blocks the hand-off until the file is closed - a
|
|
62
|
+
* terminal editor, or a known GUI editor with its wait flag applied. */
|
|
63
|
+
waits: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The editor to run: [ui] editor, then the environment chain, then nano. */
|
|
67
|
+
export function resolveEditor(
|
|
68
|
+
configuredEditor: string | undefined,
|
|
69
|
+
env: Record<string, string | undefined> = process.env,
|
|
70
|
+
): string {
|
|
71
|
+
const candidates = [configuredEditor, env.CUELOOP_EDITOR, env.VISUAL, env.EDITOR];
|
|
72
|
+
return candidates.find((candidate) => candidate && candidate.trim())?.trim() ?? DEFAULT_EDITOR;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function editorBaseName(command: string): string {
|
|
76
|
+
return (command.split(/[\\/]/).pop() ?? command).toLowerCase();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Split the editor string and apply a GUI wait flag when we know one. */
|
|
80
|
+
export function resolveEditorCommand(rawEditor: string): ResolvedEditor {
|
|
81
|
+
const parts = rawEditor.trim().split(/\s+/);
|
|
82
|
+
const base = editorBaseName(parts[0] ?? "");
|
|
83
|
+
if (TERMINAL_EDITORS.has(base)) return { argv: parts, waits: true };
|
|
84
|
+
const waitFlags = GUI_WAIT_FLAGS[base];
|
|
85
|
+
if (waitFlags) {
|
|
86
|
+
const alreadyWaits = parts.some(
|
|
87
|
+
(part) => part === "--wait" || part === "-w" || part === "--block",
|
|
88
|
+
);
|
|
89
|
+
return { argv: alreadyWaits ? parts : [...parts, ...waitFlags], waits: true };
|
|
90
|
+
}
|
|
91
|
+
return { argv: parts, waits: false };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A fast return with an untouched file means the editor probably did not wait. */
|
|
95
|
+
const NO_WAIT_THRESHOLD_MS = 1000;
|
|
96
|
+
|
|
97
|
+
function suspectsNoWait(resolved: ResolvedEditor, elapsedMs: number, unchanged: boolean): boolean {
|
|
98
|
+
if (resolved.waits) return false; // holds the terminal or has a wait flag - trust the exit
|
|
99
|
+
if (!unchanged) return false; // edits landed, so it waited after all
|
|
100
|
+
return elapsedMs < NO_WAIT_THRESHOLD_MS;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface EditHandOff {
|
|
104
|
+
/** Editor from [ui] editor config; overrides the environment. */
|
|
105
|
+
editor?: string;
|
|
106
|
+
env?: Record<string, string | undefined>;
|
|
107
|
+
/** Confirm gate for a suspected no-wait return; true re-reads the file. */
|
|
108
|
+
confirmSaved?: (editorLabel: string, path: string) => boolean;
|
|
109
|
+
now?: () => number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Prompt on the released terminal during the suspend window. */
|
|
113
|
+
function promptSaved(editorLabel: string, path: string): boolean {
|
|
114
|
+
const answer = prompt(
|
|
115
|
+
`${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):`,
|
|
116
|
+
);
|
|
117
|
+
return answer !== null && answer.trim().toLowerCase() !== "n";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function editInEditor(
|
|
121
|
+
content: string,
|
|
122
|
+
filename = "plan.md",
|
|
123
|
+
handOff: EditHandOff = {},
|
|
124
|
+
): EditResult {
|
|
125
|
+
const env = handOff.env ?? process.env;
|
|
126
|
+
const now = handOff.now ?? Date.now;
|
|
127
|
+
const resolved = resolveEditorCommand(resolveEditor(handOff.editor, env));
|
|
19
128
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-edit-"));
|
|
20
129
|
const path = join(dir, filename);
|
|
21
130
|
writeFileSync(path, content);
|
|
22
131
|
try {
|
|
23
|
-
const
|
|
132
|
+
const startedAt = now();
|
|
133
|
+
const editorProcess = Bun.spawnSync([...resolved.argv, path], {
|
|
24
134
|
stdin: "inherit",
|
|
25
135
|
stdout: "inherit",
|
|
26
136
|
stderr: "inherit",
|
|
27
137
|
});
|
|
28
|
-
if (
|
|
29
|
-
|
|
138
|
+
if (editorProcess.exitCode !== 0) throw new Error(`editor exited ${editorProcess.exitCode}`);
|
|
139
|
+
let next = readFileSync(path, "utf8");
|
|
140
|
+
if (suspectsNoWait(resolved, now() - startedAt, next === content)) {
|
|
141
|
+
const confirm = handOff.confirmSaved ?? promptSaved;
|
|
142
|
+
if (confirm(resolved.argv[0] ?? "the editor", path)) next = readFileSync(path, "utf8");
|
|
143
|
+
}
|
|
30
144
|
return { content: next, changed: next !== content };
|
|
31
145
|
} finally {
|
|
32
146
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -0,0 +1,45 @@
|
|
|
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 || typeof factory.create === "function").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
|
+
expect(term).not.toBeNull();
|
|
18
|
+
|
|
19
|
+
// Act - plain text then bold + palette-green "GO"
|
|
20
|
+
term.write(new TextEncoder().encode("hi\x1b[1;32mGO\x1b[0m"));
|
|
21
|
+
const cells = [0, 1, 2, 3].map((x) => term.readCell(x, 0));
|
|
22
|
+
|
|
23
|
+
// Assert - glyphs decode, and the green run is bold on ANSI palette index 2
|
|
24
|
+
expect(
|
|
25
|
+
cells.map((c) => (c?.codepoint ? String.fromCodePoint(c.codepoint) : " ")).join(""),
|
|
26
|
+
).toBe("hiGO");
|
|
27
|
+
expect(cells[2]!.bold).toBe(true);
|
|
28
|
+
expect(cells[2]!.fg).toEqual({ kind: "palette", index: 2 });
|
|
29
|
+
term.free();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test.skipIf(!factory)("tracks the cursor position as text is written", () => {
|
|
33
|
+
// Arrange
|
|
34
|
+
const term = factory!.create(80, 24)!;
|
|
35
|
+
|
|
36
|
+
// Act
|
|
37
|
+
term.write(new TextEncoder().encode("abc"));
|
|
38
|
+
|
|
39
|
+
// Assert - cursor advanced past the three glyphs on row 0
|
|
40
|
+
const cursor = term.readCursor();
|
|
41
|
+
expect(cursor.y).toBe(0);
|
|
42
|
+
expect(cursor.x).toBe(3);
|
|
43
|
+
term.free();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A live in-process terminal emulator backed by Ghostty's VT core (libghostty-vt)
|
|
3
|
+
* through a flat FFI shim (native/src/shim.c). Feed it a child process's PTY
|
|
4
|
+
* output with `write`, then read the rendered screen cell-by-cell with `readCell`
|
|
5
|
+
* to paint it anywhere - this is what lets a real `cc`/shell render inside an
|
|
6
|
+
* OpenTUI box. Load with `loadGhosttyTerminals`, which returns null when no
|
|
7
|
+
* prebuilt dylib exists for this platform (callers fall back to a herdr split).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** How a cell's color arrived from Ghostty: the terminal default, an ANSI palette index, or true RGB. */
|
|
15
|
+
export type GhosttyColor =
|
|
16
|
+
| { kind: "default" }
|
|
17
|
+
| { kind: "palette"; index: number }
|
|
18
|
+
| { kind: "rgb"; r: number; g: number; b: number };
|
|
19
|
+
|
|
20
|
+
/** The cursor's viewport position and whether it should be drawn. */
|
|
21
|
+
export interface GhosttyCursor {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
visible: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One decoded screen cell: its glyph, colors, cell width, and text decorations. */
|
|
28
|
+
export interface GhosttyCell {
|
|
29
|
+
/** Unicode code point; 0 = blank. */
|
|
30
|
+
codepoint: number;
|
|
31
|
+
fg: GhosttyColor;
|
|
32
|
+
bg: GhosttyColor;
|
|
33
|
+
/** 0 = wide-char trailing cell (skip it), 1 = normal, 2 = double-width lead. */
|
|
34
|
+
width: number;
|
|
35
|
+
bold: boolean;
|
|
36
|
+
italic: boolean;
|
|
37
|
+
underline: boolean;
|
|
38
|
+
inverse: boolean;
|
|
39
|
+
faint: boolean;
|
|
40
|
+
strikethrough: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CVT_SYMBOLS = {
|
|
44
|
+
cvt_new: { args: [FFIType.u16, FFIType.u16], returns: FFIType.ptr },
|
|
45
|
+
cvt_free: { args: [FFIType.ptr], returns: FFIType.void },
|
|
46
|
+
cvt_write: { args: [FFIType.ptr, FFIType.ptr, FFIType.u64], returns: FFIType.void },
|
|
47
|
+
cvt_resize: { args: [FFIType.ptr, FFIType.u16, FFIType.u16], returns: FFIType.void },
|
|
48
|
+
cvt_cell: { args: [FFIType.ptr, FFIType.u16, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
|
49
|
+
cvt_cursor: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.void },
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
type CvtLib = ReturnType<typeof dlopen<typeof CVT_SYMBOLS>>["symbols"];
|
|
53
|
+
|
|
54
|
+
/** The prebuilt shim dylib for this platform, or null when none ships for it. */
|
|
55
|
+
function nativeLibraryPath(): string | null {
|
|
56
|
+
const suffix = process.platform === "darwin" ? "dylib" : "so";
|
|
57
|
+
const dir = `${process.platform}-${process.arch}`;
|
|
58
|
+
const path = join(import.meta.dir, "..", "native", dir, `libcueloopvt.${suffix}`);
|
|
59
|
+
return existsSync(path) ? path : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Open the Ghostty VT library for this platform, or return null when the prebuilt
|
|
64
|
+
* dylib is missing (the caller degrades to a herdr split instead of embedding).
|
|
65
|
+
*/
|
|
66
|
+
export function loadGhosttyTerminals(): GhosttyTerminalFactory | null {
|
|
67
|
+
const path = nativeLibraryPath();
|
|
68
|
+
if (!path) return null;
|
|
69
|
+
try {
|
|
70
|
+
const lib = dlopen(path, CVT_SYMBOLS);
|
|
71
|
+
return new GhosttyTerminalFactory(lib.symbols);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// The file exists but failed to load (bad arch, missing symbol) - a real
|
|
74
|
+
// fault, not the expected no-prebuilt case; surface it, then degrade.
|
|
75
|
+
console.error(`cueloop: failed to load ${path}:`, error);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Creates `GhosttyTerminal`s that share one loaded copy of the VT library. */
|
|
81
|
+
export class GhosttyTerminalFactory {
|
|
82
|
+
constructor(private readonly lib: CvtLib) {}
|
|
83
|
+
/** A fresh terminal sized to cols x rows, or null if allocation failed. */
|
|
84
|
+
create(cols: number, rows: number): GhosttyTerminal | null {
|
|
85
|
+
const handle = this.lib.cvt_new(cols, rows);
|
|
86
|
+
return handle ? new GhosttyTerminal(this.lib, handle) : null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The flat CvtCell struct the shim writes: u32 codepoint, fg rgba-ish (4 bytes),
|
|
91
|
+
// bg (4 bytes), u8 width, u8 flags, u16 pad = 16 bytes.
|
|
92
|
+
const CELL_STRUCT_BYTES = 16;
|
|
93
|
+
const FLAG_BOLD = 1;
|
|
94
|
+
const FLAG_ITALIC = 2;
|
|
95
|
+
const FLAG_UNDERLINE = 4;
|
|
96
|
+
const FLAG_INVERSE = 8;
|
|
97
|
+
const FLAG_FAINT = 16;
|
|
98
|
+
const FLAG_STRIKETHROUGH = 32;
|
|
99
|
+
|
|
100
|
+
/** A live Ghostty terminal: feed PTY bytes, read the screen, resize, free. */
|
|
101
|
+
export class GhosttyTerminal {
|
|
102
|
+
private readonly out = new Uint8Array(CELL_STRUCT_BYTES);
|
|
103
|
+
private readonly outView = new DataView(this.out.buffer);
|
|
104
|
+
private readonly cursorOut = new Uint8Array(8);
|
|
105
|
+
private freed = false;
|
|
106
|
+
|
|
107
|
+
constructor(
|
|
108
|
+
private readonly lib: CvtLib,
|
|
109
|
+
private readonly handle: NonNullable<ReturnType<CvtLib["cvt_new"]>>,
|
|
110
|
+
) {}
|
|
111
|
+
|
|
112
|
+
/** Process a chunk of the child's PTY output into the screen state. */
|
|
113
|
+
write(bytes: Uint8Array): void {
|
|
114
|
+
if (this.freed || bytes.length === 0) return;
|
|
115
|
+
this.lib.cvt_write(this.handle, ptr(bytes), BigInt(bytes.length));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Resize the screen (and the child's tty, via the caller) to cols x rows. */
|
|
119
|
+
resize(cols: number, rows: number): void {
|
|
120
|
+
if (this.freed) return;
|
|
121
|
+
this.lib.cvt_resize(this.handle, cols, rows);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The rendered cell at viewport (x, y), or null when out of range. */
|
|
125
|
+
readCell(x: number, y: number): GhosttyCell | null {
|
|
126
|
+
if (this.freed) return null;
|
|
127
|
+
if (this.lib.cvt_cell(this.handle, x, y, ptr(this.out)) !== 0) return null;
|
|
128
|
+
const flags = this.out[13]!;
|
|
129
|
+
return {
|
|
130
|
+
codepoint: this.outView.getUint32(0, true),
|
|
131
|
+
fg: decodeColor(this.out[7]!, this.out[4]!, this.out[5]!, this.out[6]!),
|
|
132
|
+
bg: decodeColor(this.out[11]!, this.out[8]!, this.out[9]!, this.out[10]!),
|
|
133
|
+
width: this.out[12]!,
|
|
134
|
+
bold: (flags & FLAG_BOLD) !== 0,
|
|
135
|
+
italic: (flags & FLAG_ITALIC) !== 0,
|
|
136
|
+
underline: (flags & FLAG_UNDERLINE) !== 0,
|
|
137
|
+
inverse: (flags & FLAG_INVERSE) !== 0,
|
|
138
|
+
faint: (flags & FLAG_FAINT) !== 0,
|
|
139
|
+
strikethrough: (flags & FLAG_STRIKETHROUGH) !== 0,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The cursor's current viewport position and visibility. */
|
|
144
|
+
readCursor(): GhosttyCursor {
|
|
145
|
+
if (this.freed) return { x: 0, y: 0, visible: false };
|
|
146
|
+
this.lib.cvt_cursor(this.handle, ptr(this.cursorOut));
|
|
147
|
+
const view = new DataView(this.cursorOut.buffer);
|
|
148
|
+
return {
|
|
149
|
+
x: view.getUint16(0, true),
|
|
150
|
+
y: view.getUint16(2, true),
|
|
151
|
+
visible: this.cursorOut[4] === 1,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Release the terminal; further calls are no-ops. Call once on teardown. */
|
|
156
|
+
free(): void {
|
|
157
|
+
if (this.freed) return;
|
|
158
|
+
this.freed = true;
|
|
159
|
+
this.lib.cvt_free(this.handle);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Decode the shim's (kind, r, g, b) color triple; palette carries its index in r. */
|
|
164
|
+
function decodeColor(kind: number, r: number, g: number, b: number): GhosttyColor {
|
|
165
|
+
if (kind === 2) return { kind: "rgb", r, g, b };
|
|
166
|
+
if (kind === 1) return { kind: "palette", index: r };
|
|
167
|
+
return { kind: "default" };
|
|
168
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { focusHerdrPane } from "./herdr";
|
|
6
|
+
|
|
7
|
+
let dir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-"));
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
rmSync(dir, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("focusHerdrPane", () => {
|
|
17
|
+
test("resolves the pane's tab and focuses it", () => {
|
|
18
|
+
// Arrange
|
|
19
|
+
const log = join(dir, "calls.log");
|
|
20
|
+
const stub = join(dir, "herdr");
|
|
21
|
+
writeFileSync(
|
|
22
|
+
stub,
|
|
23
|
+
`#!/bin/sh
|
|
24
|
+
echo "$@" >> "${log}"
|
|
25
|
+
if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
|
|
26
|
+
`,
|
|
27
|
+
);
|
|
28
|
+
Bun.spawnSync(["chmod", "+x", stub]);
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
31
|
+
expect(focusHerdrPane(stub, "w1:p1")).toBe(true);
|
|
32
|
+
const calls = readFileSync(log, "utf8").trim().split("\n");
|
|
33
|
+
expect(calls[0]).toBe("pane get w1:p1");
|
|
34
|
+
expect(calls[1]).toBe("tab focus w1:t3");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a missing binary never throws", () => {
|
|
38
|
+
// Assert
|
|
39
|
+
expect(focusHerdrPane(join(dir, "nope"), "w1:p1")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/herdr.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-class herdr return: a review opened beside an agent hands focus back
|
|
3
|
+
* to the agent's pane when it closes, not leaving the reviewer on a dead tab.
|
|
4
|
+
* The env contract and the return-target rule live in @cueloop/schema; this
|
|
5
|
+
* module owns the one reviewer-side action that spawns the herdr CLI. Focus
|
|
6
|
+
* is best-effort - a failure never blocks closing the review.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Focus the tab holding the target pane. Synchronous: runs just before exit. */
|
|
10
|
+
export function focusHerdrPane(binPath: string, paneId: string): boolean {
|
|
11
|
+
try {
|
|
12
|
+
const got = Bun.spawnSync([binPath, "pane", "get", paneId], {
|
|
13
|
+
stdout: "pipe",
|
|
14
|
+
stderr: "ignore",
|
|
15
|
+
});
|
|
16
|
+
if (got.exitCode !== 0) return false;
|
|
17
|
+
const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: { tab_id?: string } } };
|
|
18
|
+
const tab = parsed.result?.pane?.tab_id;
|
|
19
|
+
if (!tab) return false;
|
|
20
|
+
return (
|
|
21
|
+
Bun.spawnSync([binPath, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" })
|
|
22
|
+
.exitCode === 0
|
|
23
|
+
);
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,12 @@
|
|
|
1
1
|
export { runClient, type RunClientOptions } from "./run";
|
|
2
2
|
export { serveClient, type ServeHandle, type ServeOptions } from "./serve";
|
|
3
|
+
export { App, type AppProps } from "./App";
|
|
4
|
+
export {
|
|
5
|
+
collaboratorAnnotations,
|
|
6
|
+
publishShare,
|
|
7
|
+
pullShare,
|
|
8
|
+
pushShare,
|
|
9
|
+
shareIdFromLine,
|
|
10
|
+
type ShareResult,
|
|
11
|
+
type ShareTarget,
|
|
12
|
+
} from "./share";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { OBSIDIAN_DEFAULTS } from "@cueloop/integration-obsidian";
|
|
3
|
+
import { loadBundledExporters } from "./integrations";
|
|
4
|
+
import type { IntegrationsConfig } from "./config";
|
|
5
|
+
|
|
6
|
+
function integrations(overrides: Partial<typeof OBSIDIAN_DEFAULTS> = {}): IntegrationsConfig {
|
|
7
|
+
return { obsidian: { ...OBSIDIAN_DEFAULTS, ...overrides } };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("loadBundledExporters", () => {
|
|
11
|
+
test("composes the obsidian integration into one named exporter", async () => {
|
|
12
|
+
// Act
|
|
13
|
+
const exporters = await loadBundledExporters(integrations());
|
|
14
|
+
|
|
15
|
+
// Assert
|
|
16
|
+
expect(exporters.map((exporter) => exporter.name)).toEqual(["obsidian"]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("runsOn mirrors the configured export policy", async () => {
|
|
20
|
+
// Arrange
|
|
21
|
+
const onApprove = (await loadBundledExporters(integrations({ exportOn: "approve" })))[0]!;
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
24
|
+
expect(onApprove.runsOn("approve")).toBeTrue();
|
|
25
|
+
expect(onApprove.runsOn("request_changes")).toBeFalse();
|
|
26
|
+
|
|
27
|
+
// Arrange
|
|
28
|
+
const onResolve = (await loadBundledExporters(integrations({ exportOn: "resolve" })))[0]!;
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
31
|
+
expect(onResolve.runsOn("approve")).toBeTrue();
|
|
32
|
+
expect(onResolve.runsOn("request_changes")).toBeTrue();
|
|
33
|
+
|
|
34
|
+
// Arrange
|
|
35
|
+
const manual = (await loadBundledExporters(integrations({ exportOn: "manual" })))[0]!;
|
|
36
|
+
|
|
37
|
+
// Assert
|
|
38
|
+
expect(manual.runsOn("approve")).toBeFalse();
|
|
39
|
+
expect(manual.runsOn("request_changes")).toBeFalse();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundled integration wiring: composes the configured integrations into
|
|
3
|
+
* generic exporters plus their run policy, so the session controller depends
|
|
4
|
+
* only on the extension seam, never on an integration's own config type. This
|
|
5
|
+
* and config.ts are the only client modules that name a concrete integration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Registry, type Exporter } from "@cueloop/extension-api";
|
|
9
|
+
import { createObsidianExtension, shouldExport } from "@cueloop/integration-obsidian";
|
|
10
|
+
import type { VerdictKind } from "@cueloop/schema";
|
|
11
|
+
import type { IntegrationsConfig } from "./config";
|
|
12
|
+
|
|
13
|
+
/** An exporter plus the policy deciding which verdicts trigger it. */
|
|
14
|
+
export interface BundledExporter {
|
|
15
|
+
name: string;
|
|
16
|
+
run: Exporter;
|
|
17
|
+
runsOn(verdict: VerdictKind): boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Load the bundled integrations from config as generic exporters. */
|
|
21
|
+
export async function loadBundledExporters(
|
|
22
|
+
integrations: IntegrationsConfig,
|
|
23
|
+
): Promise<BundledExporter[]> {
|
|
24
|
+
const registry = new Registry();
|
|
25
|
+
const obsidian = integrations.obsidian;
|
|
26
|
+
const record = await registry.load("obsidian", createObsidianExtension(obsidian));
|
|
27
|
+
const run = record.exporters.get("obsidian");
|
|
28
|
+
if (!run) return [];
|
|
29
|
+
return [{ name: "obsidian", run, runsOn: (verdict) => shouldExport(obsidian.exportOn, verdict) }];
|
|
30
|
+
}
|