Skip to content

Commit 7cbd37b

Browse files
committed
use multiplexer
1 parent a7cf115 commit 7cbd37b

File tree

12 files changed

+423
-219
lines changed

12 files changed

+423
-219
lines changed

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# packages/tunnel
2+
3+
before touching packages/tunnel ALWAYS fetch curl https://gitchamber.com/repos/remorses/holocron/preview/files/cloudflare-tunnel/src/tunnel.ts?glob=**/*.ts to understand how the tunnel works
4+
5+
6+
17
# Agent Guidelines for opentui
28

39
Default to using Bun instead of Node.js.

bun.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@
134134
"@opentui/react": "workspace:*",
135135
"@opentui/tunnel": "workspace:*",
136136
"@types/bun": "^1.3.5",
137+
"@types/react": "^19.2.7",
137138
"bun-types": "^1.0.0",
138139
"react": ">=19.0.0",
139140
"typescript": "^5.0.0",

packages/tunnel/cloudflare/client/client.ts

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { connectTerminal } from "@opentui/web/client"
1+
import { MultiplexerConnection, connectTerminal } from "@opentui/web/client"
22

33
// Extract namespace and tunnelId from URL path: /s/{namespace}/{tunnelId}
44
const pathParts = window.location.pathname.split("/").filter(Boolean)
@@ -21,19 +21,11 @@ if (!namespace || !tunnelId) {
2121

2222
const wsUrl = `wss://${window.location.host}/_tunnel`
2323

24-
const connection = connectTerminal({
24+
// Create centralized multiplexer connection
25+
const multiplexer = new MultiplexerConnection({
2526
url: wsUrl,
2627
namespace,
2728
ids: [tunnelId],
28-
container,
29-
useCanvas: true,
30-
fontFamily: "Consolas, monospace",
31-
fontSize: 14,
32-
lineHeight: 1.4,
33-
letterSpacing: 0,
34-
fontWeight: 500,
35-
fontWeightBold: 700,
36-
backgroundColor: "#1e1e1e",
3729
onConnect: () => {
3830
console.log("[opentui] Connected to tunnel:", tunnelId, "namespace:", namespace)
3931
},
@@ -49,29 +41,45 @@ if (!namespace || !tunnelId) {
4941
setTimeout(() => window.location.reload(), 2000)
5042
}
5143
},
52-
onUpstreamClosed: (id) => {
53-
console.log("[opentui] Upstream closed:", id)
44+
onError: (error) => {
45+
console.error("[opentui] Error:", error)
5446
const terminal = document.getElementById("terminal")
55-
if (terminal) {
47+
if (terminal && error.message.includes("4008")) {
5648
terminal.innerHTML = `
5749
<div style="color: #f85149; font-family: system-ui; text-align: center; padding-top: 40vh;">
58-
<h1>Tunnel closed</h1>
59-
<p>The upstream application disconnected.</p>
50+
<h1>Tunnel not active</h1>
51+
<p>The upstream application is not connected.</p>
6052
</div>
6153
`
62-
setTimeout(() => window.location.reload(), 2000)
6354
}
6455
},
65-
onError: (error) => {
66-
console.error("[opentui] Error:", error)
56+
})
57+
58+
// Connect the multiplexer
59+
multiplexer.connect()
60+
61+
const connection = connectTerminal({
62+
connection: multiplexer,
63+
id: tunnelId,
64+
container,
65+
fontFamily: "Consolas, monospace",
66+
fontSize: 14,
67+
lineHeight: 1.4,
68+
letterSpacing: 0,
69+
fontWeight: 500,
70+
fontWeightBold: 700,
71+
backgroundColor: "#1e1e1e",
72+
onUpstreamClosed: (id) => {
73+
console.log("[opentui] Upstream closed:", id)
6774
const terminal = document.getElementById("terminal")
68-
if (terminal && error.message.includes("4008")) {
75+
if (terminal) {
6976
terminal.innerHTML = `
7077
<div style="color: #f85149; font-family: system-ui; text-align: center; padding-top: 40vh;">
71-
<h1>Tunnel not active</h1>
72-
<p>The upstream application is not connected.</p>
78+
<h1>Tunnel closed</h1>
79+
<p>The upstream application disconnected.</p>
7380
</div>
7481
`
82+
setTimeout(() => window.location.reload(), 2000)
7583
}
7684
},
7785
})

packages/web/demo/client.ts

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,104 @@
1-
import { connectTerminal } from "../src/client"
1+
import { MultiplexerConnection, connectTerminal } from "../src/client"
22

3-
// Setup container to fill viewport
4-
const container = document.getElementById("terminal")!
5-
document.body.style.cssText = "margin: 0; background: #0D1117; overflow: hidden;"
6-
container.style.cssText = "width: 100vw; height: 100vh;"
3+
// Setup container to fill viewport with 2 side-by-side terminals
4+
document.body.style.cssText = "margin: 0; background: #0D1117; overflow: hidden; display: flex; flex-direction: column;"
5+
6+
// Header showing sync info
7+
const header = document.createElement("div")
8+
header.style.cssText =
9+
"padding: 8px 16px; background: #161b22; color: #8b949e; font-family: system-ui; font-size: 12px; border-bottom: 1px solid #30363d;"
10+
header.innerHTML = `
11+
<strong style="color: #58a6ff;">Synced Terminals Demo</strong> -
12+
Both terminals share the same connection and ID. Type in either one to see sync!
13+
`
14+
document.body.appendChild(header)
15+
16+
// Container for the two terminals
17+
const terminalWrapper = document.createElement("div")
18+
terminalWrapper.style.cssText = "display: flex; flex: 1; gap: 2px; background: #30363d;"
19+
document.body.appendChild(terminalWrapper)
20+
21+
// Create two terminal containers
22+
const container1 = document.createElement("div")
23+
container1.id = "terminal1"
24+
container1.style.cssText = "flex: 1; background: #0D1117;"
25+
terminalWrapper.appendChild(container1)
26+
27+
const container2 = document.createElement("div")
28+
container2.id = "terminal2"
29+
container2.style.cssText = "flex: 1; background: #0D1117;"
30+
terminalWrapper.appendChild(container2)
731

832
// Generate a unique tunnel ID for this session
933
const tunnelId = crypto.randomUUID()
1034

11-
const connection = connectTerminal({
35+
// Create a centralized multiplexer connection
36+
const multiplexer = new MultiplexerConnection({
1237
url: `ws://${window.location.host}`,
1338
namespace: "demo",
1439
ids: [tunnelId],
15-
container,
16-
useCanvas: true,
40+
onConnect: () => {
41+
console.log("[demo] Multiplexer connected")
42+
},
43+
onDisconnect: () => {
44+
console.log("[demo] Multiplexer disconnected")
45+
},
46+
onError: (error) => {
47+
console.error("[demo] Multiplexer error:", error)
48+
},
49+
})
50+
51+
// Connect the multiplexer
52+
multiplexer.connect()
53+
54+
// Shared renderer options
55+
const rendererOptions = {
1756
fontFamily: "Consolas, monospace",
1857
fontSize: 14,
19-
lineHeight: 1.4,
58+
lineHeight: 1.2,
59+
devicePixelRatio: 1.5,
2060
letterSpacing: 0,
2161
fontWeight: 500,
2262
fontWeightBold: 700,
2363
backgroundColor: "#0D1117",
64+
} as const
65+
66+
// Create two terminals sharing the same connection and ID
67+
const terminal1 = connectTerminal({
68+
connection: multiplexer,
69+
id: tunnelId,
70+
container: container1,
71+
...rendererOptions,
72+
})
73+
74+
const terminal2 = connectTerminal({
75+
connection: multiplexer,
76+
id: tunnelId,
77+
container: container2,
78+
focused: false, // Only first terminal is focused initially
79+
...rendererOptions,
2480
})
2581

2682
// Handle window resize
2783
window.addEventListener("resize", () => {
28-
connection.resize()
84+
terminal1.resize()
85+
terminal2.resize()
86+
})
87+
88+
// Focus management: click to focus
89+
container1.addEventListener("click", () => {
90+
terminal1.setFocused(true)
91+
terminal2.setFocused(false)
92+
container1.style.outline = "2px solid #58a6ff"
93+
container2.style.outline = "none"
2994
})
95+
96+
container2.addEventListener("click", () => {
97+
terminal2.setFocused(true)
98+
terminal1.setFocused(false)
99+
container2.style.outline = "2px solid #58a6ff"
100+
container1.style.outline = "none"
101+
})
102+
103+
// Initial focus indicator
104+
container1.style.outline = "2px solid #58a6ff"

packages/web/demo/server.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ type Tab = "overview" | "diff" | "scroll" | "colors"
6868
function Header({ tab, spinner }: { tab: Tab; spinner: string }) {
6969
return (
7070
<box flexDirection="row" marginBottom={1}>
71-
<text bold fg={theme.accent}>
72-
OpenTUI Web Demo {spinner}
71+
<text fg={theme.accent}>
72+
<strong>OpenTUI Web Demo {spinner}</strong>
7373
</text>
7474
<text fg={theme.dimmed}> | </text>
7575
<text fg={tab === "overview" ? theme.accent : theme.dimmed}>[1] Overview</text>
@@ -265,7 +265,7 @@ function ColorsTab() {
265265

266266
return (
267267
<box flexDirection="column" flexGrow={1} gap={1}>
268-
<text bold fg={theme.accent}>
268+
<text fg={theme.accent}>
269269
Color Palette & Text Styles
270270
</text>
271271

@@ -282,7 +282,7 @@ function ColorsTab() {
282282
width={15}
283283
alignItems="center"
284284
>
285-
<text fg={color.fg} bold>
285+
<text fg={color.fg} >
286286
{color.name}
287287
</text>
288288
</box>
@@ -344,7 +344,7 @@ function App() {
344344

345345
useKeyboard((e) => {
346346
// Only handle specific keys, let others pass through to focused input
347-
const key = e.name || e.char
347+
const key = e.name
348348
switch (key) {
349349
case "1":
350350
setTab("overview")
@@ -411,6 +411,7 @@ const server = Bun.serve({
411411
port: 3001,
412412
hostname: "0.0.0.0",
413413

414+
// @ts-expect-error - Bun's static option is not in the type definitions yet
414415
static: {
415416
"/": html,
416417
"/client.ts": new Response(clientJs, {

packages/web/demo/tunnel.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ type Tab = "overview" | "diff" | "scroll" | "colors"
6868
function Header({ tab, spinner }: { tab: Tab; spinner: string }) {
6969
return (
7070
<box flexDirection="row" marginBottom={1}>
71-
<text bold fg={theme.accent}>
72-
OpenTUI Tunnel Demo {spinner}
71+
<text fg={theme.accent}>
72+
<strong>OpenTUI Tunnel Demo {spinner}</strong>
7373
</text>
7474
<text fg={theme.dimmed}> | </text>
7575
<text fg={tab === "overview" ? theme.accent : theme.dimmed}>[1] Overview</text>
@@ -254,8 +254,8 @@ function ColorsTab() {
254254

255255
return (
256256
<box flexDirection="column" flexGrow={1} gap={1}>
257-
<text bold fg={theme.accent}>
258-
Color Palette & Text Styles
257+
<text fg={theme.accent}>
258+
<strong>Color Palette & Text Styles</strong>
259259
</text>
260260

261261
<box flexDirection="row" gap={1} flexWrap="wrap">
@@ -270,8 +270,8 @@ function ColorsTab() {
270270
width={15}
271271
alignItems="center"
272272
>
273-
<text fg={color.fg} bold>
274-
{color.name}
273+
<text fg={color.fg}>
274+
<strong>{color.name}</strong>
275275
</text>
276276
</box>
277277
))}
@@ -328,7 +328,7 @@ function App() {
328328
}, [])
329329

330330
useKeyboard((e) => {
331-
const key = e.name || e.char
331+
const key = e.name
332332
switch (key) {
333333
case "1":
334334
setTab("overview")

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"@opentui/react": "workspace:*",
3232
"@opentui/tunnel": "workspace:*",
3333
"@types/bun": "^1.3.5",
34+
"@types/react": "^19.2.7",
3435
"bun-types": "^1.0.0",
3536
"react": ">=19.0.0",
3637
"typescript": "^5.0.0"

packages/web/src/client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
// Client exports (for browser)
22
export { connectTerminal, type ConnectOptions, type TerminalConnection } from "./client/connect"
3+
export {
4+
MultiplexerConnection,
5+
type MultiplexerOptions,
6+
type MultiplexerEvent,
7+
type MultiplexerListener,
8+
} from "./client/multiplexer"
39
export { CanvasRenderer, type CanvasRendererOptions } from "./client/canvas-renderer"
410

511
// Measurement utilities for layout calculations

packages/web/src/client/canvas-renderer.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export interface CanvasRendererOptions {
4141
backgroundColor?: string
4242
textColor?: string
4343
devicePixelRatio?: number
44+
/** Whether the terminal is focused (default: true). Controls cursor visibility. */
45+
focused?: boolean
4446
}
4547

4648
export interface FontMetrics {
@@ -79,6 +81,7 @@ export class CanvasRenderer {
7981
private cursorBlinkVisible: boolean = true
8082
private lastCursorX: number = 0
8183
private lastCursorY: number = 0
84+
private focused: boolean = true
8285

8386
constructor(options: CanvasRendererOptions) {
8487
this.container = options.container
@@ -93,6 +96,7 @@ export class CanvasRenderer {
9396
this.backgroundColor = options.backgroundColor ?? DEFAULT_BG
9497
this.textColor = options.textColor ?? DEFAULT_FG
9598
this.dpr = options.devicePixelRatio ?? window.devicePixelRatio ?? 1
99+
this.focused = options.focused ?? true
96100

97101
// Use 'alphabetic' baseline - most standard and predictable
98102
this.textBaseline = "alphabetic"
@@ -284,7 +288,7 @@ export class CanvasRenderer {
284288
this.stopCursorBlink()
285289
this.cursorBlinkInterval = setInterval(() => {
286290
this.cursorBlinkVisible = !this.cursorBlinkVisible
287-
this.cursorEl.style.opacity = this.cursorBlinkVisible ? "1" : "0"
291+
this.cursorEl.style.opacity = this.focused && this.cursorBlinkVisible ? "1" : "0"
288292
}, 530) // ~1s full cycle (530ms on, 530ms off)
289293
}
290294

@@ -297,7 +301,7 @@ export class CanvasRenderer {
297301

298302
private resetCursorBlink(): void {
299303
this.cursorBlinkVisible = true
300-
this.cursorEl.style.opacity = "1"
304+
this.cursorEl.style.opacity = this.focused ? "1" : "0"
301305
this.startCursorBlink()
302306
}
303307

@@ -508,6 +512,11 @@ export class CanvasRenderer {
508512
return { cols: this.cols, rows: this.rows }
509513
}
510514

515+
setFocused(focused: boolean): void {
516+
this.focused = focused
517+
this.cursorEl.style.opacity = focused && this.cursorBlinkVisible ? "1" : "0"
518+
}
519+
511520
setSelection(anchor: { x: number; y: number }, focus: { x: number; y: number }): void {
512521
// Normalize to start/end (anchor could be after focus if selecting backwards)
513522
const startY = Math.min(anchor.y, focus.y)

0 commit comments

Comments
 (0)