@celestia-island/hikari 0.55.108 → 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.108",
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": "^*",
@@ -76,6 +76,17 @@ const setWidth = (w: number) => {
76
76
  window.dispatchEvent(new Event("resize"));
77
77
  };
78
78
 
79
+ function paddingInner(container: HTMLElement): HTMLElement | null | undefined {
80
+ const viewport = container.querySelector(".hk-scroll-container-viewport")
81
+ ?? container.querySelector(".hk-scroll-container");
82
+ return viewport?.firstElementChild as HTMLElement | null | undefined;
83
+ }
84
+
85
+ function mobileInnerPadding(container: HTMLElement): string {
86
+ return paddingInner(container)?.style.padding ?? "";
87
+ }
88
+
89
+
79
90
  function shellNode(
80
91
  props: Record<string, unknown>,
81
92
  slots: Record<string, unknown>,
@@ -234,4 +245,37 @@ describe("HkAdminShell", () => {
234
245
  expect(inner).toBeTruthy();
235
246
  expect(inner?.style.padding).toBe("2rem");
236
247
  });
248
+
249
+ it("drops the horizontal half of the content padding below the mobile breakpoint", async () => {
250
+ // Phone width: side gutters must vanish; vertical clearance survives.
251
+ // Asserted via longhands: happy-dom's shorthand parser collapses
252
+ // 3-value paddings, so style.padding cannot distinguish them here.
253
+ setWidth(390);
254
+ const mobileShell = mount(shellNode(
255
+ { navTitle: "Navigation", contentPadding: "2rem" },
256
+ { header: () => null, sidebar: NAV, content: CONTENT },
257
+ ));
258
+ const inner = paddingInner(mobileShell);
259
+ expect(inner?.style.paddingTop).toBe("2rem");
260
+ expect(inner?.style.paddingBottom).toBe("2rem");
261
+ expect(inner?.style.paddingLeft).toBe("0px");
262
+ expect(inner?.style.paddingRight).toBe("0px");
263
+
264
+ // Multi-value shorthand keeps per-side meaning: vertical survives,
265
+ // horizontal is zeroed (never the inverted "1rem 2rem 0").
266
+ const multi = mount(shellNode(
267
+ { navTitle: "Navigation", contentPadding: "1rem 2rem" },
268
+ { header: () => null, sidebar: NAV, content: CONTENT },
269
+ ));
270
+ const multiInner = paddingInner(multi);
271
+ expect(multiInner?.style.paddingTop).toBe("1rem");
272
+ expect(multiInner?.style.paddingBottom).toBe("1rem");
273
+ expect(multiInner?.style.paddingLeft).toBe("0px");
274
+ expect(multiInner?.style.paddingRight).toBe("0px");
275
+
276
+ // Crossing back to desktop restores the verbatim padding.
277
+ setWidth(1280);
278
+ await nextTick();
279
+ expect(mobileInnerPadding(mobileShell)).toBe("2rem");
280
+ });
237
281
  });
@@ -21,7 +21,11 @@ export const HkAdminShell = defineComponent({
21
21
  drawerPanelClass: { type: String, default: undefined },
22
22
  /** Content-area padding (inside the scroll viewport). Padding is
23
23
  * applied to an inner wrapper rather than the scroll container so
24
- * card box-shadows are never clipped at the viewport edges. */
24
+ * card box-shadows are never clipped at the viewport edges. Below
25
+ * the mobile breakpoint the horizontal half is dropped
26
+ * automatically (2026-10-03 user direction: on phones the content
27
+ * area must span the full width for 2D panels and the 3D scene —
28
+ * only the vertical clearance survives). */
25
29
  contentPadding: { type: String, default: "1.5rem" },
26
30
  },
27
31
  setup(props, { slots }) {
@@ -30,6 +34,22 @@ export const HkAdminShell = defineComponent({
30
34
  const isDesktop = computed(() => viewportWidth.value >= props.mobileBreakpoint);
31
35
  const sidebarOpen = ref(false);
32
36
 
37
+ // Content padding with the mobile side-gutter rule applied: desktop
38
+ // gets the value verbatim; below the breakpoint the horizontal
39
+ // tracks become zero while the vertical tracks survive. The CSS
40
+ // padding shorthand is parsed per side so multi-value values keep
41
+ // their meaning ("1rem 2rem" -> "1rem 0", not the invalid or
42
+ // inverted "1rem 2rem 0"). An empty value passes through untouched.
43
+ const mobileSideGutterFree = (value: string): string => {
44
+ const sides = value.trim().split(/\s+/).filter(Boolean);
45
+ if (sides.length === 0) return value;
46
+ const [top, , bottom] = sides;
47
+ return `${top} 0 ${bottom ?? top}`;
48
+ };
49
+ const contentStyle = computed(() => ({
50
+ padding: isDesktop.value ? props.contentPadding : mobileSideGutterFree(props.contentPadding),
51
+ }));
52
+
33
53
  const actionBar = provideActionBar();
34
54
 
35
55
  const toggleHamburger = () => {
@@ -80,7 +100,7 @@ export const HkAdminShell = defineComponent({
80
100
  {/* Padding lives INSIDE the scroll viewport (an inner
81
101
  wrapper) so card box-shadows are not clipped at the
82
102
  viewport edges. */}
83
- <div style={{ padding: props.contentPadding }}>{slots.content?.()}</div>
103
+ <div style={contentStyle.value}>{slots.content?.()}</div>
84
104
  </HScrollContainer>
85
105
  </main>
86
106
 
@@ -47,9 +47,11 @@
47
47
  target; sm keeps its compact --text-xs. xs (2026-10-02, connection
48
48
  popover action row) shrinks the footprint further for inline popover
49
49
  footers: same --text-xs, tighter padding, 1.375rem min-height, and a
50
- 13px glyph (HkButton.tsx scales icon/suffix with the size) inside a
51
- 0.8125rem fixed box so the square-glyph contract keeps its rhythm at
52
- the smaller scale. */
50
+ 13px glyph produced entirely by the CSS chain below (the glyph box
51
+ drops to 0.8125rem and the inner .hk-icon span scales to 100% of it,
52
+ so the svg that always renders at HIcon's size={16} lands at 13px)
53
+ while the square-glyph contract keeps its rhythm at the smaller
54
+ scale. HkButton.tsx itself renders every size's glyph identically. */
53
55
 
54
56
  .hk-btn-xs {
55
57
  padding: var(--space-4) var(--space-8);
@@ -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";