@cueloop/client 0.1.0-alpha.40 → 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 +7 -5
- package/src/App.tsx +10 -0
- package/src/components/ReviewRail.tsx +10 -2
- package/src/components/agent-launcher.tsx +62 -5
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +174 -0
- package/src/ghostty-terminal.test.ts +45 -0
- package/src/ghostty-terminal.ts +168 -0
package/native/build.sh
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Rebuild libcueloopvt.dylib: libghostty-vt (Ghostty's VT core) statically linked
|
|
3
|
+
# behind the flat FFI shim in src/shim.c. Pinned to a Ghostty commit + Zig 0.16.0.
|
|
4
|
+
# Output lands in native/<os>-<arch>/libcueloopvt.dylib. Requires curl, git, clang.
|
|
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
|
+
cd "$work"
|
|
14
|
+
|
|
15
|
+
# host triple for the prebuilt dir + zig download
|
|
16
|
+
arch="$(uname -m)"; os="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
|
17
|
+
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
|
|
18
|
+
outdir="$here/${os}-${arch}"
|
|
19
|
+
|
|
20
|
+
if [ ! -x "zig-$arch-$zig_os-$ZIG_VERSION/zig" ]; then
|
|
21
|
+
echo "== fetching zig $ZIG_VERSION =="
|
|
22
|
+
curl -sSL -o zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-$arch-$zig_os-$ZIG_VERSION.tar.xz"
|
|
23
|
+
tar -xf zig.tar.xz
|
|
24
|
+
fi
|
|
25
|
+
zig="$work/zig-$arch-$zig_os-$ZIG_VERSION/zig"
|
|
26
|
+
|
|
27
|
+
if [ ! -d ghostty ]; then
|
|
28
|
+
echo "== cloning ghostty @ $GHOSTTY_COMMIT =="
|
|
29
|
+
mkdir ghostty && cd ghostty && git init -q
|
|
30
|
+
git remote add origin https://github.com/ghostty-org/ghostty.git
|
|
31
|
+
git fetch -q --depth 1 origin "$GHOSTTY_COMMIT"
|
|
32
|
+
git checkout -q FETCH_HEAD
|
|
33
|
+
cd ..
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
echo "== building libghostty-vt (ReleaseFast) =="
|
|
37
|
+
cd ghostty
|
|
38
|
+
# The final xcframework packaging step can fail while the .a we need is produced,
|
|
39
|
+
# so tolerate a non-zero exit here but assert the static lib exists below.
|
|
40
|
+
ZIG_GLOBAL_CACHE_DIR="$work/zig-gcache" ZIG_LOCAL_CACHE_DIR="$work/zig-lcache" \
|
|
41
|
+
"$zig" build -Demit-lib-vt -Doptimize=ReleaseFast --prefix "$work/vt-install" || true
|
|
42
|
+
cd ..
|
|
43
|
+
staticlib="$work/vt-install/lib/libghostty-vt.a"
|
|
44
|
+
[ -f "$staticlib" ] || { echo "build failed: $staticlib not produced" >&2; exit 1; }
|
|
45
|
+
|
|
46
|
+
echo "== linking shim -> $outdir/$dylib =="
|
|
47
|
+
mkdir -p "$outdir"
|
|
48
|
+
if [ "$os" = "darwin" ]; then
|
|
49
|
+
clang -O2 -dynamiclib -o "$outdir/$dylib" "$here/src/shim.c" \
|
|
50
|
+
-I"$work/vt-install/include" "$staticlib" \
|
|
51
|
+
-framework CoreFoundation -framework CoreText -framework CoreGraphics
|
|
52
|
+
else
|
|
53
|
+
clang -O2 -shared -fPIC -o "$outdir/$dylib" "$here/src/shim.c" \
|
|
54
|
+
-I"$work/vt-install/include" "$staticlib" -lm -lpthread
|
|
55
|
+
fi
|
|
56
|
+
echo "== done: $outdir/$dylib =="
|
|
Binary file
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Flat, FFI-trivial wrapper over libghostty-vt: hides the allocator vtable, the
|
|
2
|
+
// by-value GhosttyPoint (which bun:ffi cannot pass), and the packed-cell decode,
|
|
3
|
+
// exposing exactly what an OpenTUI paint loop needs - new/write/resize/cell/free.
|
|
4
|
+
#include <stdint.h>
|
|
5
|
+
#include <stddef.h>
|
|
6
|
+
#include <stdbool.h>
|
|
7
|
+
#include <string.h>
|
|
8
|
+
#include "ghostty/vt/types.h"
|
|
9
|
+
#include "ghostty/vt/allocator.h"
|
|
10
|
+
#include "ghostty/vt/terminal.h"
|
|
11
|
+
#include "ghostty/vt/point.h"
|
|
12
|
+
#include "ghostty/vt/grid_ref.h"
|
|
13
|
+
#include "ghostty/vt/screen.h"
|
|
14
|
+
#include "ghostty/vt/style.h"
|
|
15
|
+
#include "ghostty/vt/color.h"
|
|
16
|
+
|
|
17
|
+
typedef struct {
|
|
18
|
+
uint32_t codepoint; // 0 = blank
|
|
19
|
+
uint8_t fg_r, fg_g, fg_b, fg_kind; // kind: 0 default, 1 palette (index in fg_r), 2 rgb
|
|
20
|
+
uint8_t bg_r, bg_g, bg_b, bg_kind;
|
|
21
|
+
uint8_t width; // 0 (wide trailing), 1, 2
|
|
22
|
+
uint8_t flags; // bit0 bold, 1 italic, 2 underline, 3 inverse, 4 faint, 5 strikethrough
|
|
23
|
+
uint16_t _pad;
|
|
24
|
+
} CvtCell;
|
|
25
|
+
|
|
26
|
+
void *cvt_new(uint16_t cols, uint16_t rows) {
|
|
27
|
+
GhosttyTerminal t = NULL;
|
|
28
|
+
// NULL allocator selects libghostty's default (libc malloc/free when linked).
|
|
29
|
+
if (ghostty_terminal_new(NULL, &t, cols, rows) != GHOSTTY_SUCCESS) return NULL;
|
|
30
|
+
return (void *)t;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
void cvt_free(void *t) {
|
|
34
|
+
if (t) ghostty_terminal_free((GhosttyTerminal)t);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
void cvt_write(void *t, const uint8_t *data, size_t len) {
|
|
38
|
+
ghostty_terminal_vt_write((GhosttyTerminal)t, data, len);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
void cvt_resize(void *t, uint16_t cols, uint16_t rows) {
|
|
42
|
+
// cell pixel size is irrelevant to the character grid; pass a nominal 1x1.
|
|
43
|
+
ghostty_terminal_resize((GhosttyTerminal)t, cols, rows, 1, 1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Writes cursor state into out8: [0..1] col (u16), [2..3] row (u16), [4] visible (u8).
|
|
47
|
+
void cvt_cursor(void *t, uint8_t *out8) {
|
|
48
|
+
uint16_t cx = 0, cy = 0;
|
|
49
|
+
bool vis = false;
|
|
50
|
+
ghostty_terminal_get((GhosttyTerminal)t, GHOSTTY_TERMINAL_DATA_CURSOR_X, &cx);
|
|
51
|
+
ghostty_terminal_get((GhosttyTerminal)t, GHOSTTY_TERMINAL_DATA_CURSOR_Y, &cy);
|
|
52
|
+
ghostty_terminal_get((GhosttyTerminal)t, GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE, &vis);
|
|
53
|
+
memcpy(out8, &cx, 2);
|
|
54
|
+
memcpy(out8 + 2, &cy, 2);
|
|
55
|
+
out8[4] = vis ? 1 : 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static void decode_color(const GhosttyStyleColor *c, uint8_t *r, uint8_t *g, uint8_t *b,
|
|
59
|
+
uint8_t *kind) {
|
|
60
|
+
if (c->tag == GHOSTTY_STYLE_COLOR_RGB) {
|
|
61
|
+
*kind = 2;
|
|
62
|
+
*r = c->value.rgb.r;
|
|
63
|
+
*g = c->value.rgb.g;
|
|
64
|
+
*b = c->value.rgb.b;
|
|
65
|
+
} else if (c->tag == GHOSTTY_STYLE_COLOR_PALETTE) {
|
|
66
|
+
*kind = 1;
|
|
67
|
+
*r = (uint8_t)c->value.palette; // palette index in r; JS maps 0-255 -> RGB
|
|
68
|
+
*g = 0;
|
|
69
|
+
*b = 0;
|
|
70
|
+
} else {
|
|
71
|
+
*kind = 0;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Reads the viewport cell at (x, y). Returns 0 on success, -1 otherwise.
|
|
76
|
+
int cvt_cell(void *t, uint16_t x, uint32_t y, CvtCell *out) {
|
|
77
|
+
memset(out, 0, sizeof(*out));
|
|
78
|
+
GhosttyPoint p;
|
|
79
|
+
p.tag = GHOSTTY_POINT_TAG_VIEWPORT;
|
|
80
|
+
p.value.coordinate.x = x;
|
|
81
|
+
p.value.coordinate.y = y;
|
|
82
|
+
GhosttyGridRef ref;
|
|
83
|
+
if (ghostty_terminal_grid_ref((GhosttyTerminal)t, p, &ref) != GHOSTTY_SUCCESS) return -1;
|
|
84
|
+
GhosttyCell cell;
|
|
85
|
+
if (ghostty_grid_ref_cell(&ref, &cell) != GHOSTTY_SUCCESS) return -1;
|
|
86
|
+
uint32_t cp = 0;
|
|
87
|
+
ghostty_cell_get(cell, GHOSTTY_CELL_DATA_CODEPOINT, &cp);
|
|
88
|
+
out->codepoint = cp;
|
|
89
|
+
uint32_t wide = 0;
|
|
90
|
+
ghostty_cell_get(cell, GHOSTTY_CELL_DATA_WIDE, &wide);
|
|
91
|
+
out->width = (uint8_t)wide;
|
|
92
|
+
GhosttyStyle st;
|
|
93
|
+
ghostty_style_default(&st);
|
|
94
|
+
if (ghostty_grid_ref_style(&ref, &st) == GHOSTTY_SUCCESS) {
|
|
95
|
+
decode_color(&st.fg_color, &out->fg_r, &out->fg_g, &out->fg_b, &out->fg_kind);
|
|
96
|
+
decode_color(&st.bg_color, &out->bg_r, &out->bg_g, &out->bg_b, &out->bg_kind);
|
|
97
|
+
uint8_t f = 0;
|
|
98
|
+
if (st.bold) f |= 1;
|
|
99
|
+
if (st.italic) f |= 2;
|
|
100
|
+
if (st.underline) f |= 4;
|
|
101
|
+
if (st.inverse) f |= 8;
|
|
102
|
+
if (st.faint) f |= 16;
|
|
103
|
+
if (st.strikethrough) f |= 32;
|
|
104
|
+
out->flags = f;
|
|
105
|
+
}
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.41",
|
|
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.
|
|
29
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
30
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
31
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
29
|
+
"@cueloop/daemon": "0.1.0-alpha.41",
|
|
30
|
+
"@cueloop/extension-api": "0.1.0-alpha.41",
|
|
31
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.41",
|
|
32
|
+
"@cueloop/schema": "0.1.0-alpha.41",
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
84
|
-
|
|
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
|
+
// width 0 = the trailing half of a wide glyph already painted at x-1: skip it.
|
|
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,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
|
+
}
|