@cueloop/client 0.1.0-alpha.40 → 0.1.0-alpha.42

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.
@@ -0,0 +1,82 @@
1
+ #!/bin/sh
2
+ # Rebuild libcueloopvt.dylib: libghostty-vt (Ghostty's VT core) statically linked
3
+ # behind the flat FFI shim. Pinned to a Ghostty commit + Zig 0.16.0.
4
+ # Output lands in native/<os>-<arch>/libcueloopvt.dylib. Requires curl and git.
5
+ set -eu
6
+
7
+ GHOSTTY_COMMIT="5834a0e3df621802e9578e4562d88b0c2ad4ada8"
8
+ ZIG_VERSION="0.16.0"
9
+
10
+ here="$(cd "$(dirname "$0")" && pwd)"
11
+ work="${TMPDIR:-/tmp}/cueloop-libghostty-build"
12
+ mkdir -p "$work"
13
+ export ZIG_GLOBAL_CACHE_DIR="$work/zig-gcache"
14
+ export ZIG_LOCAL_CACHE_DIR="$work/zig-lcache"
15
+ cd "$work"
16
+
17
+ # Host triple for the Bun prebuilt dir and Zig download. The two tools use
18
+ # different architecture names.
19
+ arch="$(uname -m)"; os="$(uname -s | tr '[:upper:]' '[:lower:]')"
20
+ case "$os" in darwin) zig_os="macos"; dylib="libcueloopvt.dylib";; linux) zig_os="linux"; dylib="libcueloopvt.so";; *) echo "unsupported os $os" >&2; exit 1;; esac
21
+ case "$arch-$os" in
22
+ arm64-darwin|aarch64-linux) zig_arch="aarch64"; bun_arch="arm64";;
23
+ x86_64-darwin|x86_64-linux) zig_arch="x86_64"; bun_arch="x64";;
24
+ *) echo "unsupported architecture $arch on $os" >&2; exit 1;;
25
+ esac
26
+ case "$zig_arch-$zig_os" in
27
+ aarch64-macos) zig_sha="b23d70deaa879b5c2d486ed3316f7eaa53e84acf6fc9cc747de152450d401489";;
28
+ x86_64-macos) zig_sha="0387557ed1877bc6a2e1802c8391953baddba76081876301c522f52977b52ba7";;
29
+ aarch64-linux) zig_sha="ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17";;
30
+ x86_64-linux) zig_sha="70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00";;
31
+ esac
32
+ outdir="$here/${os}-${bun_arch}"
33
+ zig_archive="zig-$zig_arch-$zig_os-$ZIG_VERSION.tar.xz"
34
+
35
+ if [ ! -f "$zig_archive" ]; then
36
+ echo "== fetching zig $ZIG_VERSION =="
37
+ curl -fsSL -o "$zig_archive" "https://ziglang.org/download/$ZIG_VERSION/$zig_archive"
38
+ fi
39
+ printf '%s %s\n' "$zig_sha" "$zig_archive" | shasum -a 256 -c -
40
+
41
+ if [ ! -d ghostty ]; then
42
+ echo "== cloning ghostty @ $GHOSTTY_COMMIT =="
43
+ mkdir ghostty && cd ghostty && git init -q
44
+ git remote add origin https://github.com/ghostty-org/ghostty.git
45
+ cd ..
46
+ fi
47
+ if ! git -C ghostty cat-file -e "$GHOSTTY_COMMIT^{commit}" 2>/dev/null; then
48
+ git -C ghostty fetch -q --depth 1 origin "$GHOSTTY_COMMIT"
49
+ fi
50
+
51
+ # Build only from fresh trees materialized from the verified archive and exact
52
+ # Git object. Cached working-tree edits and stale install outputs cannot enter.
53
+ build_root="$(mktemp -d "$work/native-build.XXXXXX")"
54
+ trap 'rm -rf "$build_root"' EXIT HUP INT TERM
55
+ tar -xf "$zig_archive" -C "$build_root"
56
+ zig="$build_root/zig-$zig_arch-$zig_os-$ZIG_VERSION/zig"
57
+ ghostty_source="$build_root/ghostty"
58
+ mkdir "$ghostty_source"
59
+ git -C ghostty archive "$GHOSTTY_COMMIT" | tar -x -C "$ghostty_source"
60
+
61
+ echo "== building libghostty-vt (ReleaseFast) =="
62
+ cd "$ghostty_source"
63
+ # The final xcframework packaging step can fail while the .a we need is produced,
64
+ # so tolerate a non-zero exit here but assert the static lib exists below.
65
+ vt_install="$build_root/vt-install"
66
+ "$zig" build -Demit-lib-vt -Doptimize=ReleaseFast --prefix "$vt_install" || true
67
+ cd ..
68
+ staticlib="$vt_install/lib/libghostty-vt.a"
69
+ [ -f "$staticlib" ] || { echo "build failed: $staticlib not produced" >&2; exit 1; }
70
+
71
+ echo "== linking shim -> $outdir/$dylib =="
72
+ mkdir -p "$outdir"
73
+ if [ "$os" = "darwin" ]; then
74
+ "$zig" build-lib -dynamic -OReleaseFast -lc -femit-bin="$outdir/$dylib" \
75
+ -install_name "@rpath/$dylib" \
76
+ -I"$vt_install/include" "$here/src/shim.zig" "$staticlib" \
77
+ -framework CoreFoundation -framework CoreText -framework CoreGraphics
78
+ else
79
+ "$zig" build-lib -dynamic -OReleaseFast -lc -femit-bin="$outdir/$dylib" \
80
+ -I"$vt_install/include" "$here/src/shim.zig" "$staticlib" -lm -lpthread
81
+ fi
82
+ echo "== done: $outdir/$dylib =="
@@ -0,0 +1,114 @@
1
+ // Flat FFI wrapper over libghostty-vt. It hides the allocator vtable, the
2
+ // by-value GhosttyPoint that bun:ffi cannot pass, and packed-cell decoding.
3
+ const std = @import("std");
4
+ const ghostty = @cImport({
5
+ @cInclude("ghostty/vt/types.h");
6
+ @cInclude("ghostty/vt/allocator.h");
7
+ @cInclude("ghostty/vt/terminal.h");
8
+ @cInclude("ghostty/vt/point.h");
9
+ @cInclude("ghostty/vt/grid_ref.h");
10
+ @cInclude("ghostty/vt/screen.h");
11
+ @cInclude("ghostty/vt/style.h");
12
+ @cInclude("ghostty/vt/color.h");
13
+ });
14
+
15
+ const CvtCell = extern struct {
16
+ codepoint: u32,
17
+ fg_r: u8,
18
+ fg_g: u8,
19
+ fg_b: u8,
20
+ fg_kind: u8,
21
+ bg_r: u8,
22
+ bg_g: u8,
23
+ bg_b: u8,
24
+ bg_kind: u8,
25
+ width: u8,
26
+ flags: u8,
27
+ _pad: u16,
28
+ };
29
+
30
+ comptime {
31
+ std.debug.assert(@sizeOf(CvtCell) == 16);
32
+ }
33
+
34
+ export fn cvt_new(cols: u16, rows: u16) ?*anyopaque {
35
+ var terminal: ghostty.GhosttyTerminal = null;
36
+ if (ghostty.ghostty_terminal_new(null, &terminal, cols, rows) != ghostty.GHOSTTY_SUCCESS) {
37
+ return null;
38
+ }
39
+ return terminal;
40
+ }
41
+
42
+ export fn cvt_free(terminal: ?*anyopaque) void {
43
+ if (terminal) |handle| ghostty.ghostty_terminal_free(@ptrCast(handle));
44
+ }
45
+
46
+ export fn cvt_write(terminal: *anyopaque, data: [*]const u8, len: usize) void {
47
+ ghostty.ghostty_terminal_vt_write(@ptrCast(terminal), data, len);
48
+ }
49
+
50
+ export fn cvt_resize(terminal: *anyopaque, cols: u16, rows: u16) void {
51
+ // Cell pixel size does not affect the character grid.
52
+ _ = ghostty.ghostty_terminal_resize(@ptrCast(terminal), cols, rows, 1, 1);
53
+ }
54
+
55
+ // Writes [col u16 LE, row u16 LE, visible u8] into the caller's eight bytes.
56
+ export fn cvt_cursor(terminal: *anyopaque, out: [*]u8) void {
57
+ var x: u16 = 0;
58
+ var y: u16 = 0;
59
+ var visible = false;
60
+ _ = ghostty.ghostty_terminal_get(@ptrCast(terminal), ghostty.GHOSTTY_TERMINAL_DATA_CURSOR_X, &x);
61
+ _ = ghostty.ghostty_terminal_get(@ptrCast(terminal), ghostty.GHOSTTY_TERMINAL_DATA_CURSOR_Y, &y);
62
+ _ = ghostty.ghostty_terminal_get(@ptrCast(terminal), ghostty.GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE, &visible);
63
+ out[0] = @truncate(x);
64
+ out[1] = @truncate(x >> 8);
65
+ out[2] = @truncate(y);
66
+ out[3] = @truncate(y >> 8);
67
+ out[4] = @intFromBool(visible);
68
+ }
69
+
70
+ fn decodeColor(color: *const ghostty.GhosttyStyleColor, red: *u8, green: *u8, blue: *u8, kind: *u8) void {
71
+ if (color.tag == ghostty.GHOSTTY_STYLE_COLOR_RGB) {
72
+ kind.* = 2;
73
+ red.* = color.value.rgb.r;
74
+ green.* = color.value.rgb.g;
75
+ blue.* = color.value.rgb.b;
76
+ } else if (color.tag == ghostty.GHOSTTY_STYLE_COLOR_PALETTE) {
77
+ kind.* = 1;
78
+ red.* = @intCast(color.value.palette);
79
+ }
80
+ }
81
+
82
+ export fn cvt_cell(terminal: *anyopaque, x: u16, y: u32, out: *CvtCell) c_int {
83
+ out.* = std.mem.zeroes(CvtCell);
84
+
85
+ var point: ghostty.GhosttyPoint = undefined;
86
+ point.tag = ghostty.GHOSTTY_POINT_TAG_VIEWPORT;
87
+ point.value.coordinate.x = x;
88
+ point.value.coordinate.y = y;
89
+
90
+ var reference: ghostty.GhosttyGridRef = undefined;
91
+ if (ghostty.ghostty_terminal_grid_ref(@ptrCast(terminal), point, &reference) != ghostty.GHOSTTY_SUCCESS) return -1;
92
+
93
+ var cell: ghostty.GhosttyCell = undefined;
94
+ if (ghostty.ghostty_grid_ref_cell(&reference, &cell) != ghostty.GHOSTTY_SUCCESS) return -1;
95
+
96
+ _ = ghostty.ghostty_cell_get(cell, ghostty.GHOSTTY_CELL_DATA_CODEPOINT, &out.codepoint);
97
+ var width: u32 = 0;
98
+ _ = ghostty.ghostty_cell_get(cell, ghostty.GHOSTTY_CELL_DATA_WIDE, &width);
99
+ out.width = @intCast(width);
100
+
101
+ var style: ghostty.GhosttyStyle = undefined;
102
+ ghostty.ghostty_style_default(&style);
103
+ if (ghostty.ghostty_grid_ref_style(&reference, &style) == ghostty.GHOSTTY_SUCCESS) {
104
+ decodeColor(&style.fg_color, &out.fg_r, &out.fg_g, &out.fg_b, &out.fg_kind);
105
+ decodeColor(&style.bg_color, &out.bg_r, &out.bg_g, &out.bg_b, &out.bg_kind);
106
+ if (style.bold) out.flags |= 1;
107
+ if (style.italic) out.flags |= 2;
108
+ if (style.underline != 0) out.flags |= 4;
109
+ if (style.inverse) out.flags |= 8;
110
+ if (style.faint) out.flags |= 16;
111
+ if (style.strikethrough) out.flags |= 32;
112
+ }
113
+ return 0;
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.40",
3
+ "version": "0.1.0-alpha.42",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "files": [
16
16
  "src",
17
+ "native",
17
18
  "README.md"
18
19
  ],
19
20
  "type": "module",
@@ -25,15 +26,16 @@
25
26
  "access": "public"
26
27
  },
27
28
  "dependencies": {
28
- "@cueloop/daemon": "0.1.0-alpha.40",
29
- "@cueloop/extension-api": "0.1.0-alpha.40",
30
- "@cueloop/integration-obsidian": "0.1.0-alpha.40",
31
- "@cueloop/schema": "0.1.0-alpha.40",
29
+ "@cueloop/daemon": "0.1.0-alpha.42",
30
+ "@cueloop/extension-api": "0.1.0-alpha.42",
31
+ "@cueloop/integration-obsidian": "0.1.0-alpha.42",
32
+ "@cueloop/schema": "0.1.0-alpha.42",
32
33
  "@opentui/core": "^0.5.2",
33
34
  "@opentui/keymap": "0.5.2",
34
35
  "@opentui/react": "^0.5.2",
35
36
  "@opentui/ssh": "0.5.2",
36
37
  "@pierre/diffs": "^1.3.5",
38
+ "bun-pty": "^0.4.10",
37
39
  "diff": "^8",
38
40
  "react": "^19.2.0"
39
41
  },
package/src/App.tsx CHANGED
@@ -59,6 +59,7 @@ import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
59
59
  import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
60
60
  import { DiffSheet } from "./components/DiffSheet";
61
61
  import { annotationBlocking, type ReviewRailHandle } from "./components/ReviewRail";
62
+ import type { AgentTerminalHandle } from "./components/agent-launcher";
62
63
  import { ReviewPanel } from "./components/ReviewPanel";
63
64
  import {
64
65
  REVIEW_COMPACT_WIDTH,
@@ -163,6 +164,8 @@ export function App({
163
164
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
164
165
  const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
165
166
  const [railTab, setRailTab] = useState<"review" | "agent">("review");
167
+ // A running in-tab agent terminal (embedded harness); while set, keys route to it.
168
+ const [agentTerminal, setAgentTerminal] = useState<AgentTerminalHandle | null>(null);
166
169
  // review panel layout: mode + expanded width are client view state, loaded
167
170
  // from and persisted to the user config so they survive a restart. The ref
168
171
  // mirrors the width so the drag-end persist reads the latest value.
@@ -584,6 +587,12 @@ export function App({
584
587
  };
585
588
 
586
589
  useKeyboard((key) => {
590
+ // A running in-tab agent terminal owns the keyboard: forward every key to it,
591
+ // with ctrl+] as the detach chord back to the review.
592
+ if (agentTerminal) {
593
+ if (key.ctrl && key.name === "]") return void agentTerminal.detach();
594
+ return void agentTerminal.write(key.sequence);
595
+ }
587
596
  if (menuDialog === "settings") return void handleSettingsKey(key.name);
588
597
  if (menuDialog) return void (key.name === "escape" && setMenuDialog(null));
589
598
  if (menuOpen) return void (key.name === "escape" && setMenuOpen(false));
@@ -1067,6 +1076,7 @@ export function App({
1067
1076
  cwd: activeSession.artifact.meta.cwd ?? process.cwd(),
1068
1077
  seedText,
1069
1078
  }),
1079
+ onAgentTerminal: setAgentTerminal,
1070
1080
  }}
1071
1081
  />
1072
1082
  </box>
@@ -17,7 +17,7 @@ import { Button } from "./primitives/Button";
17
17
  import { Toolbar } from "./primitives/Toolbar";
18
18
  import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
19
19
  import { ConfirmCard, type ConfirmCardProps } from "./ConfirmCard";
20
- import { AgentLauncher } from "./agent-launcher";
20
+ import { AgentLauncher, type AgentTerminalHandle } from "./agent-launcher";
21
21
  import { Card } from "./primitives/Card";
22
22
  import { truncateToSingleLine } from "./truncate-text";
23
23
  import { resolveDisplayName } from "../attribution";
@@ -63,6 +63,8 @@ export interface ReviewRailProps {
63
63
  onSubmitRequest: () => void;
64
64
  /** Launch a bring-your-own harness in the rail (Agent tab); seedText is the plan-context briefing. */
65
65
  onLaunchHarness: (command: string, seedText?: string) => void;
66
+ /** In-tab agent terminal handle (embedded path); null when detached. Lets the app route keys to it. */
67
+ onAgentTerminal?: (handle: AgentTerminalHandle | null) => void;
66
68
  /** Rail column width; the app derives it from the persisted review layout. */
67
69
  width?: number;
68
70
  /**
@@ -97,6 +99,7 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
97
99
  onUndoCuration,
98
100
  onSubmitRequest,
99
101
  onLaunchHarness,
102
+ onAgentTerminal,
100
103
  width = 34,
101
104
  onCollapse,
102
105
  theme,
@@ -201,7 +204,12 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
201
204
  {railTab === "agent" ? (
202
205
  <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 2 }}>
203
206
  <text> </text>
204
- <AgentLauncher session={session} onLaunchHarness={onLaunchHarness} theme={theme} />
207
+ <AgentLauncher
208
+ session={session}
209
+ onLaunchHarness={onLaunchHarness}
210
+ onAgentTerminal={onAgentTerminal}
211
+ theme={theme}
212
+ />
205
213
  </box>
206
214
  ) : openAnnotations.length === 0 && curationItems.length === 0 ? (
207
215
  <box style={{ flexGrow: 1, alignItems: "center", justifyContent: "center" }}>
@@ -5,11 +5,24 @@
5
5
  * without leaving the tab. A plan-context toggle seeds a briefing into the split.
6
6
  */
7
7
 
8
- import React, { useState } from "react";
8
+ import React, { useEffect, useRef, useState } from "react";
9
9
  import type { ReviewSession } from "@cueloop/schema";
10
10
  import type { Theme } from "../theme";
11
11
  import { useComponentTheme } from "./theme-context";
12
12
  import { FRAME_BORDER_STYLE } from "./primitives/frame";
13
+ import {
14
+ embeddedTerminalAvailable,
15
+ registerTerminalPane,
16
+ type TerminalPaneRenderable,
17
+ } from "./terminal-pane";
18
+
19
+ registerTerminalPane();
20
+
21
+ /** A running in-tab agent terminal, handed to the app so it can route keys and detach. */
22
+ export interface AgentTerminalHandle {
23
+ write: (data: string) => void;
24
+ detach: () => void;
25
+ }
13
26
 
14
27
  /** One launchable harness: its rail command and the real logo, rendered as colored rows. */
15
28
  export interface HarnessLauncher {
@@ -62,26 +75,70 @@ function LogoMark({ harness }: { harness: HarnessLauncher }): React.ReactNode {
62
75
  );
63
76
  }
64
77
 
78
+ /** Props for the Agent tab body: the session under review plus the launch callbacks. */
65
79
  export interface AgentLauncherProps {
66
80
  session: ReviewSession;
67
- /** Launch a harness in the rail; seedText is the plan-context briefing when the toggle is on. */
81
+ /**
82
+ * Fallback launch when no embedded terminal ships for this platform: run the
83
+ * harness in a herdr split. seedText is the plan-context briefing.
84
+ */
68
85
  onLaunchHarness: (command: string, seedText?: string) => void;
86
+ /** Notifies the app of the in-tab terminal handle (or null when detached) so it can route keys. */
87
+ onAgentTerminal?: (handle: AgentTerminalHandle | null) => void;
69
88
  theme?: Theme;
70
89
  }
71
90
 
72
91
  /**
73
92
  * The Agent tab body: branded launcher cards plus a plan-context toggle. Replaces
74
- * the old dead agent/status/revision placeholder (now a dim footer line).
93
+ * the old dead agent/status/revision placeholder (now a dim footer line). When a
94
+ * prebuilt libghostty-vt ships for the platform, a picked harness runs embedded
95
+ * inside this tab; otherwise it falls back to a herdr split.
75
96
  */
76
97
  export function AgentLauncher({
77
98
  session,
78
99
  onLaunchHarness,
100
+ onAgentTerminal,
79
101
  theme,
80
102
  }: AgentLauncherProps): React.ReactNode {
81
103
  const tokens = useComponentTheme(theme);
82
104
  const [seedContext, setSeedContext] = useState(true);
83
- const launch = (harness: HarnessLauncher): void =>
84
- onLaunchHarness(harness.command, seedContext ? planHandoffBriefing(session.id) : undefined);
105
+ const [running, setRunning] = useState<{ harness: HarnessLauncher; seed?: string } | null>(null);
106
+ const paneRef = useRef<TerminalPaneRenderable | null>(null);
107
+
108
+ const launch = (harness: HarnessLauncher): void => {
109
+ const seed = seedContext ? planHandoffBriefing(session.id) : undefined;
110
+ if (embeddedTerminalAvailable()) setRunning({ harness, seed });
111
+ else onLaunchHarness(harness.command, seed);
112
+ };
113
+ const detach = (): void => setRunning(null);
114
+
115
+ useEffect(() => {
116
+ if (!running) return onAgentTerminal?.(null);
117
+ onAgentTerminal?.({ write: (data) => paneRef.current?.write(data), detach });
118
+ return () => onAgentTerminal?.(null);
119
+ // eslint-disable-next-line react-hooks/exhaustive-deps
120
+ }, [running]);
121
+
122
+ if (running) {
123
+ return (
124
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
125
+ <box style={{ flexDirection: "row" }} onMouseUp={detach}>
126
+ <text fg={running.harness.color}>{running.harness.name}</text>
127
+ <box style={{ flexGrow: 1 }} />
128
+ <text fg={tokens.textDim}>✕ detach (⌃])</text>
129
+ </box>
130
+ {React.createElement("terminalPane", {
131
+ ref: paneRef,
132
+ command: running.harness.command,
133
+ cwd: session.artifact.meta.cwd ?? process.cwd(),
134
+ seedText: running.seed,
135
+ onExit: detach,
136
+ style: { flexGrow: 1 },
137
+ })}
138
+ </box>
139
+ );
140
+ }
141
+
85
142
  return (
86
143
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
87
144
  <text fg={tokens.text}>Ask an agent about this plan</text>
@@ -0,0 +1,40 @@
1
+ /** The embedded terminal renderable runs a real PTY child through Ghostty's VT and paints its screen; a live `printf` child must show up in the OpenTUI frame. Skipped where no prebuilt libghostty-vt ships for the platform. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import React from "react";
5
+ import { testRender } from "@opentui/react/test-utils";
6
+ import { waitForText } from "../test-support";
7
+ import {
8
+ embeddedTerminalAvailable,
9
+ registerTerminalPane,
10
+ TerminalPaneRenderable,
11
+ } from "./terminal-pane";
12
+
13
+ registerTerminalPane();
14
+
15
+ describe("TerminalPaneRenderable", () => {
16
+ test.skipIf(!embeddedTerminalAvailable())(
17
+ "paints a live child process's output into the box",
18
+ async () => {
19
+ // Arrange / Act - a child that prints a known line
20
+ const setup = await testRender(
21
+ React.createElement("terminalPane", {
22
+ command: "/bin/sh",
23
+ args: ["-c", "printf 'HELLO-EMBED\\n'; sleep 5"],
24
+ style: { width: 40, height: 6 },
25
+ }),
26
+ { width: 40, height: 6 },
27
+ );
28
+
29
+ // Assert - the child's output reaches the painted frame via Ghostty
30
+ await waitForText(setup, "HELLO-EMBED");
31
+ setup.renderer.destroy();
32
+ },
33
+ );
34
+
35
+ test("exposes availability + the renderable class", () => {
36
+ // Assert - the module loads and reports platform support honestly
37
+ expect(typeof embeddedTerminalAvailable()).toBe("boolean");
38
+ expect(TerminalPaneRenderable).toBeDefined();
39
+ });
40
+ });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * An OpenTUI renderable that runs a real child process (a shell, cc, pi, codex)
3
+ * on a PTY and paints its live screen into the box - the terminal-in-the-rail
4
+ * primitive. It wires bun-pty (the child + tty) to a Ghostty VT emulator
5
+ * (ghostty-terminal.ts) and blits the emulator's cell grid every frame via
6
+ * OptimizedBuffer.setCell. Register once with `registerTerminalPane`, then use
7
+ * `<terminalPane command="cc" ... />` in the OpenTUI React tree.
8
+ */
9
+
10
+ import { Renderable, RGBA, createTextAttributes, type RenderContext } from "@opentui/core";
11
+ import type { OptimizedBuffer } from "@opentui/core";
12
+ import type { RenderableOptions } from "@opentui/core";
13
+ import { extend } from "@opentui/react";
14
+ import { spawn, type IPty } from "bun-pty";
15
+ import {
16
+ loadGhosttyTerminals,
17
+ type GhosttyColor,
18
+ type GhosttyTerminal,
19
+ type GhosttyTerminalFactory,
20
+ } from "../ghostty-terminal";
21
+
22
+ /** One shared VT library load; null when this platform ships no prebuilt dylib. */
23
+ let ghosttyFactory: GhosttyTerminalFactory | null | undefined;
24
+ function factory(): GhosttyTerminalFactory | null {
25
+ if (ghosttyFactory === undefined) ghosttyFactory = loadGhosttyTerminals();
26
+ return ghosttyFactory;
27
+ }
28
+
29
+ /** Whether the embedded terminal can run here (the platform dylib is present). */
30
+ export function embeddedTerminalAvailable(): boolean {
31
+ return factory() !== null;
32
+ }
33
+
34
+ /** Props for `<terminalPane>`: the child to run plus its cwd/env and a plan-context seed. */
35
+ export interface TerminalPaneOptions extends RenderableOptions {
36
+ /** The program to run, e.g. "cc" / "pi" / "codex" / a shell. */
37
+ command?: string;
38
+ args?: string[];
39
+ cwd?: string;
40
+ env?: Record<string, string>;
41
+ /** Text left unsubmitted in the child after it starts (plan-context seed). */
42
+ seedText?: string;
43
+ /** Fired when the child process exits. */
44
+ onExit?: (exitCode: number) => void;
45
+ }
46
+
47
+ const encoder = new TextEncoder();
48
+ const DEFAULT_FG = RGBA.fromInts(208, 208, 208, 255);
49
+ const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0);
50
+ /** Glyph color under the block cursor (dark ink on the light cursor block). */
51
+ const CURSOR_INK = RGBA.fromInts(20, 20, 24, 255);
52
+
53
+ /** Runs a PTY child and paints its Ghostty-rendered screen into this box. */
54
+ export class TerminalPaneRenderable extends Renderable {
55
+ private pty: IPty | null = null;
56
+ private vt: GhosttyTerminal | null = null;
57
+ private cols = 0;
58
+ private rows = 0;
59
+ // The seed is held until the child's first output, so it lands in a ready
60
+ // prompt rather than racing the harness's startup (readline/TUI not up yet).
61
+ private pendingSeed: string | undefined;
62
+ private readonly opts: TerminalPaneOptions;
63
+
64
+ constructor(ctx: RenderContext, options: TerminalPaneOptions) {
65
+ super(ctx, options);
66
+ this.opts = options;
67
+ this.focusable = true;
68
+ }
69
+
70
+ /** Start the child + emulator once real layout dimensions are known. */
71
+ private start(cols: number, rows: number): void {
72
+ const terminals = factory();
73
+ if (!terminals) return;
74
+ const vt = terminals.create(cols, rows);
75
+ if (!vt) return; // no emulator, no visible screen - do not spawn a blind child
76
+ this.vt = vt;
77
+ this.cols = cols;
78
+ this.rows = rows;
79
+ this.pendingSeed = this.opts.seedText;
80
+ this.pty = spawn(this.opts.command ?? process.env.SHELL ?? "/bin/sh", this.opts.args ?? [], {
81
+ name: "xterm-256color",
82
+ cols,
83
+ rows,
84
+ cwd: this.opts.cwd ?? process.cwd(),
85
+ env: (this.opts.env ?? process.env) as Record<string, string>,
86
+ });
87
+ this.pty.onData((data) => {
88
+ // bun-pty streams a UTF-8-decoded string (split multibyte is handled); a
89
+ // rare non-UTF-8 byte arrives as U+FFFD - acceptable for agent TUIs.
90
+ this.vt?.write(encoder.encode(data));
91
+ if (this.pendingSeed !== undefined) {
92
+ this.pty?.write(this.pendingSeed);
93
+ this.pendingSeed = undefined;
94
+ }
95
+ this.requestRender();
96
+ });
97
+ this.pty.onExit(({ exitCode }) => this.opts.onExit?.(exitCode));
98
+ }
99
+
100
+ /** Send raw bytes to the child (a key's escape sequence, or pasted text). */
101
+ write(data: string): void {
102
+ this.pty?.write(data);
103
+ }
104
+
105
+ protected onResize(width: number, height: number): void {
106
+ if (width <= 0 || height <= 0) return;
107
+ if (!this.pty) {
108
+ this.start(width, height);
109
+ return;
110
+ }
111
+ if (width === this.cols && height === this.rows) return;
112
+ this.cols = width;
113
+ this.rows = height;
114
+ this.vt?.resize(width, height);
115
+ this.pty.resize(width, height);
116
+ }
117
+
118
+ protected renderSelf(buffer: OptimizedBuffer): void {
119
+ if (!this.vt) return;
120
+ for (let y = 0; y < this.height; y++) {
121
+ for (let x = 0; x < this.width; x++) {
122
+ const cell = this.vt.readCell(x, y);
123
+ // Skip an empty normal-width cell; the buffer already starts blank.
124
+ if (cell && cell.width === 0 && cell.codepoint === 0) continue;
125
+ const char = cell && cell.codepoint ? String.fromCodePoint(cell.codepoint) : " ";
126
+ let fg = cell ? resolveColor(cell.fg, DEFAULT_FG) : DEFAULT_FG;
127
+ let bg = cell ? resolveColor(cell.bg, TRANSPARENT) : TRANSPARENT;
128
+ if (cell?.inverse) [fg, bg] = [bg, fg];
129
+ const attributes = cell
130
+ ? createTextAttributes({
131
+ bold: cell.bold,
132
+ italic: cell.italic,
133
+ underline: cell.underline,
134
+ dim: cell.faint,
135
+ strikethrough: cell.strikethrough,
136
+ })
137
+ : 0;
138
+ buffer.setCell(this.x + x, this.y + y, char, fg, bg, attributes);
139
+ }
140
+ }
141
+ // cursor is active-area coords; equal to viewport at the bottom (the live
142
+ // typing case). It can drift if the child scrolls the viewport back.
143
+ const cursor = this.vt.readCursor();
144
+ if (cursor.visible && cursor.x < this.width && cursor.y < this.height) {
145
+ const under = this.vt.readCell(cursor.x, cursor.y);
146
+ const char = under && under.codepoint ? String.fromCodePoint(under.codepoint) : " ";
147
+ // a block cursor: paint the cell with fg/bg swapped
148
+ buffer.setCell(this.x + cursor.x, this.y + cursor.y, char, CURSOR_INK, DEFAULT_FG, 0);
149
+ }
150
+ }
151
+
152
+ protected destroySelf(): void {
153
+ this.pty?.kill();
154
+ this.vt?.free();
155
+ this.pty = null;
156
+ this.vt = null;
157
+ super.destroySelf();
158
+ }
159
+ }
160
+
161
+ /** Map a Ghostty cell color to an OpenTUI RGBA, using `fallback` for the terminal default. */
162
+ function resolveColor(color: GhosttyColor, fallback: RGBA): RGBA {
163
+ if (color.kind === "rgb") return RGBA.fromInts(color.r, color.g, color.b, 255);
164
+ if (color.kind === "palette") return RGBA.fromIndex(color.index);
165
+ return fallback;
166
+ }
167
+
168
+ let registered = false;
169
+ /** Register `<terminalPane>` with the OpenTUI React reconciler (idempotent). */
170
+ export function registerTerminalPane(): void {
171
+ if (registered) return;
172
+ registered = true;
173
+ extend({ terminalPane: TerminalPaneRenderable });
174
+ }
@@ -0,0 +1,82 @@
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
+
46
+ test.skipIf(!factory)(
47
+ "preserves the native screen contract across resize and styled text",
48
+ () => {
49
+ // Arrange
50
+ const term = factory!.create(8, 2)!;
51
+
52
+ // Act - resize first, then write RGB colors, every supported style, and a wide glyph
53
+ term.resize(6, 3);
54
+ term.write(
55
+ new TextEncoder().encode(
56
+ "\x1b[38;2;12;34;56;48;2;78;90;123;1;3;4;7;9mA\x1b[0m\x1b[2mF\x1b[0m\u754c",
57
+ ),
58
+ );
59
+
60
+ // Assert - the flat native ABI retains cell data and rejects coordinates outside the viewport
61
+ expect(term.readCell(0, 0)).toEqual({
62
+ codepoint: 0x41,
63
+ fg: { kind: "rgb", r: 12, g: 34, b: 56 },
64
+ bg: { kind: "rgb", r: 78, g: 90, b: 123 },
65
+ width: 0,
66
+ bold: true,
67
+ italic: true,
68
+ underline: true,
69
+ inverse: true,
70
+ faint: false,
71
+ strikethrough: true,
72
+ });
73
+ expect(term.readCell(1, 0)?.faint).toBe(true);
74
+ expect(term.readCell(2, 0)?.codepoint).toBe("\u754c".codePointAt(0));
75
+ expect(term.readCell(2, 0)?.width).toBe(1);
76
+ expect(term.readCell(3, 0)?.width).toBe(2);
77
+ expect(term.readCell(6, 0)).toBeNull();
78
+ expect(term.readCell(0, 3)).toBeNull();
79
+ term.free();
80
+ },
81
+ );
82
+ });
@@ -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.zig). 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
+ /** Ghostty width tag: 0 = normal, 1 = double-width lead, 2 = trailing cell. */
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
+ }