This commit is contained in:
2026-05-20 14:56:02 +00:00
parent 8cea4a899c
commit 2d841ee0b4
15 changed files with 1041 additions and 503 deletions

View File

@@ -68,6 +68,13 @@ Key patterns:
- **`hooks/useSidebar.ts`** — Module-singleton with Set<setState> subscriber pattern; one bus subscription guarded by `globalThis.__boocode_sidebar_subscribed` for HMR safety. Every new `SessionEvent` type needs a `case` in the `applyEvent` switch (no-op `return prev` is fine). - **`hooks/useSidebar.ts`** — Module-singleton with Set<setState> subscriber pattern; one bus subscription guarded by `globalThis.__boocode_sidebar_subscribed` for HMR safety. Every new `SessionEvent` type needs a `case` in the `applyEvent` switch (no-op `return prev` is fine).
- **`api/client.ts`** — Centralized typed fetch wrapper. All endpoints under `api.*` namespace. - **`api/client.ts`** — Centralized typed fetch wrapper. All endpoints under `api.*` namespace.
Font / CSS pipeline (apps/web):
- Tailwind v4's `@import "tailwindcss"` directive strips font URLs from subsequent CSS `@import`s — `@fontsource*` packages must be imported as JS side-effect modules in `apps/web/src/main.tsx`, not via `@import` in `globals.css`. Otherwise the woff2 files never make it to `dist/`.
- Lightning CSS (inside `@tailwindcss/postcss` v4) collapses contiguous unicode-ranges to wildcard shorthand (`U+0000-FFFF``U+????`), which iOS Safari/Vivaldi mishandles (silently drops the font from those codepoints). Use explicit non-wildcard-collapsible subranges (e.g. `U+2500-259F` not `U+2500-25FF`). The `apps/web` build script greps `dist/assets/*.css` for `U+2500-259F` and fails the build if missing — preserve that guard.
- `@font-face` blocks must live AFTER all `@import` statements (CSS spec). Earlier placement silently breaks every subsequent `@import` (this broke the 18 theme palette imports in globals.css for one session).
- JetBrainsMono Nerd Font self-hosted in `apps/web/src/fonts/` (TTF from ryanoasis/nerd-fonts release) — needed because `@fontsource-variable/jetbrains-mono` ships subsetted woff2s that don't cover `U+2500-259F` (box drawing + block elements, used by opencode's banner). "NL" = No Ligatures (matches `font-feature-settings: "liga" 0`); "Mono" = single-cell icon width so TUI layouts don't desync.
- xterm-addon-webgl rasterizes glyphs via Canvas2D into a GPU texture atlas. Canvas2D does NOT honor `font-display: block` — it uses whatever font is currently registered. Gate xterm initialization on `document.fonts.load(<font-name>)` resolving before calling `term.open()` (see `fontsReady` useState in `TerminalPane.tsx`). iOS Safari/Vivaldi also reclaims WebGL contexts from backgrounded tabs: keep `webgl.onContextLoss(() => webgl.dispose())` + recreate via visibilitychange. Do NOT manually dispose+recreate the addon after font load — iOS silently fails the second GL context creation and the terminal drops to DOM renderer with stale metrics.
### Data flow for chat ### Data flow for chat
1. User sends message → POST `/api/sessions/:id/messages` creates user + assistant (status=streaming) rows 1. User sends message → POST `/api/sessions/:id/messages` creates user + assistant (status=streaming) rows
@@ -105,6 +112,8 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
- node-pty's compiled `.node` is libc-specific: proddeps and runtime Dockerfile stages must share libc (alpine↔musl or bookworm-slim↔glibc); the TS-only builder stage can stay alpine for speed. - node-pty's compiled `.node` is libc-specific: proddeps and runtime Dockerfile stages must share libc (alpine↔musl or bookworm-slim↔glibc); the TS-only builder stage can stay alpine for speed.
- pnpm 10 `--frozen-lockfile` skips node-pty's postinstall — the Docker proddeps stage runs `cd node_modules/node-pty && npm run install` to force the native compile. - pnpm 10 `--frozen-lockfile` skips node-pty's postinstall — the Docker proddeps stage runs `cd node_modules/node-pty && npm run install` to force the native compile.
- A local PreToolUse hook (`security_reminder_hook.py`) regex-flags Node's older `child_process` spawn helpers as unsafe (false positive even on the File-suffixed variant). Use `spawn` — it's accepted. - A local PreToolUse hook (`security_reminder_hook.py`) regex-flags Node's older `child_process` spawn helpers as unsafe (false positive even on the File-suffixed variant). Use `spawn` — it's accepted.
- `/opt/boolab` hosts a working sibling BooCode terminal at `boocode.indifferentketchup.com`. Useful for visual side-by-side comparison on the same iPhone when debugging booterm rendering. Boolab uses Tailwind v3 (`@tailwind base`); boocode uses v4 — many subtle build differences. Don't assume parity.
- booterm SSHs to the host as `samkintop@100.114.205.53` (the Tailscale IP). The hostname `ubuntu-homelab` (shown in the bash prompt after login) does NOT resolve from inside the container — only the host's `/etc/hosts` knows it. Override via `BOOTERM_SSH_HOST` / `BOOTERM_SSH_USER` env vars in docker-compose if you ever move the shell to a different machine.
## Conventions ## Conventions

View File

@@ -39,8 +39,11 @@ RUN test -f node_modules/node-pty/build/Release/pty.node && echo "pty.node OK" |
# host's nvm node) run inside the container when invoked from the terminal # host's nvm node) run inside the container when invoked from the terminal
# pane. Side-effect: su-exec is alpine-only — Debian replacement is gosu. # pane. Side-effect: su-exec is alpine-only — Debian replacement is gosu.
FROM node:20-bookworm-slim AS runtime FROM node:20-bookworm-slim AS runtime
# v1.10.8d: openssh-client added so the terminal can ssh -t samkintop@host
# (matching boolab's pattern) — that's how the in-pane shell gets access to
# host tools (docker, claude, opencode) that don't exist inside the container.
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
tmux bash gosu ca-certificates procps \ tmux bash gosu ca-certificates procps openssh-client \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Mirror uid/gid 1000:1000 from the host so the bind-mounted /home/samkintop # Mirror uid/gid 1000:1000 from the host so the bind-mounted /home/samkintop
# (added in docker-compose) is owned by the user from the container's view. # (added in docker-compose) is owned by the user from the container's view.

View File

@@ -1,7 +1,6 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
// UUIDs already match [0-9a-f-]; allow uppercase and longer just in case.
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/; const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
export function sanitizeId(raw: string): string | null { export function sanitizeId(raw: string): string | null {
@@ -9,12 +8,15 @@ export function sanitizeId(raw: string): string | null {
return raw.toLowerCase(); return raw.toLowerCase();
} }
export function tmuxSessionName(sessionId: string): string { // v1.10.8c: per-pane tmux sessions (boolab pattern). Previously booterm used
return `bc-${sessionId}`; // one tmux session per chat-session with one window per pane; that meant the
} // session-level window-size policy was shared across panes, and
// `attach-session -d` (used to take over from a stale browser) would detach
export function tmuxWindowName(paneId: string): string { // every other pane attached to the same session — the "[detached]" bug.
return `term-${paneId}`; // Now each pane gets its own tmux session named `bc-<paneId>`. The bc- prefix
// namespaces booterm sessions on the shared tmux server.
export function tmuxSessionName(paneId: string): string {
return `bc-${paneId}`;
} }
interface CmdResult { interface CmdResult {
@@ -23,15 +25,17 @@ interface CmdResult {
code: number; code: number;
} }
// Wrap child_process.spawn with shell:false so each argv element is passed
// as a separate argument — no shell interpolation, no injection surface.
function runTmux(tmuxConfPath: string, args: string[]): Promise<CmdResult> { function runTmux(tmuxConfPath: string, args: string[]): Promise<CmdResult> {
return new Promise((resolve) => { return new Promise((resolve) => {
const child = spawn('tmux', ['-f', tmuxConfPath, ...args], { shell: false }); const child = spawn('tmux', ['-f', tmuxConfPath, ...args], { shell: false });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); }); child.stdout.on('data', (chunk: Buffer) => {
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); }); stdout += chunk.toString('utf8');
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
child.on('error', (err) => { child.on('error', (err) => {
resolve({ stdout, stderr: stderr + String(err), code: 1 }); resolve({ stdout, stderr: stderr + String(err), code: 1 });
}); });
@@ -46,57 +50,115 @@ export async function hasSession(tmuxConfPath: string, sessionName: string): Pro
return res.code === 0; return res.code === 0;
} }
export async function listWindows(tmuxConfPath: string, sessionName: string): Promise<string[]> { // Default fallback size — wider than any real terminal would care about; the
const res = await runTmux(tmuxConfPath, ['list-windows', '-t', sessionName, '-F', '#{window_name}']); // real client size lands via the WS resize frame within a few ms of attach.
if (res.code !== 0) return []; const DEFAULT_COLS = 200;
return res.stdout.trim().split('\n').filter(Boolean); const DEFAULT_ROWS = 50;
// v1.10.8d: per-pane shell is `ssh -t samkintop@SSH_HOST` (matches boolab's
// pattern). The container has no docker / claude / opencode binaries; SSH'ing
// to the host gives the user their full normal shell environment. Default is
// the host's Tailscale IP (100.114.205.53) — the hostname `ubuntu-homelab`
// only resolves on the host's local /etc/hosts, not from inside containers,
// so SSH'ing to the hostname fails with `Could not resolve hostname` even
// though the host machine is reachable. Boolab uses the same IP.
const SSH_HOST = process.env['BOOTERM_SSH_HOST']?.trim() || '100.114.205.53';
const SSH_USER = process.env['BOOTERM_SSH_USER']?.trim() || 'samkintop';
// POSIX shell single-quote escape: wrap in '…', escape embedded singles by
// closing-the-quote, inserting an escaped quote, and re-opening.
function shellEscape(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
} }
export async function killWindow( // Idempotent. Creates the tmux session if it doesn't exist, sized via -x/-y
// from the client's measured xterm dimensions. With `window-size = largest`
// + `aggressive-resize on` in tmux.conf, the attached client's actual size
// wins once it reports in — but seeding at the right size avoids the brief
// window where bash/TUI inherits the default 80x24 from a stale fallback.
export async function ensureSession(
tmuxConfPath: string, tmuxConfPath: string,
sessionName: string, sessionName: string,
windowName: string,
): Promise<boolean> {
const res = await runTmux(tmuxConfPath, ['kill-window', '-t', `${sessionName}:${windowName}`]);
return res.code === 0;
}
// Idempotent. Creates the tmux session if it doesn't exist, then ensures the
// named window is present. The session's initial window is created with the
// target name (via `-n`) so we don't need a separate rename step.
export async function ensureWindow(
tmuxConfPath: string,
sessionName: string,
windowName: string,
projectRoot: string, projectRoot: string,
log: FastifyBaseLogger, log: FastifyBaseLogger,
cols?: number,
rows?: number,
): Promise<void> { ): Promise<void> {
if (!(await hasSession(tmuxConfPath, sessionName))) { if (await hasSession(tmuxConfPath, sessionName)) return;
log.info({ sessionName, windowName, projectRoot }, 'creating tmux session'); const sizeCols = cols && cols > 0 ? Math.floor(cols) : DEFAULT_COLS;
const res = await runTmux(tmuxConfPath, [ const sizeRows = rows && rows > 0 ? Math.floor(rows) : DEFAULT_ROWS;
// Bypass tmux.conf's default-command — build the per-pane argv explicitly
// so we can wrap ssh in the gosu privilege drop. The remote shell sequence
// (per boolab's invariants in services/tmux_session.py target_cmd_for):
// 1. ssh's argv must flatten into a single quoted bash -lc <script>
// 2. -l on the outer bash sources ~/.profile on the remote (PATH etc.)
// 3. cd to projectRoot, then exec bash -l so the user lands in the repo
// /opt is bind-mounted host↔container, so projectRoot resolves to the
// same files on both sides.
const remoteScript = `cd ${shellEscape(projectRoot)} && exec bash -l`;
const remoteCmd = `bash -lc ${shellEscape(remoteScript)}`;
const argv = [
'new-session', '-d', 'new-session', '-d',
'-s', sessionName, '-s', sessionName,
'-n', windowName,
'-c', projectRoot, '-c', projectRoot,
]); '-x', String(sizeCols),
'-y', String(sizeRows),
'--',
// gosu drops privs from the container's root (tmux server runs as root)
// to samkintop:samkintop. env restores HOME/USER/SHELL so ssh finds the
// right ~/.ssh/id_ed25519 (key is mode 0600 and ssh refuses keys whose
// UID doesn't match the running user — both are 1000 here).
'gosu', 'samkintop:samkintop',
'env', 'HOME=/home/samkintop', 'USER=samkintop', 'SHELL=/bin/bash',
'ssh', '-t',
'-o', 'StrictHostKeyChecking=yes',
'-o', 'ServerAliveInterval=30',
'-o', 'ServerAliveCountMax=3',
`${SSH_USER}@${SSH_HOST}`,
remoteCmd,
];
log.info(
{ sessionName, projectRoot, cols: sizeCols, rows: sizeRows, sshTarget: `${SSH_USER}@${SSH_HOST}` },
'creating tmux session (ssh to host)',
);
const res = await runTmux(tmuxConfPath, argv);
if (res.code !== 0) { if (res.code !== 0) {
log.error({ res }, 'tmux new-session failed'); log.error({ res }, 'tmux new-session failed');
throw new Error(`tmux new-session failed: ${res.stderr}`); throw new Error(`tmux new-session failed: ${res.stderr}`);
} }
return; }
}
export async function killSession(
const windows = await listWindows(tmuxConfPath, sessionName); tmuxConfPath: string,
if (windows.includes(windowName)) return; sessionName: string,
): Promise<boolean> {
const res = await runTmux(tmuxConfPath, [ const res = await runTmux(tmuxConfPath, ['kill-session', '-t', sessionName]);
'new-window', return res.code === 0;
'-t', sessionName, }
'-n', windowName,
'-c', projectRoot, // v1.10.8c: capture-pane on WS attach to replay the buffer state to the fresh
]); // xterm (boolab pattern). `-e` preserves ANSI escape sequences so colours and
if (res.code !== 0) { // cursor position survive the replay. Returns empty string on failure — the
log.error({ res }, 'tmux new-window failed'); // client falls back to whatever tmux itself decides to repaint, which is
throw new Error(`tmux new-window failed: ${res.stderr}`); // non-fatal but visually noisier.
} //
// v1.10.8d: strip trailing blank rows. tmux capture-pane emits one `\n` per
// pane row (including all the empty rows below the actual content), so on a
// fresh 35-row pane with just the bash prompt at row 0, the output is
// `<prompt>` followed by 35 `\n` bytes. When xterm.write()s those naively,
// the cursor advances row-by-row until it hits the bottom of the canvas and
// scrolls — pushing the prompt into the scrollback buffer where the user
// can't see it. Stripping the trailing newlines leaves xterm's cursor at the
// natural end of the rendered content (matching tmux's actual cursor
// position for the common single-line-prompt case).
export async function capturePane(
tmuxConfPath: string,
sessionName: string,
lines: number = 2000,
): Promise<string> {
const res = await runTmux(tmuxConfPath, [
'capture-pane', '-t', sessionName, '-p', '-e', '-S', `-${lines}`,
]);
if (res.code !== 0) return '';
return res.stdout.replace(/(?:\r?\n)+$/, '');
} }

View File

@@ -3,7 +3,6 @@ import type { IPty } from 'node-pty';
export interface AttachPtyOptions { export interface AttachPtyOptions {
sessionName: string; sessionName: string;
windowName: string;
projectRoot: string; projectRoot: string;
cols: number; cols: number;
rows: number; rows: number;
@@ -19,16 +18,24 @@ function cleanEnv(): { [key: string]: string } {
return out; return out;
} }
// Spawns a tmux client attached to the given session+window. `-d` detaches any // v1.10.8c: no `-d` (multi-attach friendly — boolab pattern). With per-pane
// other client so a browser refresh takes over the same window without // tmux sessions, dropping `-d` means multiple browser tabs viewing the same
// duplicate input. tmux server (and the window) persists across PTY exits. // pane share one tmux session as N clients; tmux fans I/O at the session
// layer just like boolab's backend. The earlier `-d` flag detached EVERY
// other client of the session — across windows — which caused the
// "[detached] from session" bug whenever a new pane attached to a chat
// session that already had another pane open.
//
// Tmux server + session persist across PTY exits, so a refresh resumes with
// full scrollback. Explicit destroy happens via the /kill route (called from
// the frontend when the user closes a pane).
export function attachPty(opts: AttachPtyOptions): IPty { export function attachPty(opts: AttachPtyOptions): IPty {
return pty.spawn( return pty.spawn(
'tmux', 'tmux',
[ [
'-f', opts.tmuxConfPath, '-f', opts.tmuxConfPath,
'attach-session', '-d', 'attach-session',
'-t', `${opts.sessionName}:${opts.windowName}`, '-t', opts.sessionName,
], ],
{ {
name: 'xterm-256color', name: 'xterm-256color',

View File

@@ -4,22 +4,33 @@ import { getSessionInfo } from '../db.js';
import { import {
sanitizeId, sanitizeId,
tmuxSessionName, tmuxSessionName,
tmuxWindowName, ensureSession,
ensureWindow, killSession,
killWindow,
hasSession, hasSession,
listWindows,
} from '../pty/manager.js'; } from '../pty/manager.js';
import { resizePane } from '../ws/attach.js';
const ParamsSchema = z.object({ sid: z.string(), pid: z.string() }); const ParamsSchema = z.object({ sid: z.string(), pid: z.string() });
const ResizeBodySchema = z.object({ // v1.10.8c: optional cols/rows on /start so the per-pane tmux session is
cols: z.coerce.number().int().min(1).max(2000), // born at the right dimensions. Bodyless POSTs remain valid (Fastify's
rows: z.coerce.number().int().min(1).max(2000), // tolerant parser).
}); const StartBodySchema = z
.object({
cols: z.coerce.number().int().min(1).max(2000).optional(),
rows: z.coerce.number().int().min(1).max(2000).optional(),
})
.partial()
.optional();
export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: string): void { export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: string): void {
app.post<{ Params: { sid: string; pid: string } }>( // v1.10.8c: /start creates the per-pane tmux session. Idempotent — a second
// /start on the same paneId is a no-op (hasSession returns true). The WS
// attach handler also calls ensureSession as belt-and-suspenders, so /start
// is technically optional, but having it as a separate step surfaces tmux
// errors as HTTP responses (vs WS 1011 close codes).
app.post<{
Params: { sid: string; pid: string };
Body: { cols?: number; rows?: number } | undefined;
}>(
'/api/term/sessions/:sid/panes/:pid/start', '/api/term/sessions/:sid/panes/:pid/start',
async (req, reply) => { async (req, reply) => {
const p = ParamsSchema.safeParse(req.params); const p = ParamsSchema.safeParse(req.params);
@@ -28,39 +39,35 @@ export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: strin
const pid = sanitizeId(p.data.pid); const pid = sanitizeId(p.data.pid);
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' }); if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
const b = StartBodySchema.safeParse(req.body ?? {});
const cols = b.success ? b.data?.cols : undefined;
const rows = b.success ? b.data?.rows : undefined;
const session = await getSessionInfo(sid); const session = await getSessionInfo(sid);
if (!session) return reply.code(404).send({ error: 'unknown_session' }); if (!session) return reply.code(404).send({ error: 'unknown_session' });
const sessionName = tmuxSessionName(sid); const sessionName = tmuxSessionName(pid);
const windowName = tmuxWindowName(pid);
try { try {
await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log); await ensureSession(
tmuxConfPath,
sessionName,
session.project_path,
req.log,
cols,
rows,
);
} catch (err) { } catch (err) {
req.log.error({ err }, 'ensureWindow failed'); req.log.error({ err }, 'ensureSession failed');
return reply.code(500).send({ error: 'tmux_failed' }); return reply.code(500).send({ error: 'tmux_failed' });
} }
return reply.code(200).send({ tmux_window: windowName }); return reply.code(200).send({ tmux_session: sessionName });
},
);
app.post<{ Params: { sid: string; pid: string }; Body: { cols: number; rows: number } }>(
'/api/term/sessions/:sid/panes/:pid/resize',
async (req, reply) => {
const p = ParamsSchema.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
const b = ResizeBodySchema.safeParse(req.body);
if (!b.success) return reply.code(400).send({ error: 'bad_body' });
const sid = sanitizeId(p.data.sid);
const pid = sanitizeId(p.data.pid);
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
const ok = resizePane(pid, b.data.cols, b.data.rows);
if (!ok) return reply.code(404).send({ error: 'no_active_pty' });
return reply.code(200).send({ ok: true });
}, },
); );
// v1.10.8c: explicit pane teardown. Frontend calls this when the user
// intentionally closes a terminal pane (vs an implicit WS disconnect, which
// leaves the tmux session intact for refresh-driven resume).
app.post<{ Params: { sid: string; pid: string } }>( app.post<{ Params: { sid: string; pid: string } }>(
'/api/term/sessions/:sid/panes/:pid/kill', '/api/term/sessions/:sid/panes/:pid/kill',
async (req, reply) => { async (req, reply) => {
@@ -70,19 +77,17 @@ export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: strin
const pid = sanitizeId(p.data.pid); const pid = sanitizeId(p.data.pid);
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' }); if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
const sessionName = tmuxSessionName(sid); const sessionName = tmuxSessionName(pid);
const windowName = tmuxWindowName(pid);
if (!(await hasSession(tmuxConfPath, sessionName))) { if (!(await hasSession(tmuxConfPath, sessionName))) {
return reply.code(404).send({ error: 'unknown_session' });
}
const windows = await listWindows(tmuxConfPath, sessionName);
if (!windows.includes(windowName)) {
return reply.code(404).send({ error: 'unknown_pane' }); return reply.code(404).send({ error: 'unknown_pane' });
} }
const killed = await killWindow(tmuxConfPath, sessionName, windowName); const killed = await killSession(tmuxConfPath, sessionName);
if (!killed) return reply.code(500).send({ error: 'tmux_kill_failed' }); if (!killed) return reply.code(500).send({ error: 'tmux_kill_failed' });
return reply.code(200).send({ ok: true }); return reply.code(200).send({ ok: true });
}, },
); );
// Resize endpoint removed in v1.10.8c. Resize now flows in-band via the
// WebSocket as a `{type:"resize",cols,rows}` text frame — no more race
// between active-PTY-map registration and HTTP POST lookup. See ws/attach.ts.
} }

View File

@@ -1,25 +1,15 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { IPty } from 'node-pty'; import type { IPty } from 'node-pty';
import { getSessionInfo } from '../db.js'; import { getSessionInfo } from '../db.js';
import { sanitizeId, tmuxSessionName, tmuxWindowName, ensureWindow } from '../pty/manager.js'; import {
sanitizeId,
tmuxSessionName,
ensureSession,
capturePane,
} from '../pty/manager.js';
import { attachPty } from '../pty/pty.js'; import { attachPty } from '../pty/pty.js';
import { getUser } from '../auth.js'; import { getUser } from '../auth.js';
// Registry of currently-attached PTYs keyed by paneId. Used by the resize REST
// route to find the active node-pty handle so it can call pty.resize(cols, rows).
const active = new Map<string, IPty>();
export function resizePane(paneId: string, cols: number, rows: number): boolean {
const handle = active.get(paneId);
if (!handle) return false;
try {
handle.resize(cols, rows);
return true;
} catch {
return false;
}
}
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void { export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
app.get<{ app.get<{
Params: { sid: string; pid: string }; Params: { sid: string; pid: string };
@@ -44,24 +34,33 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
return; return;
} }
const sessionName = tmuxSessionName(sid); const sessionName = tmuxSessionName(pid);
const windowName = tmuxWindowName(pid); const cols = parseInt(req.query.cols ?? '', 10) || 80;
const rows = parseInt(req.query.rows ?? '', 10) || 24;
// Idempotent — /start typically created the session already, but cover
// the race where the client opens the WS before /start's response lands
// (or skips /start entirely). With per-pane tmux sessions there's no
// cross-pane interference, so creating-on-attach is safe.
try { try {
await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log); await ensureSession(
tmuxConfPath,
sessionName,
session.project_path,
req.log,
cols,
rows,
);
} catch (err) { } catch (err) {
req.log.error({ err }, 'ensureWindow failed in WS handler'); req.log.error({ err }, 'ensureSession failed in WS handler');
socket.close(1011, 'tmux_failed'); socket.close(1011, 'tmux_failed');
return; return;
} }
const cols = parseInt(req.query.cols ?? '', 10) || 80;
const rows = parseInt(req.query.rows ?? '', 10) || 24;
let handle: IPty; let handle: IPty;
try { try {
handle = attachPty({ handle = attachPty({
sessionName, sessionName,
windowName,
projectRoot: session.project_path, projectRoot: session.project_path,
cols, cols,
rows, rows,
@@ -73,9 +72,31 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
return; return;
} }
active.set(pid, handle); // Frame contract (boolab pattern):
// server → client text: JSON control — `init` on connect, `exit` on PTY death
// server → client binary: raw PTY bytes (first frame after init = capture-pane replay)
// client → server binary: user keystrokes
// client → server text: JSON control — `{type:"resize", cols, rows}`
//
// The init frame lets the client term.clear() before paint so a remount
// doesn't show stale buffer content. The capture-pane replay then
// paints the current tmux pane state into the fresh xterm.
try {
socket.send(JSON.stringify({ type: 'init', cols, rows, tmux_session: sessionName }));
} catch (err) {
req.log.warn({ err }, 'init frame send failed');
}
const onData = (data: string) => { try {
const capture = await capturePane(tmuxConfPath, sessionName);
if (capture.length > 0) {
socket.send(Buffer.from(capture, 'utf8'), { binary: true });
}
} catch (err) {
req.log.warn({ err }, 'capture-pane failed');
}
const onData = (data: string): void => {
if (socket.readyState !== socket.OPEN) return; if (socket.readyState !== socket.OPEN) return;
try { try {
socket.send(Buffer.from(data, 'utf8'), { binary: true }); socket.send(Buffer.from(data, 'utf8'), { binary: true });
@@ -85,13 +106,32 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
}; };
handle.onData(onData); handle.onData(onData);
socket.on('message', (data: Buffer | string) => { socket.on('message', (rawData: Buffer | string, isBinary?: boolean) => {
// ws v8 emits Buffer + isBinary boolean; older versions emit string
// for text frames. Either way: text path tries JSON parse for the
// resize control; binary path writes to the PTY.
const isTextFrame = typeof rawData === 'string' || isBinary === false;
if (isTextFrame) {
const text = typeof rawData === 'string' ? rawData : rawData.toString('utf8');
try { try {
if (typeof data === 'string') { const parsed = JSON.parse(text) as { type?: string; cols?: number; rows?: number };
handle.write(data); if (parsed.type === 'resize') {
} else { const newCols = Math.max(1, Math.min(2000, Math.floor(Number(parsed.cols) || 80)));
handle.write(data.toString('utf8')); const newRows = Math.max(1, Math.min(2000, Math.floor(Number(parsed.rows) || 24)));
req.log.info({ pid, cols: newCols, rows: newRows }, 'resize');
try {
handle.resize(newCols, newRows);
} catch {
/* ignore — invalid winsize bubble */
} }
}
} catch {
/* malformed text frame — drop silently */
}
return;
}
try {
handle.write((rawData as Buffer).toString('utf8'));
} catch (err) { } catch (err) {
req.log.warn({ err }, 'pty write failed'); req.log.warn({ err }, 'pty write failed');
} }
@@ -110,13 +150,13 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
} catch { } catch {
/* ignore */ /* ignore */
} }
if (active.get(pid) === handle) active.delete(pid);
}); });
// WS close kills the local PTY (the tmux client). The tmux server and // WS close kills the tmux client (the local PTY) but the tmux server +
// window persist so a refresh resumes with full scrollback. // session persist so a refresh resumes with full scrollback. Permanent
// teardown happens via the /kill route called from the frontend when the
// user closes the pane.
socket.on('close', () => { socket.on('close', () => {
if (active.get(pid) === handle) active.delete(pid);
try { try {
handle.kill(); handle.kill();
} catch { } catch {

View File

@@ -1,20 +1,30 @@
set -g default-terminal "screen-256color" set -g default-terminal "screen-256color"
set -g history-limit 50000 set -g history-limit 50000
# v1.10.8c: per-pane tmux sessions (boolab pattern). With one session per
# pane, the session size adapts to the attached client; `window-size = largest`
# + `aggressive-resize on` make tmux pick up the client's actual cols/rows
# instead of falling back to 80x24. Critical for opencode/claude TUIs that
# read TIOCGWINSZ once at fork time.
set -g window-size largest
set -g aggressive-resize on
# v1.10.3: `set -g mouse on` removed. tmux's mouse mode captured wheel/touch # v1.10.3: `set -g mouse on` removed. tmux's mouse mode captured wheel/touch
# events at the protocol level, so xterm.js never saw them and the viewport # events at the protocol level, so xterm.js never saw them and the viewport
# couldn't scroll on mobile. With mouse off, xterm.js handles scrollback # couldn't scroll on mobile. With mouse off, xterm.js handles scrollback
# natively (wheel on desktop, finger-drag on mobile via touch-action: pan-y). # natively (wheel on desktop, finger-drag on mobile via touch-action: pan-y).
# Tradeoff: lose tmux mouse pane-resize and scroll-inside-vim; acceptable for # Tradeoff: lose tmux mouse pane-resize and scroll-inside-vim; acceptable for
# the homelab single-user setup. # the homelab single-user setup.
set -g mouse off
setw -g mode-keys vi setw -g mode-keys vi
set -g status off set -g status off
set -g destroy-unattached off set -g destroy-unattached off
# v1.10.1: shells drop privs to samkintop (uid 1000) so the terminal runs in # v1.10.1: shells drop privs to samkintop (uid 1000) so the terminal runs in
# the user's environment, not root. `env HOME=… USER=…` is required because # the user's environment, not root. `env HOME=… USER=…` is required because
# gosu (and su-exec before it) only changes uid/gid — it leaves env intact, # gosu only changes uid/gid — env (including HOME) survives, and the tmux
# and the tmux server runs as root so HOME would otherwise be /root. bash -l # server runs as root so HOME would otherwise be /root. bash -l then sources
# then sources samkintop's ~/.profile / ~/.bashrc to pick up PATH (nvm, # samkintop's ~/.profile / ~/.bashrc to pick up PATH (nvm, ~/.local/bin,
# ~/.local/bin, ~/.opencode/bin). # ~/.opencode/bin).
# v1.10.2: su-exec → gosu (alpine → debian; functionally identical). # v1.10.2: su-exec → gosu (alpine → debian; functionally identical).
set -g default-command "gosu samkintop:samkintop env HOME=/home/samkintop USER=samkintop SHELL=/bin/bash bash -l" set -g default-command "gosu samkintop:samkintop env HOME=/home/samkintop USER=samkintop SHELL=/bin/bash bash -l"

View File

@@ -12,6 +12,11 @@
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8",
"@xterm/addon-fit": "0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "0.11.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "5.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
@@ -26,11 +31,7 @@
"shiki": "^1.29.2", "shiki": "^1.29.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0"
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-search": "^0.13.0",
"xterm-addon-web-links": "^0.9.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0",

View File

@@ -68,8 +68,13 @@ function AppShell() {
// theme class on <html> is correct before any child renders. // theme class on <html> is correct before any child renders.
useTheme(); useTheme();
useUserEvents(); useUserEvents();
// v1.10.8c: h-dvh (dynamic viewport) instead of h-screen (100vh) so the
// root height excludes the iOS URL-bar overlay area. Without this, every
// descendant — including the terminal pane — measures itself against a
// height that extends behind the URL bar, and xterm allocates extra rows
// that scroll out of reach on iPhone.
return ( return (
<div className="h-screen flex bg-background text-foreground"> <div className="h-dvh flex bg-background text-foreground">
<ProjectSidebar /> <ProjectSidebar />
<MobileBackdrop /> <MobileBackdrop />
<main className="flex-1 flex flex-col min-w-0"> <main className="flex-1 flex flex-col min-w-0">

View File

@@ -264,18 +264,23 @@ export const api = {
// v1.10 booterm: REST control plane for terminal panes. WebSocket attach // v1.10 booterm: REST control plane for terminal panes. WebSocket attach
// lives at /ws/term/sessions/:sid/panes/:pid (handled directly by // lives at /ws/term/sessions/:sid/panes/:pid (handled directly by
// TerminalPane). All three endpoints are tolerant of empty bodies on the // TerminalPane). v1.10.8c: resize moved in-band onto the WebSocket as a
// POSTs that don't take parameters. // `{type:"resize",cols,rows}` text frame — the old /resize HTTP endpoint is
// gone, eliminating the race between WS attach and PTY-map registration.
terminals: { terminals: {
start: (sessionId: string, paneId: string) => // cols/rows are optional. When passed, booterm sizes the per-pane tmux
request<{ tmux_window: string }>( // session at creation time so the inner bash (and any TUI it spawns) is
// born with the correct PTY dimensions instead of tmux's 80x24 default.
start: (sessionId: string, paneId: string, cols?: number, rows?: number) =>
request<{ tmux_session: string }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/start`, `/api/term/sessions/${sessionId}/panes/${paneId}/start`,
{ method: 'POST' }, {
), method: 'POST',
resize: (sessionId: string, paneId: string, cols: number, rows: number) => body:
request<{ ok: true }>( cols !== undefined && rows !== undefined
`/api/term/sessions/${sessionId}/panes/${paneId}/resize`, ? JSON.stringify({ cols, rows })
{ method: 'POST', body: JSON.stringify({ cols, rows }) }, : undefined,
},
), ),
kill: (sessionId: string, paneId: string) => kill: (sessionId: string, paneId: string) =>
request<{ ok: true }>( request<{ ok: true }>(

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react'; import type { DragEvent } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api/client';
import type { WorkspacePane } from '@/api/types'; import type { WorkspacePane } from '@/api/types';
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane'; import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
@@ -302,11 +303,19 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
} }
return prev; return prev;
} }
// v1.10.8c: with per-pane tmux sessions, an unkilled session leaks until
// the next `tmux kill-server`. Fire-and-forget /kill on terminal removal.
// The endpoint is idempotent (404 on missing session) so a strict-mode
// double-invoke of the updater is safe.
const removed = prev[idx];
if (removed?.kind === 'terminal') {
api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ });
}
const next = prev.filter((_, i) => i !== idx); const next = prev.filter((_, i) => i !== idx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1)); setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next; return next;
}); });
}, []); }, [sessionId]);
// Replaces a single empty default pane with a chat pane. Used by the initial // Replaces a single empty default pane with a chat pane. Used by the initial
// chat fetch to land on the most-recent open chat if no saved pane state. // chat fetch to land on the most-recent open chat if no saved pane state.

View File

@@ -1,3 +1,8 @@
// Fonts imported as JS side-effect modules (boolab pattern, adapted for
// Tailwind v4 + Vite asset-pipeline URL rewriting). Must precede the React
// imports so the @font-face CSS lands before any component-tree render.
import '@fontsource-variable/inter';
import '@fontsource-variable/jetbrains-mono';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';

View File

@@ -1,8 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@import "@fontsource-variable/inter"; /* @fontsource-variable JBM + Inter imported from main.tsx as JS modules. */
@import "@fontsource-variable/jetbrains-mono";
/* themes-v1: 18 preset palettes. Order matches docs/themes_v1.md §1 with /* themes-v1: 18 preset palettes. Order matches docs/themes_v1.md §1 with
obsidian first (default). Each file declares .theme-<id> for the light obsidian first (default). Each file declares .theme-<id> for the light
@@ -152,3 +151,96 @@
@apply font-sans; @apply font-sans;
} }
} }
/*
* iOS Safari auto-enlarges text in narrow viewports (anti-zoom). On its own
* that's fine for HTML chrome, but xterm.js measures its cell width from a
* hidden text-measure element — so when iOS up-sizes that element, xterm
* computes wider cells and the terminal ends up at fewer cols than it should.
* In opencode this surfaces as the small fragmented banner instead of the
* big chunky one (opencode picks the banner glyph set based on terminal
* width). 100% disables the auto-adjust and keeps boocode at the same
* effective cols as boolab on the same iPhone.
*/
html, body {
-webkit-text-size-adjust: 100% !important;
-ms-text-size-adjust: 100% !important;
text-size-adjust: 100% !important;
}
/* iOS Safari auto-zooms when a user taps an input/textarea whose font-size
* is under 16px. Pin every input/textarea/select to 16px (boolab pattern)
* to suppress the zoom — applies globally; specific components can override
* with `text-base` or inline if a smaller visual is intentional. */
input, textarea, select {
font-size: 16px !important;
}
/*
* xterm.js overrides (boolab pattern — see /opt/boolab/frontend/src/styles/globals.css).
*
* Why these live in a global stylesheet, not in an inline <style> inside the
* component: an inline <style> inserted at component-mount time races the
* upstream @xterm/xterm/css/xterm.css that ships with the addon. We saw the
* right-edge stripe persist on iOS even though the override was identical to
* boolab's — moving the rules here so they're parsed alongside index.css
* eliminates that race.
*/
.xterm,
.xterm *,
.xterm .xterm-rows,
.xterm .xterm-rows * {
font-family: 'JetBrains Mono Variable', 'JetBrains Mono', 'Fira Code', Menlo, monospace !important;
}
/* Fill the host node — xterm's only non-absolute sizing comes from the canvas,
* and fractional rounding would otherwise leave a phantom right-edge stripe.
*/
.xterm {
width: 100% !important;
height: 100% !important;
}
/* Lock cell metrics so block-element glyphs (U+2580..U+259F) tile without
* subpixel gaps. Any non-zero letter-spacing or line-height ≠ 1 leaves
* fractional space between cells that paints as a horizontal/vertical
* stripe through the opencode banner on iOS. Disabling ligatures
* (font-feature-settings + font-variant-ligatures) prevents the renderer
* from collapsing adjacent block chars into shaped glyphs at unpredictable
* widths.
*/
.xterm,
.xterm .xterm-rows {
letter-spacing: 0 !important;
line-height: 1 !important;
font-feature-settings: "liga" 0, "calt" 0 !important;
font-variant-ligatures: none !important;
}
.xterm .xterm-viewport {
overflow-y: hidden !important;
scrollbar-width: none !important;
-ms-overflow-style: none !important;
/*
* xterm.css ships `background-color: #000` on the viewport (kept for OS X
* scrollbar opacity in the upstream default). FitAddon rounds cols down
* to integer cells, so .xterm-screen is up to `cellWidth - 1` pixels
* narrower than .xterm-viewport — the strip between the canvas right
* edge and the viewport right edge then paints viewport's #000, which
* differs from the theme background (#0b0f14, set on the host wrapper in
* TerminalPane.tsx + via Terminal options.theme.background) and shows up
* as a visible right-edge gap.
*
* Setting viewport's background transparent lets the host wrapper's
* #0b0f14 show through, hiding the sub-cell remainder. Single source of
* truth for the bg color: the host.
*/
background-color: transparent !important;
}
.xterm .xterm-viewport::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important;
}

100
pnpm-lock.yaml generated
View File

@@ -91,6 +91,21 @@ importers:
'@fontsource-variable/jetbrains-mono': '@fontsource-variable/jetbrains-mono':
specifier: ^5.2.8 specifier: ^5.2.8
version: 5.2.8 version: 5.2.8
'@xterm/addon-fit':
specifier: 0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
'@xterm/addon-search':
specifier: ^0.15.0
version: 0.15.0(@xterm/xterm@5.5.0)
'@xterm/addon-web-links':
specifier: 0.11.0
version: 0.11.0(@xterm/xterm@5.5.0)
'@xterm/addon-webgl':
specifier: ^0.19.0
version: 0.19.0
'@xterm/xterm':
specifier: 5.5.0
version: 5.5.0
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@@ -136,18 +151,6 @@ importers:
tw-animate-css: tw-animate-css:
specifier: ^1.4.0 specifier: ^1.4.0
version: 1.4.0 version: 1.4.0
xterm:
specifier: ^5.3.0
version: 5.3.0
xterm-addon-fit:
specifier: ^0.8.0
version: 0.8.0(xterm@5.3.0)
xterm-addon-search:
specifier: ^0.13.0
version: 0.13.0(xterm@5.3.0)
xterm-addon-web-links:
specifier: ^0.9.0
version: 0.9.0(xterm@5.3.0)
devDependencies: devDependencies:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4.3.0 specifier: ^4.3.0
@@ -1843,6 +1846,27 @@ packages:
'@vitest/utils@3.2.4': '@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
'@xterm/addon-fit@0.10.0':
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-search@0.15.0':
resolution: {integrity: sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-web-links@0.11.0':
resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-webgl@0.19.0':
resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==}
'@xterm/xterm@5.5.0':
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
abstract-logging@2.0.1: abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@@ -3906,28 +3930,6 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
xterm-addon-fit@0.8.0:
resolution: {integrity: sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==}
deprecated: This package is now deprecated. Move to @xterm/addon-fit instead.
peerDependencies:
xterm: ^5.0.0
xterm-addon-search@0.13.0:
resolution: {integrity: sha512-sDUwG4CnqxUjSEFh676DlS3gsh3XYCzAvBPSvJ5OPgF3MRL3iHLPfsb06doRicLC2xXNpeG2cWk8x1qpESWJMA==}
deprecated: This package is now deprecated. Move to @xterm/addon-search instead.
peerDependencies:
xterm: ^5.0.0
xterm-addon-web-links@0.9.0:
resolution: {integrity: sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==}
deprecated: This package is now deprecated. Move to @xterm/addon-web-links instead.
peerDependencies:
xterm: ^5.0.0
xterm@5.3.0:
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
deprecated: This package is now deprecated. Move to @xterm/xterm instead.
y18n@5.0.8: y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -5601,6 +5603,22 @@ snapshots:
loupe: 3.2.1 loupe: 3.2.1
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-search@0.15.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-webgl@0.19.0': {}
'@xterm/xterm@5.5.0': {}
abstract-logging@2.0.1: {} abstract-logging@2.0.1: {}
accepts@2.0.0: accepts@2.0.0:
@@ -7972,20 +7990,6 @@ snapshots:
xtend@4.0.2: {} xtend@4.0.2: {}
xterm-addon-fit@0.8.0(xterm@5.3.0):
dependencies:
xterm: 5.3.0
xterm-addon-search@0.13.0(xterm@5.3.0):
dependencies:
xterm: 5.3.0
xterm-addon-web-links@0.9.0(xterm@5.3.0):
dependencies:
xterm: 5.3.0
xterm@5.3.0: {}
y18n@5.0.8: {} y18n@5.0.8: {}
yallist@3.1.1: {} yallist@3.1.1: {}