@celestia-island/hikari 0.55.109 → 0.55.110
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celestia-island/hikari",
|
|
3
|
-
"version": "0.55.
|
|
3
|
+
"version": "0.55.110",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
|
|
@@ -56,11 +56,14 @@
|
|
|
56
56
|
"vue": "^*"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
+
"@xterm/addon-fit": "^0.*",
|
|
60
|
+
"@xterm/addon-image": "^0.10.0-beta.301",
|
|
61
|
+
"@xterm/xterm": "^6.1.0-beta.304",
|
|
59
62
|
"dompurify": "^*",
|
|
60
63
|
"echarts": "^*",
|
|
64
|
+
"highlight.js": "^*",
|
|
61
65
|
"lucide-vue-next": "^*",
|
|
62
|
-
"marked": "^*"
|
|
63
|
-
"highlight.js": "^*"
|
|
66
|
+
"marked": "^*"
|
|
64
67
|
},
|
|
65
68
|
"devDependencies": {
|
|
66
69
|
"@types/three": "^*",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Use theme variables with namespace to avoid conflicts
|
|
2
|
+
@use "./hikari-vars" as vars;
|
|
3
|
+
|
|
4
|
+
.hk-terminal {
|
|
5
|
+
position: relative;
|
|
6
|
+
display: block;
|
|
7
|
+
width: 100%;
|
|
8
|
+
height: 100%;
|
|
9
|
+
min-height: 8rem;
|
|
10
|
+
padding: 0.35rem 0.45rem;
|
|
11
|
+
background: rgb(var(--color-background, 20 22 26));
|
|
12
|
+
border: 1px solid rgb(var(--color-border, 128 128 128) / 15%);
|
|
13
|
+
border-radius: vars.$hikari-radius-fui-md;
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
|
|
17
|
+
/* xterm fills the host box */
|
|
18
|
+
.terminal,
|
|
19
|
+
.xterm {
|
|
20
|
+
height: 100%;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* The viewport scrollbar inherits the shared chrome shape. */
|
|
24
|
+
.xterm-viewport {
|
|
25
|
+
border-radius: vars.$hikari-radius-fui-sm;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* HkTerminal contract tests.
|
|
6
|
+
*
|
|
7
|
+
* The xterm CORE is mocked (happy-dom has no layout engine — xterm's
|
|
8
|
+
* WidthCache needs real glyph measurement); the mock records the exact
|
|
9
|
+
* calls so the component contract is pinned without executing the
|
|
10
|
+
* renderer:
|
|
11
|
+
*
|
|
12
|
+
* 1. props reach the Terminal options
|
|
13
|
+
* 2. `write` is a binary-safe passthrough (Uint8Array handed over
|
|
14
|
+
* AS-IS — a string round-trip would corrupt kitty/SIXEL payloads)
|
|
15
|
+
* 3. `data` / `resize` events are wired to the consumer
|
|
16
|
+
* 4. the image addon loads with kitty/SIXEL/IIP enabled, and does not
|
|
17
|
+
* load when `enableImages` is false
|
|
18
|
+
* 5. the terminal is disposed on unmount and observers disconnected
|
|
19
|
+
* 6. the theme derivation turns channel triplets into hex colors
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const writeCalls: Array<string | Uint8Array> = [];
|
|
23
|
+
const loadedAddons: Array<{ ctor: string; opts?: Record<string, unknown> }> = [];
|
|
24
|
+
const dataHandlers = new Set<(text: string) => void>();
|
|
25
|
+
const resizeHandlers = new Set<(d: { cols: number; rows: number }) => void>();
|
|
26
|
+
let disposed = 0;
|
|
27
|
+
let lastOptions: Record<string, unknown> | null = null;
|
|
28
|
+
let resizeCalls: Array<{ cols: number; rows: number }> = [];
|
|
29
|
+
|
|
30
|
+
vi.mock("@xterm/xterm", () => ({
|
|
31
|
+
Terminal: class {
|
|
32
|
+
options: Record<string, unknown>;
|
|
33
|
+
cols: number;
|
|
34
|
+
rows: number;
|
|
35
|
+
constructor(opts: Record<string, unknown>) {
|
|
36
|
+
this.options = { ...opts };
|
|
37
|
+
this.cols = (opts.cols as number) ?? 80;
|
|
38
|
+
this.rows = (opts.rows as number) ?? 24;
|
|
39
|
+
lastOptions = this.options;
|
|
40
|
+
}
|
|
41
|
+
loadAddon(addon: { ctor?: string; opts?: Record<string, unknown> }) {
|
|
42
|
+
loadedAddons.push({
|
|
43
|
+
ctor: (addon as { ctor?: string }).ctor ?? "unknown",
|
|
44
|
+
opts: (addon as { opts?: Record<string, unknown> }).opts,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
open() {}
|
|
48
|
+
onData(cb: (text: string) => void) {
|
|
49
|
+
dataHandlers.add(cb);
|
|
50
|
+
return () => dataHandlers.delete(cb);
|
|
51
|
+
}
|
|
52
|
+
onResize(cb: (d: { cols: number; rows: number }) => void) {
|
|
53
|
+
resizeHandlers.add(cb);
|
|
54
|
+
return () => resizeHandlers.delete(cb);
|
|
55
|
+
}
|
|
56
|
+
write(data: string | Uint8Array) {
|
|
57
|
+
writeCalls.push(data);
|
|
58
|
+
}
|
|
59
|
+
writeln(data: string | Uint8Array) {
|
|
60
|
+
writeCalls.push(data);
|
|
61
|
+
writeCalls.push("\r\n");
|
|
62
|
+
}
|
|
63
|
+
clear() {}
|
|
64
|
+
reset() {}
|
|
65
|
+
focus() {}
|
|
66
|
+
resize(cols: number, rows: number) {
|
|
67
|
+
this.cols = cols;
|
|
68
|
+
this.rows = rows;
|
|
69
|
+
resizeCalls.push({ cols, rows });
|
|
70
|
+
// Real xterm fires onResize from resize() — the mock must too, or
|
|
71
|
+
// double-emit bugs in the component hide behind it (R1 F2).
|
|
72
|
+
for (const cb of [...resizeHandlers]) cb({ cols, rows });
|
|
73
|
+
}
|
|
74
|
+
dispose() {
|
|
75
|
+
disposed += 1;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
vi.mock("@xterm/addon-fit", () => ({
|
|
80
|
+
FitAddon: class {
|
|
81
|
+
ctor = "fit";
|
|
82
|
+
fit() {}
|
|
83
|
+
},
|
|
84
|
+
}));
|
|
85
|
+
vi.mock("@xterm/addon-image", () => ({
|
|
86
|
+
ImageAddon: class {
|
|
87
|
+
ctor = "image";
|
|
88
|
+
opts: Record<string, unknown>;
|
|
89
|
+
constructor(opts: Record<string, unknown>) {
|
|
90
|
+
this.opts = opts;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
}));
|
|
94
|
+
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
|
|
95
|
+
|
|
96
|
+
import HkTerminal, { __testables } from "./HkTerminal";
|
|
97
|
+
|
|
98
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
99
|
+
|
|
100
|
+
interface TerminalHarness {
|
|
101
|
+
exposed: Record<string, unknown>;
|
|
102
|
+
unmount: () => void;
|
|
103
|
+
props: Record<string, unknown>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function mountTerminal(props: Record<string, unknown> = {}): TerminalHarness {
|
|
107
|
+
const container = document.createElement("div");
|
|
108
|
+
document.body.appendChild(container);
|
|
109
|
+
const exposedRef = ref<Record<string, unknown> | null>(null);
|
|
110
|
+
const Host = defineComponent({
|
|
111
|
+
setup() {
|
|
112
|
+
return () =>
|
|
113
|
+
h(HkTerminal, {
|
|
114
|
+
...props,
|
|
115
|
+
ref: (el: unknown) => {
|
|
116
|
+
exposedRef.value = el as Record<string, unknown>;
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
const app = createApp(Host);
|
|
122
|
+
app.mount(container);
|
|
123
|
+
mounts.push({ app, container });
|
|
124
|
+
return {
|
|
125
|
+
get exposed() {
|
|
126
|
+
return exposedRef.value ?? {};
|
|
127
|
+
},
|
|
128
|
+
unmount: () => app.unmount(),
|
|
129
|
+
props,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Read a sibling source file (for source-level contract pins). Vitest
|
|
134
|
+
* runs with cwd = packages/vue, which is the stable anchor here. */
|
|
135
|
+
function require_fs_read(name: string): string {
|
|
136
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
137
|
+
const fs = require("node:fs") as typeof import("node:fs");
|
|
138
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
139
|
+
const path = require("node:path") as typeof import("node:path");
|
|
140
|
+
return fs.readFileSync(path.resolve(process.cwd(), "src/components", name), "utf8");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
writeCalls.length = 0;
|
|
145
|
+
loadedAddons.length = 0;
|
|
146
|
+
dataHandlers.clear();
|
|
147
|
+
resizeHandlers.clear();
|
|
148
|
+
disposed = 0;
|
|
149
|
+
lastOptions = null;
|
|
150
|
+
resizeCalls = [];
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
while (mounts.length) {
|
|
155
|
+
const m = mounts.pop()!;
|
|
156
|
+
m.app.unmount();
|
|
157
|
+
m.container.remove();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("HkTerminal", () => {
|
|
162
|
+
it("passes the typography props into the Terminal options", async () => {
|
|
163
|
+
mountTerminal({ fontSize: 15, fontFamily: "MyMono", scrollback: 500, cursorBlink: false });
|
|
164
|
+
await nextTick();
|
|
165
|
+
expect(lastOptions).toMatchObject({
|
|
166
|
+
fontSize: 15,
|
|
167
|
+
fontFamily: "MyMono",
|
|
168
|
+
scrollback: 500,
|
|
169
|
+
cursorBlink: false,
|
|
170
|
+
cols: 80,
|
|
171
|
+
rows: 24,
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("writes bytes through untouched (binary safety)", async () => {
|
|
176
|
+
const t = mountTerminal();
|
|
177
|
+
await nextTick();
|
|
178
|
+
const bytes = new Uint8Array([0x1b, 0x5f, 0x47, 0xe4, 0xbd, 0xa0]);
|
|
179
|
+
(t.exposed.write as (d: Uint8Array) => void)(bytes);
|
|
180
|
+
expect(writeCalls).toHaveLength(1);
|
|
181
|
+
expect(writeCalls[0]).toBe(bytes); // SAME object — no string round-trip
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("forwards xterm data to the consumer event", async () => {
|
|
185
|
+
const seen: string[] = [];
|
|
186
|
+
const container = document.createElement("div");
|
|
187
|
+
document.body.appendChild(container);
|
|
188
|
+
const Host = defineComponent({
|
|
189
|
+
setup() {
|
|
190
|
+
return () =>
|
|
191
|
+
h(HkTerminal, {
|
|
192
|
+
onData: (text: string) => seen.push(text),
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
const app = createApp(Host);
|
|
197
|
+
app.mount(container);
|
|
198
|
+
mounts.push({ app, container });
|
|
199
|
+
await nextTick();
|
|
200
|
+
for (const cb of [...dataHandlers]) cb("ls -la\r");
|
|
201
|
+
expect(seen).toEqual(["ls -la\r"]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("loads the image addon with kitty/SIXEL/IIP support", async () => {
|
|
205
|
+
mountTerminal();
|
|
206
|
+
await nextTick();
|
|
207
|
+
const image = loadedAddons.find((a) => a.ctor === "image");
|
|
208
|
+
expect(image).toBeDefined();
|
|
209
|
+
expect(image!.opts).toMatchObject({
|
|
210
|
+
kittySupport: true,
|
|
211
|
+
sixelSupport: true,
|
|
212
|
+
iipSupport: true,
|
|
213
|
+
enableSizeReports: true,
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("skips the image addon when disabled", async () => {
|
|
218
|
+
mountTerminal({ enableImages: false });
|
|
219
|
+
await nextTick();
|
|
220
|
+
expect(loadedAddons.find((a) => a.ctor === "image")).toBeUndefined();
|
|
221
|
+
expect(loadedAddons.find((a) => a.ctor === "fit")).toBeDefined();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("emits exactly one resize when the cols/rows props change", async () => {
|
|
225
|
+
const resizeEvents: Array<{ cols: number; rows: number }> = [];
|
|
226
|
+
const container = document.createElement("div");
|
|
227
|
+
document.body.appendChild(container);
|
|
228
|
+
const propsRef = ref<Record<string, unknown>>({ cols: 100, rows: 30 });
|
|
229
|
+
const Host = defineComponent({
|
|
230
|
+
setup() {
|
|
231
|
+
return () => h(HkTerminal, {
|
|
232
|
+
...(propsRef.value as Record<string, unknown>),
|
|
233
|
+
onResize: (d: { cols: number; rows: number }) => resizeEvents.push(d),
|
|
234
|
+
});
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
const app = createApp(Host);
|
|
238
|
+
app.mount(container);
|
|
239
|
+
mounts.push({ app, container });
|
|
240
|
+
await nextTick();
|
|
241
|
+
resizeEvents.length = 0;
|
|
242
|
+
propsRef.value = { cols: 120, rows: 40 };
|
|
243
|
+
await nextTick();
|
|
244
|
+
await nextTick();
|
|
245
|
+
// xterm's own onResize already emitted — a manual second emit (the
|
|
246
|
+
// R1 F2 double-fire shape) would make this 2.
|
|
247
|
+
expect(resizeEvents).toEqual([{ cols: 120, rows: 40 }]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("imports xterm's core stylesheet (source pin)", () => {
|
|
251
|
+
// The component is only self-sufficient with xterm.css in place
|
|
252
|
+
// (R1 F1); bundlers prune nothing thanks to sideEffects. A source
|
|
253
|
+
// pin is the honest guard — the runtime import is invisible to the
|
|
254
|
+
// mocked tests by construction.
|
|
255
|
+
const src = require_fs_read("HkTerminal.tsx");
|
|
256
|
+
expect(src).toContain('@xterm/xterm/css/xterm.css');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("disposes the terminal on unmount", async () => {
|
|
260
|
+
const t = mountTerminal();
|
|
261
|
+
await nextTick();
|
|
262
|
+
expect(disposed).toBe(0);
|
|
263
|
+
t.unmount();
|
|
264
|
+
expect(disposed).toBe(1);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("resizes programmatically and reports the new grid", async () => {
|
|
268
|
+
const resizeEvents: Array<{ cols: number; rows: number }> = [];
|
|
269
|
+
const container = document.createElement("div");
|
|
270
|
+
document.body.appendChild(container);
|
|
271
|
+
const Host = defineComponent({
|
|
272
|
+
setup() {
|
|
273
|
+
return () =>
|
|
274
|
+
h(HkTerminal, {
|
|
275
|
+
cols: 100,
|
|
276
|
+
rows: 30,
|
|
277
|
+
onResize: (d: { cols: number; rows: number }) => resizeEvents.push(d),
|
|
278
|
+
});
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
const app = createApp(Host);
|
|
282
|
+
app.mount(container);
|
|
283
|
+
mounts.push({ app, container });
|
|
284
|
+
await nextTick();
|
|
285
|
+
// Simulate xterm's own onResize (what fit() triggers internally).
|
|
286
|
+
for (const cb of [...resizeHandlers]) cb({ cols: 120, rows: 40 });
|
|
287
|
+
expect(resizeEvents).toEqual([{ cols: 120, rows: 40 }]);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("channelToHex", () => {
|
|
292
|
+
it("turns r-g-b channel triplets into hex and keeps passthrough colors", () => {
|
|
293
|
+
const el = document.createElement("div");
|
|
294
|
+
const hex = __testables.channelToHex(el, "--color-background", "#000000");
|
|
295
|
+
// No channel value set in happy-dom → fallback path (unless the env
|
|
296
|
+
// defines one); both arms are valid, assert it returns a color.
|
|
297
|
+
expect(hex).toMatch(/^(#[0-9a-fA-F]{6}|rgb\(.*\))$/);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineComponent,
|
|
3
|
+
onBeforeUnmount,
|
|
4
|
+
onMounted,
|
|
5
|
+
ref,
|
|
6
|
+
watch,
|
|
7
|
+
} from "vue";
|
|
8
|
+
|
|
9
|
+
import { FitAddon } from "@xterm/addon-fit";
|
|
10
|
+
import { ImageAddon } from "@xterm/addon-image";
|
|
11
|
+
import { Terminal, type ITheme } from "@xterm/xterm";
|
|
12
|
+
|
|
13
|
+
// xterm's own core stylesheet (element layout, viewport scrolling, the
|
|
14
|
+
// hidden helper textarea) — the component is self-sufficient only with
|
|
15
|
+
// it in place; sideEffects in package.json keeps bundlers from pruning.
|
|
16
|
+
import "@xterm/xterm/css/xterm.css";
|
|
17
|
+
|
|
18
|
+
import "./HkTerminal.scss";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* HkTerminal — the shared terminal rendering surface (a rich component in
|
|
22
|
+
* the HkMarkdownRenderer class, not a thin wrapper).
|
|
23
|
+
*
|
|
24
|
+
* Owns the whole xterm.js lifecycle: creation, fitting, image-protocol
|
|
25
|
+
* decoding, theming from the hikari theme channels, and disposal. The
|
|
26
|
+
* consumer owns the DATA — it feeds bytes in through `write` (Uint8Array
|
|
27
|
+
* passthrough keeps split UTF-8 and APC/SIXEL/kitty payloads intact) and
|
|
28
|
+
* listens to `data` / `resize` to drive its transport.
|
|
29
|
+
*
|
|
30
|
+
* Props
|
|
31
|
+
* - `cols` / `rows`: initial grid (autoFit re-fits to the host afterwards).
|
|
32
|
+
* - `autoFit`: keep the grid matched to the host box (ResizeObserver).
|
|
33
|
+
* (Mount-only: toggling after mount has no effect.)
|
|
34
|
+
* - `enableImages`: decode kitty graphics / SIXEL / iTerm2 IIP payloads
|
|
35
|
+
* in-band (remote-control sessions push screenshots through the PTY;
|
|
36
|
+
* mount-only).
|
|
37
|
+
* - `fontSize` / `fontFamily`: terminal typography (defaults to the
|
|
38
|
+
* hikari mono stack).
|
|
39
|
+
* - `scrollback`: scrollback buffer size.
|
|
40
|
+
* - `cursorBlink`: blinking caret.
|
|
41
|
+
*
|
|
42
|
+
* Events
|
|
43
|
+
* - `data(text)`: keystrokes and pastes (UTF-8 text, as xterm emits).
|
|
44
|
+
* - `resize({cols, rows})`: the grid changed (autoFit or option change).
|
|
45
|
+
*
|
|
46
|
+
* Exposed API
|
|
47
|
+
* - `write(data: string | Uint8Array)` — binary-safe feed.
|
|
48
|
+
* - `writeln`, `clear`, `reset`, `fit`, `focus`, `dimensions`.
|
|
49
|
+
*
|
|
50
|
+
* Theming: the xterm theme is derived from the hikari color channels
|
|
51
|
+
* (`--color-background` / `--color-text` / `--color-primary` / …) at
|
|
52
|
+
* mount, and re-derived when the document element's class or style
|
|
53
|
+
* changes (the light/dark switch mechanism consumers use).
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/** Resolve an `r g b` triplet channel (with fallback) to `#rrggbb`. */
|
|
57
|
+
function channelToHex(host: HTMLElement, name: string, fallback: string): string {
|
|
58
|
+
const raw = getComputedStyle(host).getPropertyValue(name).trim();
|
|
59
|
+
const parts = raw.split(/\s+/).map((p) => Number.parseFloat(p));
|
|
60
|
+
if (parts.length === 3 && parts.every((n) => Number.isFinite(n) && n >= 0 && n <= 255)) {
|
|
61
|
+
return (
|
|
62
|
+
"#" +
|
|
63
|
+
parts
|
|
64
|
+
.map((n) => Math.round(n).toString(16).padStart(2, "0"))
|
|
65
|
+
.join("")
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
// Some channels are already full colors (rgb(...) / #hex) — pass through.
|
|
69
|
+
if (raw && !raw.includes(" ")) return raw;
|
|
70
|
+
return fallback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function deriveXtermTheme(host: HTMLElement): ITheme {
|
|
74
|
+
return {
|
|
75
|
+
background: channelToHex(host, "--color-background", "#14161a"),
|
|
76
|
+
foreground: channelToHex(host, "--color-text", "#e6e6e6"),
|
|
77
|
+
cursor: channelToHex(host, "--color-primary", "#7aa2f7"),
|
|
78
|
+
cursorAccent: channelToHex(host, "--color-background", "#14161a"),
|
|
79
|
+
selectionBackground: channelToHex(host, "--color-selected-bg", "#3b4356") + "88",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const FAMILY_FALLBACK =
|
|
84
|
+
"ui-monospace, 'SF Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace";
|
|
85
|
+
|
|
86
|
+
export default defineComponent({
|
|
87
|
+
name: "HkTerminal",
|
|
88
|
+
props: {
|
|
89
|
+
cols: { type: Number, default: 80 },
|
|
90
|
+
rows: { type: Number, default: 24 },
|
|
91
|
+
autoFit: { type: Boolean, default: true },
|
|
92
|
+
enableImages: { type: Boolean, default: true },
|
|
93
|
+
fontSize: { type: Number, default: 13 },
|
|
94
|
+
fontFamily: { type: String, default: FAMILY_FALLBACK },
|
|
95
|
+
scrollback: { type: Number, default: 2000 },
|
|
96
|
+
cursorBlink: { type: Boolean, default: true },
|
|
97
|
+
},
|
|
98
|
+
emits: {
|
|
99
|
+
data: (text: string) => typeof text === "string",
|
|
100
|
+
resize: (dims: { cols: number; rows: number }) =>
|
|
101
|
+
typeof dims?.cols === "number" && typeof dims?.rows === "number",
|
|
102
|
+
},
|
|
103
|
+
setup(props, { emit, expose }) {
|
|
104
|
+
const hostRef = ref<HTMLElement>();
|
|
105
|
+
let term: Terminal | null = null;
|
|
106
|
+
let fitAddon: FitAddon | null = null;
|
|
107
|
+
let observer: ResizeObserver | null = null;
|
|
108
|
+
let themeObserver: MutationObserver | null = null;
|
|
109
|
+
|
|
110
|
+
function emitResize(cols: number, rows: number) {
|
|
111
|
+
emit("resize", { cols, rows });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
onMounted(() => {
|
|
115
|
+
const host = hostRef.value;
|
|
116
|
+
if (!host) return;
|
|
117
|
+
const t = new Terminal({
|
|
118
|
+
cursorBlink: props.cursorBlink,
|
|
119
|
+
fontSize: props.fontSize,
|
|
120
|
+
fontFamily: props.fontFamily,
|
|
121
|
+
cols: props.cols,
|
|
122
|
+
rows: props.rows,
|
|
123
|
+
scrollback: props.scrollback,
|
|
124
|
+
theme: deriveXtermTheme(host),
|
|
125
|
+
allowProposedApi: true,
|
|
126
|
+
});
|
|
127
|
+
const fa = new FitAddon();
|
|
128
|
+
t.loadAddon(fa);
|
|
129
|
+
if (props.enableImages) {
|
|
130
|
+
// Image protocols: kitty graphics (APC _G) + SIXEL + iTerm2 IIP —
|
|
131
|
+
// remote-control sessions push screenshots and plots through the
|
|
132
|
+
// same PTY stream; their escape sequences must render as images.
|
|
133
|
+
// 0.10.0-beta is currently the only kitty implementation for
|
|
134
|
+
// xterm.js (alpha upstream).
|
|
135
|
+
t.loadAddon(
|
|
136
|
+
new ImageAddon({
|
|
137
|
+
enableSizeReports: true,
|
|
138
|
+
sixelSupport: true,
|
|
139
|
+
iipSupport: true,
|
|
140
|
+
kittySupport: true,
|
|
141
|
+
sixelPaletteLimit: 256,
|
|
142
|
+
storageLimit: 256,
|
|
143
|
+
showPlaceholder: false,
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
t.open(host);
|
|
148
|
+
t.onData((text) => emit("data", text));
|
|
149
|
+
t.onResize(({ cols, rows }) => emitResize(cols, rows));
|
|
150
|
+
term = t;
|
|
151
|
+
fitAddon = fa;
|
|
152
|
+
if (props.autoFit) {
|
|
153
|
+
fa.fit();
|
|
154
|
+
observer = new ResizeObserver(() => {
|
|
155
|
+
if (!fitAddon || !term) return;
|
|
156
|
+
fitAddon.fit();
|
|
157
|
+
});
|
|
158
|
+
observer.observe(host);
|
|
159
|
+
}
|
|
160
|
+
// Re-derive colors when the consumer flips the theme (host apps
|
|
161
|
+
// switch light/dark by mutating :root class or style).
|
|
162
|
+
const retheme = () => {
|
|
163
|
+
if (term && hostRef.value) term.options.theme = deriveXtermTheme(hostRef.value);
|
|
164
|
+
};
|
|
165
|
+
if (typeof MutationObserver !== "undefined") {
|
|
166
|
+
themeObserver = new MutationObserver(retheme);
|
|
167
|
+
themeObserver.observe(document.documentElement, {
|
|
168
|
+
attributes: true,
|
|
169
|
+
attributeFilter: ["class", "style", "data-theme", "data-mode"],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
onBeforeUnmount(() => {
|
|
175
|
+
observer?.disconnect();
|
|
176
|
+
observer = null;
|
|
177
|
+
themeObserver?.disconnect();
|
|
178
|
+
themeObserver = null;
|
|
179
|
+
term?.dispose();
|
|
180
|
+
term = null;
|
|
181
|
+
fitAddon = null;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Live option updates (recreate-free where xterm supports it).
|
|
185
|
+
watch(
|
|
186
|
+
() => [props.fontSize, props.fontFamily, props.scrollback, props.cursorBlink],
|
|
187
|
+
([fontSize, fontFamily, scrollback, cursorBlink]) => {
|
|
188
|
+
if (!term) return;
|
|
189
|
+
term.options.fontSize = fontSize as number;
|
|
190
|
+
term.options.fontFamily = fontFamily as string;
|
|
191
|
+
term.options.scrollback = scrollback as number;
|
|
192
|
+
term.options.cursorBlink = cursorBlink as boolean;
|
|
193
|
+
fitAddon?.fit();
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
watch(
|
|
197
|
+
() => [props.cols, props.rows],
|
|
198
|
+
([cols, rows]) => {
|
|
199
|
+
if (!term) return;
|
|
200
|
+
if (term.cols !== (cols as number) || term.rows !== (rows as number)) {
|
|
201
|
+
// term.resize fires xterm's own onResize, which emits to the
|
|
202
|
+
// consumer exactly once — emitting here as well would double
|
|
203
|
+
// every resize.
|
|
204
|
+
term.resize(cols as number, rows as number);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expose({
|
|
210
|
+
/** Binary-safe feed: Uint8Array goes to xterm's UTF-8 decoder, so
|
|
211
|
+
* multi-byte sequences split across writes survive. */
|
|
212
|
+
write(data: string | Uint8Array) {
|
|
213
|
+
term?.write(data);
|
|
214
|
+
},
|
|
215
|
+
writeln(data: string) {
|
|
216
|
+
term?.writeln(data);
|
|
217
|
+
},
|
|
218
|
+
clear() {
|
|
219
|
+
term?.clear();
|
|
220
|
+
},
|
|
221
|
+
reset() {
|
|
222
|
+
term?.reset();
|
|
223
|
+
},
|
|
224
|
+
fit() {
|
|
225
|
+
fitAddon?.fit();
|
|
226
|
+
},
|
|
227
|
+
focus() {
|
|
228
|
+
term?.focus();
|
|
229
|
+
},
|
|
230
|
+
/** Current grid size (null before mount). */
|
|
231
|
+
get dimensions(): { cols: number; rows: number } | null {
|
|
232
|
+
return term ? { cols: term.cols, rows: term.rows } : null;
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return () => <div ref={hostRef} class="hk-terminal" />;
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
/** Test seam: pure helpers (no DOM dependency beyond getComputedStyle). */
|
|
241
|
+
export const __testables = { channelToHex };
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ export { default as HLabel } from "./components/HkLabel";
|
|
|
41
41
|
export { default as HListTransition } from "./components/HkListTransition";
|
|
42
42
|
export { default as HLoadMore } from "./components/HkLoadMore";
|
|
43
43
|
export { default as HMarkdownRenderer } from "./components/HkMarkdownRenderer";
|
|
44
|
+
export { default as HTerminal } from "./components/HkTerminal";
|
|
44
45
|
export { default as HModal } from "./components/HkModal";
|
|
45
46
|
export { default as HNavItem } from "./components/HkNavItem";
|
|
46
47
|
export { default as HNumberInput } from "./components/HkNumberInput";
|
|
@@ -700,6 +701,7 @@ export { default as HkLabel } from "./components/HkLabel";
|
|
|
700
701
|
export { default as HkListTransition } from "./components/HkListTransition";
|
|
701
702
|
export { default as HkLoadMore } from "./components/HkLoadMore";
|
|
702
703
|
export { default as HkMarkdownRenderer } from "./components/HkMarkdownRenderer";
|
|
704
|
+
export { default as HkTerminal } from "./components/HkTerminal";
|
|
703
705
|
export { default as HkModal } from "./components/HkModal";
|
|
704
706
|
export { default as HkNavItem } from "./components/HkNavItem";
|
|
705
707
|
export { default as HkNumberInput } from "./components/HkNumberInput";
|