@vibesdotdev/terminal-client 0.0.0-stage → 1.0.0-rc.3

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.
Files changed (201) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +102 -2
  3. package/dist/ansi/index.d.ts +11 -0
  4. package/dist/ansi/index.d.ts.map +1 -0
  5. package/dist/ansi/index.js +11 -0
  6. package/dist/ansi/index.js.map +1 -0
  7. package/dist/ansi/keymap.d.ts +77 -0
  8. package/dist/ansi/keymap.d.ts.map +1 -0
  9. package/dist/ansi/keymap.js +257 -0
  10. package/dist/ansi/keymap.js.map +1 -0
  11. package/dist/api-client-plugin.d.ts +4 -0
  12. package/dist/api-client-plugin.d.ts.map +1 -0
  13. package/dist/api-client-plugin.js +16 -0
  14. package/dist/api-client-plugin.js.map +1 -0
  15. package/dist/client/http-session.d.ts +44 -0
  16. package/dist/client/http-session.d.ts.map +1 -0
  17. package/dist/client/http-session.js +457 -0
  18. package/dist/client/http-session.js.map +1 -0
  19. package/dist/client/index.d.ts +8 -0
  20. package/dist/client/index.d.ts.map +1 -0
  21. package/dist/client/index.js +8 -0
  22. package/dist/client/index.js.map +1 -0
  23. package/dist/client.d.ts +56 -0
  24. package/dist/client.d.ts.map +1 -0
  25. package/dist/client.js +169 -0
  26. package/dist/client.js.map +1 -0
  27. package/dist/index.d.ts +14 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +14 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/pty-bridge/index.d.ts +15 -0
  32. package/dist/pty-bridge/index.d.ts.map +1 -0
  33. package/dist/pty-bridge/index.js +15 -0
  34. package/dist/pty-bridge/index.js.map +1 -0
  35. package/dist/pty-bridge/protocol.d.ts +6 -0
  36. package/dist/pty-bridge/protocol.d.ts.map +1 -0
  37. package/dist/pty-bridge/protocol.js +6 -0
  38. package/dist/pty-bridge/protocol.js.map +1 -0
  39. package/dist/pty-bridge/server.d.ts +7 -0
  40. package/dist/pty-bridge/server.d.ts.map +1 -0
  41. package/dist/pty-bridge/server.js +9 -0
  42. package/dist/pty-bridge/server.js.map +1 -0
  43. package/dist/remote-session.d.ts +51 -0
  44. package/dist/remote-session.d.ts.map +1 -0
  45. package/dist/remote-session.js +326 -0
  46. package/dist/remote-session.js.map +1 -0
  47. package/dist/schemas/http.d.ts +61 -0
  48. package/dist/schemas/http.d.ts.map +1 -0
  49. package/dist/schemas/http.js +45 -0
  50. package/dist/schemas/http.js.map +1 -0
  51. package/dist/schemas/remote.d.ts +25 -0
  52. package/dist/schemas/remote.d.ts.map +1 -0
  53. package/dist/schemas/remote.js +23 -0
  54. package/dist/schemas/remote.js.map +1 -0
  55. package/dist/schemas/session.d.ts +26 -0
  56. package/dist/schemas/session.d.ts.map +1 -0
  57. package/dist/schemas/session.js +17 -0
  58. package/dist/schemas/session.js.map +1 -0
  59. package/dist/schemas/store.d.ts +29 -0
  60. package/dist/schemas/store.d.ts.map +1 -0
  61. package/dist/schemas/store.js +22 -0
  62. package/dist/schemas/store.js.map +1 -0
  63. package/dist/schemas/terminal.d.ts +296 -0
  64. package/dist/schemas/terminal.d.ts.map +1 -0
  65. package/dist/schemas/terminal.js +243 -0
  66. package/dist/schemas/terminal.js.map +1 -0
  67. package/dist/server/index.d.ts +9 -0
  68. package/dist/server/index.d.ts.map +1 -0
  69. package/dist/server/index.js +9 -0
  70. package/dist/server/index.js.map +1 -0
  71. package/dist/server/session-store.d.ts +176 -0
  72. package/dist/server/session-store.d.ts.map +1 -0
  73. package/dist/server/session-store.js +462 -0
  74. package/dist/server/session-store.js.map +1 -0
  75. package/dist/server/sse-stream.d.ts +32 -0
  76. package/dist/server/sse-stream.d.ts.map +1 -0
  77. package/dist/server/sse-stream.js +87 -0
  78. package/dist/server/sse-stream.js.map +1 -0
  79. package/dist/svelte/HostPicker.svelte +263 -0
  80. package/dist/svelte/HostPicker.svelte.d.ts +36 -0
  81. package/dist/svelte/HostPicker.svelte.d.ts.map +1 -0
  82. package/dist/svelte/README.md +75 -0
  83. package/dist/svelte/ReconnectBanner.svelte +88 -0
  84. package/dist/svelte/ReconnectBanner.svelte.d.ts +16 -0
  85. package/dist/svelte/ReconnectBanner.svelte.d.ts.map +1 -0
  86. package/dist/svelte/SessionTabs.svelte +192 -0
  87. package/dist/svelte/SessionTabs.svelte.d.ts +17 -0
  88. package/dist/svelte/SessionTabs.svelte.d.ts.map +1 -0
  89. package/dist/svelte/Terminal.svelte +567 -0
  90. package/dist/svelte/Terminal.svelte.d.ts +25 -0
  91. package/dist/svelte/Terminal.svelte.d.ts.map +1 -0
  92. package/dist/svelte/TerminalHostWorkspace.svelte +264 -0
  93. package/dist/svelte/TerminalHostWorkspace.svelte.d.ts +59 -0
  94. package/dist/svelte/TerminalHostWorkspace.svelte.d.ts.map +1 -0
  95. package/dist/svelte/TerminalMobileToolbar.svelte +56 -0
  96. package/dist/svelte/TerminalMobileToolbar.svelte.d.ts +14 -0
  97. package/dist/svelte/TerminalMobileToolbar.svelte.d.ts.map +1 -0
  98. package/dist/svelte/TerminalWorkspace.svelte +562 -0
  99. package/dist/svelte/TerminalWorkspace.svelte.d.ts +25 -0
  100. package/dist/svelte/TerminalWorkspace.svelte.d.ts.map +1 -0
  101. package/dist/svelte/ambient.d.ts +9 -0
  102. package/dist/svelte/docs/terminal-svelte.docs.descriptor.d.ts +4 -0
  103. package/dist/svelte/docs/terminal-svelte.docs.descriptor.d.ts.map +1 -0
  104. package/dist/svelte/docs/terminal-svelte.docs.descriptor.js +74 -0
  105. package/dist/svelte/index.d.ts +29 -0
  106. package/dist/svelte/index.d.ts.map +1 -0
  107. package/dist/svelte/index.js +28 -0
  108. package/dist/svelte/styles.css +8 -0
  109. package/dist/svelte/terminal-host-workspace.d.ts +152 -0
  110. package/dist/svelte/terminal-host-workspace.d.ts.map +1 -0
  111. package/dist/svelte/terminal-host-workspace.js +335 -0
  112. package/dist/svelte/terminal-session-authority.d.ts +17 -0
  113. package/dist/svelte/terminal-session-authority.d.ts.map +1 -0
  114. package/dist/svelte/terminal-session-authority.js +25 -0
  115. package/dist/svelte/terminal-svelte.plugin.d.ts +21 -0
  116. package/dist/svelte/terminal-svelte.plugin.d.ts.map +1 -0
  117. package/dist/svelte/terminal-svelte.plugin.js +53 -0
  118. package/dist/svelte/terminal-theme.d.ts +11 -0
  119. package/dist/svelte/terminal-theme.d.ts.map +1 -0
  120. package/dist/svelte/terminal-theme.js +116 -0
  121. package/dist/svelte/tool-widgets/TerminalToolCard.svelte +65 -0
  122. package/dist/svelte/tool-widgets/TerminalToolCard.svelte.d.ts +5 -0
  123. package/dist/svelte/tool-widgets/TerminalToolCard.svelte.d.ts.map +1 -0
  124. package/dist/svelte/tool-widgets/terminal-tool-card.d.ts +25 -0
  125. package/dist/svelte/tool-widgets/terminal-tool-card.d.ts.map +1 -0
  126. package/dist/svelte/tool-widgets/terminal-tool-card.js +116 -0
  127. package/dist/svelte/types.d.ts +160 -0
  128. package/dist/svelte/types.d.ts.map +1 -0
  129. package/dist/svelte/types.js +51 -0
  130. package/dist/svelte/workspace.d.ts +3 -0
  131. package/dist/svelte/workspace.d.ts.map +1 -0
  132. package/dist/svelte/workspace.js +2 -0
  133. package/dist/terminal-client.plugin.d.ts +3 -0
  134. package/dist/terminal-client.plugin.d.ts.map +1 -0
  135. package/dist/terminal-client.plugin.js +3 -0
  136. package/dist/terminal-client.plugin.js.map +1 -0
  137. package/dist/terminal-runtime.d.ts +30 -0
  138. package/dist/terminal-runtime.d.ts.map +1 -0
  139. package/dist/terminal-runtime.js +673 -0
  140. package/dist/terminal-runtime.js.map +1 -0
  141. package/dist/terminal.consumer.plugin.d.ts +14 -0
  142. package/dist/terminal.consumer.plugin.d.ts.map +1 -0
  143. package/dist/terminal.consumer.plugin.js +31 -0
  144. package/dist/terminal.consumer.plugin.js.map +1 -0
  145. package/dist/tools/terminal.impl.consumer.d.ts +11 -0
  146. package/dist/tools/terminal.impl.consumer.d.ts.map +1 -0
  147. package/dist/tools/terminal.impl.consumer.js +20 -0
  148. package/dist/tools/terminal.impl.consumer.js.map +1 -0
  149. package/dist/tools/terminal.schemas.d.ts +3 -0
  150. package/dist/tools/terminal.schemas.d.ts.map +1 -0
  151. package/dist/tools/terminal.schemas.js +3 -0
  152. package/dist/tools/terminal.schemas.js.map +1 -0
  153. package/dist/tools/terminal.unified.descriptor.d.ts +218 -0
  154. package/dist/tools/terminal.unified.descriptor.d.ts.map +1 -0
  155. package/dist/tools/terminal.unified.descriptor.js +36 -0
  156. package/dist/tools/terminal.unified.descriptor.js.map +1 -0
  157. package/dist/tui/index.d.ts +9 -0
  158. package/dist/tui/index.d.ts.map +1 -0
  159. package/dist/tui/index.js +8 -0
  160. package/dist/tui/index.js.map +1 -0
  161. package/dist/tui/terminal-open.descriptor.d.ts +9 -0
  162. package/dist/tui/terminal-open.descriptor.d.ts.map +1 -0
  163. package/dist/tui/terminal-open.descriptor.js +18 -0
  164. package/dist/tui/terminal-open.descriptor.js.map +1 -0
  165. package/dist/tui/terminal-open.impl.consumer.d.ts +9 -0
  166. package/dist/tui/terminal-open.impl.consumer.d.ts.map +1 -0
  167. package/dist/tui/terminal-open.impl.consumer.js +13 -0
  168. package/dist/tui/terminal-open.impl.consumer.js.map +1 -0
  169. package/dist/tui/terminal-registry.descriptor.d.ts +11 -0
  170. package/dist/tui/terminal-registry.descriptor.d.ts.map +1 -0
  171. package/dist/tui/terminal-registry.descriptor.js +17 -0
  172. package/dist/tui/terminal-registry.descriptor.js.map +1 -0
  173. package/dist/tui/terminal-registry.impl.consumer.d.ts +71 -0
  174. package/dist/tui/terminal-registry.impl.consumer.d.ts.map +1 -0
  175. package/dist/tui/terminal-registry.impl.consumer.js +190 -0
  176. package/dist/tui/terminal-registry.impl.consumer.js.map +1 -0
  177. package/dist/tui/terminal-tui.plugin.d.ts +10 -0
  178. package/dist/tui/terminal-tui.plugin.d.ts.map +1 -0
  179. package/dist/tui/terminal-tui.plugin.js +32 -0
  180. package/dist/tui/terminal-tui.plugin.js.map +1 -0
  181. package/dist/tui/terminal.descriptor.d.ts +10 -0
  182. package/dist/tui/terminal.descriptor.d.ts.map +1 -0
  183. package/dist/tui/terminal.descriptor.js +25 -0
  184. package/dist/tui/terminal.descriptor.js.map +1 -0
  185. package/dist/tui/terminal.impl.consumer.d.ts +14 -0
  186. package/dist/tui/terminal.impl.consumer.d.ts.map +1 -0
  187. package/dist/tui/terminal.impl.consumer.js +189 -0
  188. package/dist/tui/terminal.impl.consumer.js.map +1 -0
  189. package/dist/tui/terminal.state-key.d.ts +30 -0
  190. package/dist/tui/terminal.state-key.d.ts.map +1 -0
  191. package/dist/tui/terminal.state-key.js +9 -0
  192. package/dist/tui/terminal.state-key.js.map +1 -0
  193. package/dist/tui/terminal.state.d.ts +152 -0
  194. package/dist/tui/terminal.state.d.ts.map +1 -0
  195. package/dist/tui/terminal.state.js +149 -0
  196. package/dist/tui/terminal.state.js.map +1 -0
  197. package/dist/workspace.d.ts +155 -0
  198. package/dist/workspace.d.ts.map +1 -0
  199. package/dist/workspace.js +292 -0
  200. package/dist/workspace.js.map +1 -0
  201. package/package.json +228 -4
@@ -0,0 +1,567 @@
1
+ <script lang="ts">
2
+ /**
3
+ * <Terminal session={…} /> — the xterm.js rendering surface.
4
+ *
5
+ * Renders an L3 `ExecutionSession`'s byte stream. Transport-agnostic: the
6
+ * session may be a real local PTY (L9a) or an RPC proxy (L9b) — the component
7
+ * only ever sees the `ExecutionSession` interface.
8
+ *
9
+ * SSR safety: `@xterm/xterm` + addons are browser-only (they touch `document`
10
+ * and WebGL). They are imported INSIDE `onMount` via dynamic `import()`, never
11
+ * at module top-level, so this component is import-safe in a SvelteKit server
12
+ * render. This also sidesteps the `*-svelte` barrel-index `$state` trap — no
13
+ * eager xterm/DOM import leaks into a non-browser context.
14
+ *
15
+ * connectionState precedence (P6):
16
+ * The component internally derives 'live' / 'exited' from session events.
17
+ * When the `connectionState` prop is supplied by the parent (e.g. 'reconnecting',
18
+ * 'reconnected', 'failed') it takes precedence over the internal derivation, so
19
+ * the parent can drive the full state machine (transport reconnect flows).
20
+ */
21
+ import { onMount } from 'svelte';
22
+ import { peekRuntimeSvelteContext } from '@vibesdotdev/surface-sveltekit/svelte';
23
+ import { cn } from '@vibesdotdev/kit/utils';
24
+ import { resolveSequence, chordToSequence } from '@vibesdotdev/terminal-client/ansi';
25
+ import { CapabilityError, CaughtFailureSchema } from '@vibesdotdev/kernel';
26
+ import type { ExecutionSession } from '@vibesdotdev/kernel/execution';
27
+ import { DEFAULT_FONT, DEFAULT_TERMINAL_THEME, type TerminalProps } from './types.js';
28
+ import { TerminalSessionAuthority } from './terminal-session-authority.js';
29
+ import { observeTerminalAppearance, readTerminalAppearance } from './terminal-theme.js';
30
+ import ReconnectBanner from './ReconnectBanner.svelte';
31
+ import TerminalMobileToolbar from './TerminalMobileToolbar.svelte';
32
+
33
+ /**
34
+ * Connection state values driven externally or derived internally.
35
+ * connecting — xterm not yet mounted (boot async in progress)
36
+ * live — session active, no banner
37
+ * reconnecting — transport dropped, retry in progress
38
+ * reconnected — transport restored (auto-dismissed by caller)
39
+ * failed — unrecoverable transport drop
40
+ * exited — shell process exited cleanly
41
+ */
42
+ export type ConnectionState =
43
+ 'connecting' | 'live' | 'reconnecting' | 'reconnected' | 'failed' | 'exited';
44
+
45
+ interface ExtendedProps extends TerminalProps {
46
+ /**
47
+ * External connection state override. When provided it takes precedence
48
+ * over the component's internally-derived state (live / exited).
49
+ * Defaults to 'connecting' on first render.
50
+ */
51
+ connectionState?: ConnectionState;
52
+ /** Called when the user clicks "Start new session" in failed/exited state. */
53
+ onNewSession?: () => void;
54
+ }
55
+
56
+ let {
57
+ session = null,
58
+ onReady,
59
+ onInputReady,
60
+ theme,
61
+ font,
62
+ class: className = '',
63
+ onExit,
64
+ onDisconnect,
65
+ readonly = false,
66
+ connectionState: externalConnectionState,
67
+ onNewSession,
68
+ cols: lockedCols,
69
+ rows: lockedRows
70
+ }: ExtendedProps = $props();
71
+
72
+ function lockedGrid(): { cols: number; rows: number } | null {
73
+ const cols = Number(lockedCols);
74
+ const rows = Number(lockedRows);
75
+ if (Number.isInteger(cols) && cols > 0 && Number.isInteger(rows) && rows > 0) {
76
+ return { cols, rows };
77
+ }
78
+ return null;
79
+ }
80
+
81
+ let host = $state<HTMLDivElement | null>(null);
82
+
83
+ /**
84
+ * Internal derived state — updated from session lifecycle events.
85
+ * 'connecting' until xterm boots, then 'live', then 'exited'/'disconnected'.
86
+ */
87
+ let internalState = $state<'connecting' | 'live' | 'exited' | 'disconnected'>('connecting');
88
+ let internalDetail = $state<string>('');
89
+ let rendererFallback = $state<string>('');
90
+
91
+ /**
92
+ * Effective connection state — external prop wins when present.
93
+ * See file-level docblock for precedence rules.
94
+ */
95
+ const effectiveState = $derived<ConnectionState>(
96
+ externalConnectionState !== undefined
97
+ ? externalConnectionState
98
+ : internalState === 'disconnected'
99
+ ? 'failed'
100
+ : internalState
101
+ );
102
+
103
+ /**
104
+ * Map effectiveState to the ReconnectBanner status slot.
105
+ * null → banner hidden (live / connecting).
106
+ */
107
+ const bannerStatus = $derived.by<'reconnecting' | 'reconnected' | 'failed' | null>(() => {
108
+ switch (effectiveState) {
109
+ case 'reconnecting':
110
+ return 'reconnecting';
111
+ case 'reconnected':
112
+ return 'reconnected';
113
+ case 'failed':
114
+ return 'failed';
115
+ case 'exited':
116
+ return 'failed'; // exited reuses the failed slot with a distinct message
117
+ default:
118
+ return null;
119
+ }
120
+ });
121
+
122
+ /**
123
+ * Human-readable failure/exit reason forwarded to the banner.
124
+ */
125
+ const bannerReason = $derived.by<string>(() => {
126
+ if (effectiveState === 'exited') {
127
+ return 'Session ended — the shell stopped running.';
128
+ }
129
+ if (effectiveState === 'failed') {
130
+ return internalDetail
131
+ ? `Connection lost — ${internalDetail}`
132
+ : 'Connection lost and could not be restored.';
133
+ }
134
+ return '';
135
+ });
136
+
137
+ /** Whether the state calls for a "Start new session" button. */
138
+ const bannerShowAction = $derived<boolean>(
139
+ effectiveState === 'failed' || effectiveState === 'exited'
140
+ );
141
+
142
+ let appearance = $state({
143
+ theme: { ...DEFAULT_TERMINAL_THEME },
144
+ fontFamily: DEFAULT_FONT.family
145
+ });
146
+ const mergedTheme = $derived({ ...appearance.theme, ...theme });
147
+ const fontFamily = $derived(font?.family ?? appearance.fontFamily);
148
+ const fontSize = $derived(font?.size ?? DEFAULT_FONT.size);
149
+ let refit: (() => void) | null = null;
150
+ let mobileCtrl = $state(false);
151
+ let mobileAlt = $state(false);
152
+
153
+ const encoder = new TextEncoder();
154
+ const runtimeStore = peekRuntimeSvelteContext();
155
+
156
+ /**
157
+ * The xterm instance once booted, and the session currently wired to it.
158
+ * Input paths (onData / key chords / onResize) are registered ONCE at boot
159
+ * and route through `sessionAuthority` so a session can attach late or be
160
+ * swapped without re-registering xterm handlers.
161
+ */
162
+ let xterm = $state<import('@xterm/xterm').Terminal | null>(null);
163
+ const sessionAuthority = new TerminalSessionAuthority<ExecutionSession>();
164
+ let detachActiveSession: (() => void) | null = null;
165
+
166
+ onMount(() => {
167
+ // All xterm imports are deferred to the browser. Cleanup is registered as
168
+ // teardown handles inside the async boot; onMount's returned disposer drains
169
+ // them whether or not boot finished.
170
+ let disposed = false;
171
+ const teardown: Array<() => void> = [];
172
+ const observeUiTask = (task: Promise<void>): void => {
173
+ void task
174
+ .then(
175
+ () => ({ kind: 'complete' as const }),
176
+ (cause) => ({
177
+ kind: 'failure' as const,
178
+ failure: CapabilityError.from(CaughtFailureSchema.parse(cause))
179
+ })
180
+ )
181
+ .then((outcome) => {
182
+ if (outcome.kind === 'complete' || disposed) return;
183
+ internalState = 'disconnected';
184
+ internalDetail = outcome.failure.message;
185
+ onDisconnect?.(outcome.failure.message);
186
+ });
187
+ };
188
+ const runTeardown = (dispose: () => void) => {
189
+ try {
190
+ dispose();
191
+ return { kind: 'complete' as const };
192
+ } catch (cause) {
193
+ return {
194
+ kind: 'failure' as const,
195
+ failure: CapabilityError.from(CaughtFailureSchema.parse(cause))
196
+ };
197
+ }
198
+ };
199
+
200
+ const boot = async () => {
201
+ const { Terminal } = await import('@xterm/xterm');
202
+ const { FitAddon } = await import('@xterm/addon-fit');
203
+ await import('@xterm/xterm/css/xterm.css');
204
+
205
+ if (disposed || !host) return;
206
+ appearance = readTerminalAppearance(host);
207
+ teardown.push(
208
+ observeTerminalAppearance(host, () => {
209
+ if (host) appearance = readTerminalAppearance(host);
210
+ })
211
+ );
212
+
213
+ const term = new Terminal({
214
+ cursorBlink: true,
215
+ minimumContrastRatio: 4.5,
216
+ allowProposedApi: true,
217
+ // Start immediately with native metrics while an edge font is downloading.
218
+ // The font effect switches families after loading, invalidating xterm's metrics.
219
+ fontFamily: document.fonts.check(`${fontSize}px ${fontFamily}`) ? fontFamily : 'monospace',
220
+ fontSize,
221
+ theme: mergedTheme,
222
+ scrollback: 5000
223
+ });
224
+
225
+ const fit = new FitAddon();
226
+ term.loadAddon(fit);
227
+ term.open(host);
228
+
229
+ // P1 AUTOFOCUS — focus the terminal after mount, but only when the
230
+ // current active element is not an input/textarea/contenteditable outside
231
+ // the terminal. This eliminates the "click first to type" dead zone while
232
+ // respecting pre-existing focus (e.g. a form field the user is editing).
233
+ if (!disposed && host) {
234
+ const active = document.activeElement;
235
+ const isExternalInput =
236
+ active instanceof HTMLElement &&
237
+ !host.contains(active) &&
238
+ (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable);
239
+ if (!isExternalInput) {
240
+ term.focus();
241
+ }
242
+ }
243
+
244
+ // P7 FIT/PADDING — run initial fit AFTER flex layout settles via rAF.
245
+ // The surface has no padding (moved to a CSS wrapper) so FitAddon
246
+ // measures the full content box. A follow-up rAF ensures the browser
247
+ // has completed its layout pass before we ask for cols/rows.
248
+ const applyFit = () => {
249
+ if (disposed) return;
250
+ const lock = lockedGrid();
251
+ if (lock) term.resize(lock.cols, lock.rows);
252
+ else if (host && host.clientWidth > 0 && host.clientHeight > 0) fit.fit();
253
+ };
254
+ refit = applyFit;
255
+ teardown.push(() => {
256
+ refit = null;
257
+ });
258
+ const fontsLoaded = () => {
259
+ // xterm caches metrics even when a face arrives later under the same name.
260
+ // A public font option change remeasures cells and rebuilds the glyph atlas.
261
+ const family = term.options.fontFamily;
262
+ term.options.fontFamily = 'monospace';
263
+ term.options.fontFamily = family;
264
+ applyFit();
265
+ };
266
+ document.fonts?.addEventListener('loadingdone', fontsLoaded);
267
+ teardown.push(() => document.fonts?.removeEventListener('loadingdone', fontsLoaded));
268
+
269
+ // Initial fit: wait one animation frame for flex to settle, then report
270
+ // the fitted grid via onReady so the parent opens its session at the
271
+ // RIGHT size from the start. No boot-time PTY resize happens this way —
272
+ // crucial because Bun TUI children cache process.stdout.columns and
273
+ // never observe a post-boot resize.
274
+ //
275
+ // P8 RENDERER-BEFORE-FIT — the WebGL renderer must attach BEFORE the
276
+ // initial fit so fit-time cell metrics equal render-time metrics. The
277
+ // DOM renderer measures a wider cell (~7.95px at the default font)
278
+ // than WebGL renders (~7.0px); a fit taken against the DOM renderer
279
+ // under-reports cols, and because the PTY grid is fixed at the onReady
280
+ // size (no post-boot resize, see above) every route then shows a
281
+ // permanent dead band on the right. WebGL stays an optional
282
+ // enhancement, never a readiness dependency: the import is raced
283
+ // against a short timeout and any failure (no GPU, context refused,
284
+ // chunk stall) falls back to the default renderer, whose metrics the
285
+ // fit below then measures correctly. A late-arriving WebGL module is
286
+ // deliberately NOT loaded after the fit — that would reintroduce the
287
+ // exact metric mismatch this ordering exists to prevent.
288
+ const prepareRenderer = async () => {
289
+ if (disposed) return;
290
+ const loadRenderer = async () => {
291
+ // Standalone renderers need only a session. An enclosing runtime owns
292
+ // the optional GPU enhancement's deadline and cancellation when present.
293
+ if (!runtimeStore) return null;
294
+ const runtime = await runtimeStore.load();
295
+ return runtime.lifecycle.wait(() => import('@xterm/addon-webgl'), {
296
+ timeoutMs: 1000, reason: 'optional terminal renderer'
297
+ });
298
+ };
299
+ const renderer = await loadRenderer()
300
+ .then(
301
+ (module) => ({ kind: 'available' as const, module }),
302
+ (cause) => ({
303
+ kind: 'unavailable' as const,
304
+ failure: CapabilityError.from(CaughtFailureSchema.parse(cause))
305
+ })
306
+ );
307
+ if (renderer.kind === 'unavailable') {
308
+ rendererFallback = renderer.failure.message;
309
+ } else if (!disposed && renderer.module) {
310
+ const webgl = new renderer.module.WebglAddon();
311
+ webgl.onContextLoss(() => {
312
+ webgl.dispose();
313
+ });
314
+ term.loadAddon(webgl);
315
+ teardown.push(() => webgl.dispose());
316
+ }
317
+ if (disposed) return;
318
+ applyFit();
319
+ onReady?.({ cols: term.cols, rows: term.rows });
320
+ };
321
+ requestAnimationFrame(() => {
322
+ observeUiTask(prepareRenderer());
323
+ });
324
+
325
+ // --- Data path: xterm input → PTY (routes via session authority so the
326
+ // session may attach late or be swapped without re-registering) -------
327
+ // READ-ONLY GATE: when `readonly` is set, keystrokes are NOT forwarded to
328
+ // the PTY — the terminal renders output only (observe mode). The prop is
329
+ // read live inside the handler so toggling it takes effect without
330
+ // re-registering xterm handlers.
331
+ const onDataDisposable = term.onData((data: string) => {
332
+ if (readonly) return;
333
+ const input = data.length === 1 && (mobileCtrl || mobileAlt)
334
+ ? resolveSequence({ key: data, ctrl: mobileCtrl, alt: mobileAlt }) : data;
335
+ mobileCtrl = false;
336
+ mobileAlt = false;
337
+ sessionAuthority.current?.write(encoder.encode(input));
338
+ });
339
+ teardown.push(() => onDataDisposable.dispose());
340
+
341
+ // --- Non-printing keys via the extracted key-map --------------------
342
+ // P4 NULL-SAFETY: chordToSequence may return null to mean "let the browser
343
+ // handle this key natively" (e.g. Ctrl/Cmd+V for paste). When it returns
344
+ // null we must NOT call session.write and must NOT preventDefault — return
345
+ // true so xterm passes the event to the browser.
346
+ term.attachCustomKeyEventHandler((ev: KeyboardEvent) => {
347
+ if (ev.type !== 'keydown') return true;
348
+ // READ-ONLY GATE: don't consume or forward any chord — let the browser
349
+ // handle it natively (selection, copy, etc.) and never write to the PTY.
350
+ if (readonly) return true;
351
+ const seq = chordToSequence({
352
+ key: ev.key,
353
+ ctrl: ev.ctrlKey,
354
+ alt: ev.altKey,
355
+ shift: ev.shiftKey
356
+ });
357
+ // null → browser-native behavior (clipboard paste, etc.) — do not consume.
358
+ if (seq === null) return true;
359
+ sessionAuthority.current?.write(encoder.encode(seq));
360
+ return false; // we owned this key
361
+ });
362
+
363
+ // --- Resize: FitAddon → PTY -----------------------------------------
364
+ const onResizeDisposable = term.onResize(({ cols, rows }) => {
365
+ // Propagate fitted cols/rows to the session resize callback.
366
+ sessionAuthority.current?.resize(cols, rows);
367
+ });
368
+ teardown.push(() => onResizeDisposable.dispose());
369
+
370
+ // P7 ResizeObserver-driven refit — reuses the existing observer pattern.
371
+ // The observer fires on every layout change so cols/rows always track
372
+ // the container; the onResize handler above forwards them to the PTY.
373
+ const ro = new ResizeObserver(() => applyFit());
374
+ if (host) {
375
+ ro.observe(host);
376
+ teardown.push(() => ro.disconnect());
377
+ }
378
+
379
+ teardown.push(() => term.dispose());
380
+
381
+ // Publish the booted instance — the $effect below attaches the session
382
+ // (output stream, exit watcher, input-ready flag) when both exist.
383
+ xterm = term;
384
+ teardown.push(() => {
385
+ detachActiveSession?.();
386
+ detachActiveSession = null;
387
+ sessionAuthority.detach();
388
+ xterm = null;
389
+ });
390
+ };
391
+ observeUiTask(boot());
392
+
393
+ return () => {
394
+ disposed = true;
395
+ for (const fn of teardown.splice(0)) {
396
+ const outcome = runTeardown(fn);
397
+ if (outcome.kind === 'failure') rendererFallback = outcome.failure.message;
398
+ }
399
+ };
400
+ });
401
+
402
+ $effect(() => {
403
+ if (!xterm) return;
404
+ xterm.options.theme = mergedTheme;
405
+ });
406
+
407
+ $effect(() => {
408
+ const term = xterm;
409
+ const family = fontFamily;
410
+ const size = fontSize;
411
+ if (!term || (term.options.fontFamily === family && term.options.fontSize === size)) return;
412
+ let cancelled = false;
413
+ const apply = () => {
414
+ if (cancelled) return;
415
+ term.options.fontFamily = family;
416
+ term.options.fontSize = size;
417
+ refit?.();
418
+ };
419
+ // Measure after the face loads; failed downloads still use the browser fallback.
420
+ void document.fonts
421
+ .load(`${size}px ${family}`)
422
+ .then(
423
+ () => ({ kind: 'loaded' as const }),
424
+ (cause) => ({
425
+ kind: 'fallback' as const,
426
+ failure: CapabilityError.from(CaughtFailureSchema.parse(cause))
427
+ })
428
+ )
429
+ .then((outcome) => {
430
+ if (outcome.kind === 'fallback') rendererFallback = outcome.failure.message;
431
+ apply();
432
+ });
433
+ return () => {
434
+ cancelled = true;
435
+ };
436
+ });
437
+
438
+ // Attach/detach the session to the booted xterm. Runs when either the
439
+ // session prop or the xterm instance becomes available; supports a session
440
+ // arriving after boot (the onReady → open-at-fitted-size flow).
441
+ $effect(() => {
442
+ const term = xterm;
443
+ const s = session;
444
+ if (!term) return;
445
+ if (!s) {
446
+ detachActiveSession?.();
447
+ detachActiveSession = null;
448
+ sessionAuthority.detach();
449
+ return;
450
+ }
451
+ if (s === sessionAuthority.current) return;
452
+
453
+ detachActiveSession?.();
454
+ detachActiveSession = null;
455
+ const attachment = sessionAuthority.attach(s);
456
+
457
+ // --- Data path: PTY output → xterm ----------------------------------
458
+ const decoder = new TextDecoder();
459
+ const unsubscribeOutput = s.onOutput((bytes: Uint8Array) => {
460
+ // xterm.write accepts string or Uint8Array; we decode so multibyte
461
+ // and escape sequences survive intact (PTY stream is raw bytes).
462
+ term.write(decoder.decode(bytes, { stream: true }));
463
+ });
464
+
465
+ // Align the PTY with the current grid. When the parent opened the session
466
+ // at the onReady dimensions this is a no-op server-side; it only matters
467
+ // if the layout changed between open and attach.
468
+ s.resize(term.cols, term.rows);
469
+
470
+ internalState = 'live';
471
+
472
+ // Signal to test harnesses that the input path is fully wired END-TO-END
473
+ // (xterm onData registered at boot AND a session attached) so keystrokes
474
+ // typed on this flag are guaranteed to reach the PTY. The buffer reader
475
+ // is for tui:shot / Playwright waits: WebGL paints to a canvas, so
476
+ // `.xterm-screen` innerText is empty.
477
+ Reflect.set(window, '__vibesTerminalInputReady', true);
478
+ Reflect.set(window, '__vibesReadTerminalBuffer', () => {
479
+ const buf = term.buffer.active;
480
+ const lines: string[] = [];
481
+ for (let i = 0; i < buf.length; i++) {
482
+ lines.push(buf.getLine(i)?.translateToString(true) ?? '');
483
+ }
484
+ return lines.join('\n');
485
+ });
486
+ onInputReady?.();
487
+
488
+ // --- Lifecycle: exit + disconnect -----------------------------------
489
+ void s.exited()
490
+ .then(
491
+ (result) => ({ kind: 'exit' as const, result }),
492
+ (cause) => ({
493
+ kind: 'failure' as const,
494
+ failure: CapabilityError.from(CaughtFailureSchema.parse(cause))
495
+ })
496
+ )
497
+ .then((outcome) => {
498
+ if (!sessionAuthority.isCurrent(attachment)) return;
499
+ if (outcome.kind === 'failure') {
500
+ internalState = 'disconnected';
501
+ internalDetail = outcome.failure.message;
502
+ onDisconnect?.(outcome.failure.message);
503
+ return;
504
+ }
505
+ const { result } = outcome;
506
+ internalState = 'exited';
507
+ internalDetail =
508
+ result.exitCode === null
509
+ ? 'process terminated'
510
+ : `process exited (code ${result.exitCode})`;
511
+ onExit?.(result);
512
+ });
513
+
514
+ detachActiveSession = () => {
515
+ unsubscribeOutput();
516
+ Reflect.deleteProperty(window, '__vibesTerminalInputReady');
517
+ Reflect.deleteProperty(window, '__vibesReadTerminalBuffer');
518
+ sessionAuthority.detach(s);
519
+ };
520
+ });
521
+ </script>
522
+
523
+ <div
524
+ class={cn('relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-md', className)}
525
+ style:background={mergedTheme.background}
526
+ >
527
+ <!-- P6 F3 loading skeleton — shown while xterm is booting (connectionState='connecting') -->
528
+ {#if effectiveState === 'connecting'}
529
+ <div
530
+ class="absolute inset-0 z-10 flex items-center justify-start gap-2 px-3.5 py-3 font-mono text-xs text-muted-foreground"
531
+ role="status"
532
+ aria-label="Connecting…"
533
+ >
534
+ <span
535
+ class="size-2 shrink-0 animate-pulse rounded-full bg-muted-foreground"
536
+ aria-hidden="true"
537
+ ></span>
538
+ <span class="animate-pulse">connecting…</span>
539
+ </div>
540
+ {/if}
541
+
542
+ <!-- xterm host — always in DOM so xterm can attach; invisible during connecting -->
543
+ <div
544
+ class={cn('min-h-0 w-full flex-1', effectiveState === 'connecting' ? 'invisible' : '')}
545
+ bind:this={host}
546
+ ></div>
547
+
548
+ {#if !readonly}
549
+ <TerminalMobileToolbar
550
+ class="sm:hidden"
551
+ bind:ctrlActive={mobileCtrl}
552
+ bind:altActive={mobileAlt}
553
+ disabled={effectiveState !== 'live' || !session}
554
+ onInput={sequence => sessionAuthority.current?.write(encoder.encode(sequence))}
555
+ />
556
+ {/if}
557
+
558
+ <!-- P6 ReconnectBanner replaces the old inline banner -->
559
+ <ReconnectBanner
560
+ status={bannerStatus}
561
+ reason={bannerReason}
562
+ onNewSession={bannerShowAction ? onNewSession : undefined}
563
+ />
564
+ {#if rendererFallback}
565
+ <span class="sr-only" aria-live="polite">Optional terminal renderer unavailable.</span>
566
+ {/if}
567
+ </div>
@@ -0,0 +1,25 @@
1
+ import { type TerminalProps } from './types.ts';
2
+ /**
3
+ * Connection state values driven externally or derived internally.
4
+ * connecting — xterm not yet mounted (boot async in progress)
5
+ * live — session active, no banner
6
+ * reconnecting — transport dropped, retry in progress
7
+ * reconnected — transport restored (auto-dismissed by caller)
8
+ * failed — unrecoverable transport drop
9
+ * exited — shell process exited cleanly
10
+ */
11
+ export type ConnectionState = 'connecting' | 'live' | 'reconnecting' | 'reconnected' | 'failed' | 'exited';
12
+ interface ExtendedProps extends TerminalProps {
13
+ /**
14
+ * External connection state override. When provided it takes precedence
15
+ * over the component's internally-derived state (live / exited).
16
+ * Defaults to 'connecting' on first render.
17
+ */
18
+ connectionState?: ConnectionState;
19
+ /** Called when the user clicks "Start new session" in failed/exited state. */
20
+ onNewSession?: () => void;
21
+ }
22
+ declare const Terminal: import("svelte").Component<ExtendedProps, {}, "">;
23
+ type Terminal = ReturnType<typeof Terminal>;
24
+ export default Terminal;
25
+ //# sourceMappingURL=Terminal.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Terminal.svelte.d.ts","sourceRoot":"","sources":["../../src/svelte/Terminal.svelte.ts"],"names":[],"mappings":"AA4BA,OAAO,EAAwC,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AAOrF;;;;;;;;GAQG;AACH,MAAM,MAAM,eAAe,GAC1B,YAAY,GAAG,MAAM,GAAG,cAAc,GAAG,aAAa,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE9E,UAAU,aAAc,SAAQ,aAAa;IAC5C;;;;OAIG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,8EAA8E;IAC9E,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;CAC1B;AA6fF,QAAA,MAAM,QAAQ,mDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}