Compare commits
9 Commits
v1.10.1-bo
...
2d841ee0b4
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d841ee0b4 | |||
| 8cea4a899c | |||
| 3fceea064a | |||
| fccab20920 | |||
| ea9d261f0f | |||
| 4d466c5710 | |||
| 875db86e31 | |||
| 8eaf9591dc | |||
| 5d52b79a07 |
21
CLAUDE.md
21
CLAUDE.md
@@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side).
|
Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side).
|
||||||
|
|
||||||
|
Plus `apps/booterm` (second container, port 9501, bookworm-slim+glibc): Fastify + node-pty + tmux. Browser terminal panes WS to `/ws/term/sessions/:sid/panes/:pid`; per-session tmux session `bc-<sid>`, per-pane window `term-<pid>`. Shells drop privs to samkintop via `gosu` in `tmux.conf` default-command.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -35,7 +37,7 @@ Tests: `pnpm -C apps/server test` runs 23 vitest tests. No test harness on `apps
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
**Monorepo**: pnpm workspaces with `apps/server` (Fastify + postgres) and `apps/web` (React + Vite).
|
**Monorepo**: pnpm workspaces with `apps/server` (Fastify + postgres), `apps/web` (React + Vite), and `apps/booterm` (Fastify + node-pty + tmux).
|
||||||
|
|
||||||
### Server (`apps/server/src/`)
|
### Server (`apps/server/src/`)
|
||||||
|
|
||||||
@@ -66,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
|
||||||
@@ -99,6 +108,12 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
|||||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||||
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
|
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
|
||||||
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
|
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
|
||||||
|
- `node:20-*` base images ship a `node` user at uid/gid 1000 — delete it (`userdel`/`groupdel` on debian, `deluser`/`delgroup` on alpine) before adding samkintop at 1000.
|
||||||
|
- 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.
|
||||||
|
- 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
|
||||||
|
|
||||||
@@ -109,3 +124,7 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
|||||||
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
|
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
|
||||||
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
|
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
|
||||||
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
|
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
|
||||||
|
- Two UI event buses: `hooks/sessionEvents.ts` for DB-state events (chat_created, session_updated); `lib/events.ts` for ephemeral UI (`sendToTerminal`, `terminalsRegistry`). Don't merge — different subscriber lifecycles.
|
||||||
|
- `vite.config.ts` proxy entries are order-sensitive: more-specific prefixes (`/api/term`, `/ws/term`) must come BEFORE `/api`.
|
||||||
|
- Mobile pane URL sync (`Session.tsx`): the `?pane=<id>` effect resets `activePaneIdx` whenever `panes` changes. New-pane creation on mobile must push `?pane=` atomically — `addPaneAndSwitch` is the wrapper that does this. `addSplitPane` returns the new pane id for callers.
|
||||||
|
- xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path.
|
||||||
|
|||||||
@@ -15,28 +15,48 @@ COPY apps/booterm ./apps/booterm
|
|||||||
RUN pnpm --filter=@boocode/booterm build
|
RUN pnpm --filter=@boocode/booterm build
|
||||||
|
|
||||||
# ---- Prod-deps stage: hoisted, native built via npm rebuild ----
|
# ---- Prod-deps stage: hoisted, native built via npm rebuild ----
|
||||||
FROM node:20-alpine AS proddeps
|
# v1.10.2: switched to bookworm-slim (glibc) so node-pty's native .node is
|
||||||
|
# compiled against the same libc as the runtime stage. A musl-built .node
|
||||||
|
# won't dlopen in a glibc node binary, so both stages must match.
|
||||||
|
FROM node:20-bookworm-slim AS proddeps
|
||||||
ENV COREPACK_DEFAULT_TO_LATEST=0
|
ENV COREPACK_DEFAULT_TO_LATEST=0
|
||||||
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
|
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 make g++ ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /prod
|
WORKDIR /prod
|
||||||
COPY apps/booterm/package.json ./package.json
|
COPY apps/booterm/package.json ./package.json
|
||||||
RUN pnpm install --prod --config.node-linker=hoisted --config.strict-peer-dependencies=false
|
RUN pnpm install --prod --config.node-linker=hoisted --config.strict-peer-dependencies=false
|
||||||
# pnpm 10 ignores build scripts; force compile with npm directly.
|
# pnpm 10 ignores build scripts; force compile with npm directly.
|
||||||
# node-gyp is bundled with npm in the node:20-alpine image.
|
# node-gyp is bundled with npm in the node:20-bookworm-slim image.
|
||||||
RUN cd node_modules/node-pty && npm run install
|
RUN cd node_modules/node-pty && npm run install
|
||||||
# Sanity check — fail the build if the artifact still isn't there
|
# Sanity check — fail the build if the artifact still isn't there
|
||||||
RUN test -f node_modules/node-pty/build/Release/pty.node && echo "pty.node OK" || (echo "pty.node MISSING" && exit 1)
|
RUN test -f node_modules/node-pty/build/Release/pty.node && echo "pty.node OK" || (echo "pty.node MISSING" && exit 1)
|
||||||
|
|
||||||
# ---- Runtime ----
|
# ---- Runtime ----
|
||||||
FROM node:20-alpine AS runtime
|
# v1.10.2: switched from node:20-alpine (musl) to node:20-bookworm-slim (glibc)
|
||||||
RUN apk add --no-cache tmux libstdc++ bash su-exec shadow
|
# so glibc-linked binaries from /home/samkintop (Claude Code, opencode, the
|
||||||
# v1.10.1: terminal shells inside tmux drop privs to samkintop via su-exec.
|
# 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.
|
||||||
|
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 \
|
||||||
|
tmux bash gosu ca-certificates procps openssh-client \
|
||||||
|
&& 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.
|
||||||
RUN deluser --remove-home node 2>/dev/null; delgroup node 2>/dev/null; \
|
# bookworm-slim ships a `node` user at 1000 — wipe whatever sits on uid/gid
|
||||||
addgroup -g 1000 samkintop && \
|
# 1000 first, then create samkintop fresh.
|
||||||
adduser -D -u 1000 -G samkintop -s /bin/bash samkintop
|
RUN if id -u 1000 >/dev/null 2>&1; then \
|
||||||
|
userdel -r "$(id -un 1000)" 2>/dev/null || true; \
|
||||||
|
fi; \
|
||||||
|
if getent group 1000 >/dev/null 2>&1; then \
|
||||||
|
groupdel "$(getent group 1000 | cut -d: -f1)" 2>/dev/null || true; \
|
||||||
|
fi; \
|
||||||
|
groupadd -g 1000 samkintop && \
|
||||||
|
useradd -m -u 1000 -g 1000 -s /bin/bash samkintop
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /build/apps/booterm/dist ./dist
|
COPY --from=builder /build/apps/booterm/dist ./dist
|
||||||
COPY --from=proddeps /prod/package.json ./package.json
|
COPY --from=proddeps /prod/package.json ./package.json
|
||||||
|
|||||||
@@ -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)+$/, '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
set -g default-terminal "screen-256color"
|
set -g default-terminal "screen-256color"
|
||||||
set -g history-limit 50000
|
set -g history-limit 50000
|
||||||
set -g mouse on
|
|
||||||
|
# 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
|
||||||
|
# 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
|
||||||
|
# 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
|
||||||
|
# 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
|
||||||
# su-exec only changes uid/gid — it leaves env intact, and tmux server runs
|
# gosu only changes uid/gid — env (including HOME) survives, and the tmux
|
||||||
# as root so HOME would otherwise be /root. bash -l then sources samkintop's
|
# server runs as root so HOME would otherwise be /root. bash -l then sources
|
||||||
# ~/.profile / ~/.bashrc to pick up PATH (nvm, ~/.local/bin, ~/.opencode/bin).
|
# samkintop's ~/.profile / ~/.bashrc to pick up PATH (nvm, ~/.local/bin,
|
||||||
set -g default-command "su-exec samkintop:samkintop env HOME=/home/samkintop USER=samkintop SHELL=/bin/bash bash -l"
|
# ~/.opencode/bin).
|
||||||
|
# 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"
|
||||||
|
|||||||
@@ -310,6 +310,70 @@ interface StreamOptions {
|
|||||||
temperature?: number;
|
temperature?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.10.5 Qwen-coder XML fallback. Some local models (notably qwen3-coder via
|
||||||
|
// llama-swap) emit tool calls as inline XML inside delta.content rather than
|
||||||
|
// the structured delta.tool_calls field. The XML shape is:
|
||||||
|
// <tool_call>
|
||||||
|
// <function=NAME>
|
||||||
|
// <parameter=KEY>
|
||||||
|
// VALUE
|
||||||
|
// </parameter>
|
||||||
|
// ...more parameters...
|
||||||
|
// </function>
|
||||||
|
// </tool_call>
|
||||||
|
// Multiple <tool_call> blocks may appear back-to-back; they never nest.
|
||||||
|
// streamCompletion buffers delta.content, extracts complete blocks, parses
|
||||||
|
// them via parseXmlToolCall, and pushes synthetic entries into the existing
|
||||||
|
// toolCallsBuffer alongside any native JSON-format tool calls.
|
||||||
|
const XML_TOOL_OPEN = '<tool_call>';
|
||||||
|
const XML_TOOL_CLOSE = '</tool_call>';
|
||||||
|
|
||||||
|
function parseXmlToolCall(
|
||||||
|
block: string,
|
||||||
|
): { name: string; args: Record<string, unknown> } | null {
|
||||||
|
const nameMatch = block.match(/<function=([^>]+)>/);
|
||||||
|
if (!nameMatch || !nameMatch[1]) return null;
|
||||||
|
const name = nameMatch[1].trim();
|
||||||
|
if (!name) return null;
|
||||||
|
const args: Record<string, unknown> = {};
|
||||||
|
// Non-greedy body so each <parameter=…>…</parameter> pair is matched
|
||||||
|
// independently even when multiple appear in the same block.
|
||||||
|
const paramRe = /<parameter=([^>]+)>([\s\S]*?)<\/parameter>/g;
|
||||||
|
for (const m of block.matchAll(paramRe)) {
|
||||||
|
const key = (m[1] ?? '').trim();
|
||||||
|
if (!key) continue;
|
||||||
|
const raw = (m[2] ?? '').trim();
|
||||||
|
try {
|
||||||
|
args[key] = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
args[key] = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { name, args };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the first character that begins (or completely contains) an
|
||||||
|
// unfinished <tool_call> opener in `s`. Returns -1 when `s` can be flushed
|
||||||
|
// to the client in full without risking a partial tag leak.
|
||||||
|
// Case 1: a full `<tool_call>` opener with no matching closer — caller
|
||||||
|
// must keep everything from that index forward until the next
|
||||||
|
// chunk arrives with the closer.
|
||||||
|
// Case 2: `s` ends with a strict prefix of `<tool_call>` (e.g. `<tool_c`).
|
||||||
|
// Caller must keep just that suffix in the buffer.
|
||||||
|
// Note: case 1 assumes the calling loop already extracted every complete
|
||||||
|
// <tool_call>…</tool_call> pair before reaching this check.
|
||||||
|
function partialXmlOpenerStart(s: string): number {
|
||||||
|
const fullOpener = s.indexOf(XML_TOOL_OPEN);
|
||||||
|
if (fullOpener !== -1) return fullOpener;
|
||||||
|
const lastLt = s.lastIndexOf('<');
|
||||||
|
if (lastLt === -1) return -1;
|
||||||
|
const suffix = s.slice(lastLt);
|
||||||
|
if (XML_TOOL_OPEN.startsWith(suffix) && suffix.length < XML_TOOL_OPEN.length) {
|
||||||
|
return lastLt;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
async function streamCompletion(
|
async function streamCompletion(
|
||||||
ctx: InferenceContext,
|
ctx: InferenceContext,
|
||||||
model: string,
|
model: string,
|
||||||
@@ -344,6 +408,10 @@ async function streamCompletion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let content = '';
|
let content = '';
|
||||||
|
// v1.10.5: holds delta.content bytes that may contain a partial XML tool
|
||||||
|
// call. Anything not part of a (possibly forming) <tool_call>…</tool_call>
|
||||||
|
// pair is flushed to content + onDelta as soon as we know it's safe.
|
||||||
|
let pendingBuffer = '';
|
||||||
let finishReason: string | null = null;
|
let finishReason: string | null = null;
|
||||||
let promptTokens: number | null = null;
|
let promptTokens: number | null = null;
|
||||||
let completionTokens: number | null = null;
|
let completionTokens: number | null = null;
|
||||||
@@ -377,8 +445,50 @@ async function streamCompletion(
|
|||||||
if (!choice) continue;
|
if (!choice) continue;
|
||||||
const delta = choice.delta ?? {};
|
const delta = choice.delta ?? {};
|
||||||
if (typeof delta.content === 'string' && delta.content.length > 0) {
|
if (typeof delta.content === 'string' && delta.content.length > 0) {
|
||||||
content += delta.content;
|
// v1.10.5 XML fallback. Append, then extract any complete tool_call
|
||||||
onDelta(delta.content);
|
// blocks before deciding what's safe to flush as visible content.
|
||||||
|
pendingBuffer += delta.content;
|
||||||
|
while (true) {
|
||||||
|
const startIdx = pendingBuffer.indexOf(XML_TOOL_OPEN);
|
||||||
|
if (startIdx === -1) break;
|
||||||
|
const closeIdx = pendingBuffer.indexOf(XML_TOOL_CLOSE, startIdx);
|
||||||
|
if (closeIdx === -1) break;
|
||||||
|
const blockEnd = closeIdx + XML_TOOL_CLOSE.length;
|
||||||
|
const block = pendingBuffer.slice(startIdx, blockEnd);
|
||||||
|
// Any text before the opener is plain content — flush it now.
|
||||||
|
if (startIdx > 0) {
|
||||||
|
const before = pendingBuffer.slice(0, startIdx);
|
||||||
|
content += before;
|
||||||
|
onDelta(before);
|
||||||
|
}
|
||||||
|
const parsedCall = parseXmlToolCall(block);
|
||||||
|
if (parsedCall) {
|
||||||
|
const synthIdx = toolCallsBuffer.size;
|
||||||
|
toolCallsBuffer.set(synthIdx, {
|
||||||
|
id: `xml_call_${synthIdx}`,
|
||||||
|
name: parsedCall.name,
|
||||||
|
argsText: JSON.stringify(parsedCall.args),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If parsing failed we still drop the block — emitting unparseable
|
||||||
|
// XML to the chat would look worse than silently swallowing it.
|
||||||
|
pendingBuffer = pendingBuffer.slice(blockEnd);
|
||||||
|
}
|
||||||
|
// After all complete blocks are out, hold back any (partial or full)
|
||||||
|
// unclosed opener; flush the rest.
|
||||||
|
const partialIdx = partialXmlOpenerStart(pendingBuffer);
|
||||||
|
if (partialIdx >= 0) {
|
||||||
|
if (partialIdx > 0) {
|
||||||
|
const flush = pendingBuffer.slice(0, partialIdx);
|
||||||
|
content += flush;
|
||||||
|
onDelta(flush);
|
||||||
|
}
|
||||||
|
pendingBuffer = pendingBuffer.slice(partialIdx);
|
||||||
|
} else if (pendingBuffer.length > 0) {
|
||||||
|
content += pendingBuffer;
|
||||||
|
onDelta(pendingBuffer);
|
||||||
|
pendingBuffer = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (Array.isArray(delta.tool_calls)) {
|
if (Array.isArray(delta.tool_calls)) {
|
||||||
for (const tc of delta.tool_calls) {
|
for (const tc of delta.tool_calls) {
|
||||||
@@ -393,6 +503,15 @@ async function streamCompletion(
|
|||||||
if (choice.finish_reason) finishReason = choice.finish_reason;
|
if (choice.finish_reason) finishReason = choice.finish_reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.10.5: if the stream ended mid-XML (e.g. model truncated, no closer
|
||||||
|
// ever arrived), flush whatever was buffered as plain content so it isn't
|
||||||
|
// silently dropped. Better to show a stray `<tool_call>` than vanish text.
|
||||||
|
if (pendingBuffer.length > 0) {
|
||||||
|
content += pendingBuffer;
|
||||||
|
onDelta(pendingBuffer);
|
||||||
|
pendingBuffer = '';
|
||||||
|
}
|
||||||
|
|
||||||
const toolCalls: ToolCall[] = [];
|
const toolCalls: ToolCall[] = [];
|
||||||
for (const [, t] of [...toolCallsBuffer.entries()].sort(([a], [b]) => a - b)) {
|
for (const [, t] of [...toolCallsBuffer.entries()].sort(([a], [b]) => a - b)) {
|
||||||
let args: Record<string, unknown> = {};
|
let args: Record<string, unknown> = {};
|
||||||
|
|||||||
@@ -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,10 +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-web-links": "^0.9.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 }>(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { AgentPicker } from '@/components/AgentPicker';
|
|||||||
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
|
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { chatInputsRegistry, sendToChat } from '@/lib/events';
|
||||||
import { useSkills } from '@/hooks/useSkills';
|
import { useSkills } from '@/hooks/useSkills';
|
||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
|
||||||
@@ -51,9 +52,16 @@ interface Props {
|
|||||||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||||
// disables slash-command dispatch (input is sent as literal text).
|
// disables slash-command dispatch (input is sent as literal text).
|
||||||
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
||||||
|
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input
|
||||||
|
// registers in chatInputsRegistry so the terminal floating menu can list
|
||||||
|
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
|
||||||
|
// an event appends the text to the current draft (with a newline separator
|
||||||
|
// when non-empty) and focuses — no auto-send.
|
||||||
|
chatId?: string;
|
||||||
|
chatLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand }: Props) {
|
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, chatId, chatLabel }: Props) {
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -107,6 +115,35 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// v1.10.4: register this input in the chat-input registry so the terminal
|
||||||
|
// pane's "Send to chat" menu can list it. Re-registers when chatLabel
|
||||||
|
// changes (e.g. rename) so the menu reflects the current name.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chatId) return;
|
||||||
|
return chatInputsRegistry.register(chatId, chatLabel ?? 'Chat', () => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
});
|
||||||
|
}, [chatId, chatLabel]);
|
||||||
|
|
||||||
|
// v1.10.4: subscribe to send_to_chat events scoped by chatId. Appends the
|
||||||
|
// payload text to the current draft (with a newline separator if the
|
||||||
|
// draft is non-empty) and focuses the textarea. Does NOT auto-submit.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chatId) return;
|
||||||
|
return sendToChat.subscribe(({ chat_id, text }) => {
|
||||||
|
if (chat_id !== chatId) return;
|
||||||
|
setValue((prev) => (prev.length === 0 ? text : `${prev}\n${text}`));
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (!ta) return;
|
||||||
|
ta.focus();
|
||||||
|
// Put caret at end so the user can keep typing immediately.
|
||||||
|
const end = ta.value.length;
|
||||||
|
ta.selectionStart = ta.selectionEnd = end;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [chatId]);
|
||||||
|
|
||||||
function removeAttachment(id: string) {
|
function removeAttachment(id: string) {
|
||||||
setAttachments(prev => prev.filter(a => a.id !== id));
|
setAttachments(prev => prev.filter(a => a.id !== id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -31,6 +31,15 @@ interface Props {
|
|||||||
onRenameChat: (chatId: string, name: string) => Promise<void>;
|
onRenameChat: (chatId: string, name: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.10.4: swipe-left-to-close on the pane pill. Threshold matches the spec
|
||||||
|
// (80px). Vertical bail-out at 30px because the pill sits inside a vertical
|
||||||
|
// scrollable header — diagonal-ish swipes shouldn't accidentally close panes.
|
||||||
|
const SWIPE_CLOSE_PX = 80;
|
||||||
|
const SWIPE_VERTICAL_BAIL_PX = 30;
|
||||||
|
// Visual cap: pill translates left up to this much. Past this, dragX stays
|
||||||
|
// pinned so the user has a clear "release to close" indicator.
|
||||||
|
const SWIPE_VISUAL_CAP = 120;
|
||||||
|
|
||||||
function paneIcon(kind: WorkspacePane['kind']) {
|
function paneIcon(kind: WorkspacePane['kind']) {
|
||||||
if (kind === 'terminal') return <Terminal size={14} />;
|
if (kind === 'terminal') return <Terminal size={14} />;
|
||||||
if (kind === 'agent') return <Bot size={14} />;
|
if (kind === 'agent') return <Bot size={14} />;
|
||||||
@@ -70,11 +79,66 @@ export function MobileTabSwitcher({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
|
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
// v1.10.4: swipe-left state. dragX is the (clamped, negative) drag offset
|
||||||
|
// in px. suppressClick latches when a swipe completes so the trailing click
|
||||||
|
// doesn't pop open the BottomSheet on the just-closed pane.
|
||||||
|
const [dragX, setDragX] = useState(0);
|
||||||
|
const swipeStart = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const swipeBailed = useRef(false);
|
||||||
|
const suppressClick = useRef(false);
|
||||||
|
|
||||||
const active = panes[activePaneIdx];
|
const active = panes[activePaneIdx];
|
||||||
const activeLabel = active ? paneLabel(active, chats) : 'Empty';
|
const activeLabel = active ? paneLabel(active, chats) : 'Empty';
|
||||||
const activeChatId = paneActiveChatId(active);
|
const activeChatId = paneActiveChatId(active);
|
||||||
|
|
||||||
|
function onPillTouchStart(e: React.TouchEvent<HTMLDivElement>): void {
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
const t = e.touches[0]!;
|
||||||
|
swipeStart.current = { x: t.clientX, y: t.clientY };
|
||||||
|
swipeBailed.current = false;
|
||||||
|
setDragX(0);
|
||||||
|
}
|
||||||
|
function onPillTouchMove(e: React.TouchEvent<HTMLDivElement>): void {
|
||||||
|
if (!swipeStart.current || swipeBailed.current) return;
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
const t = e.touches[0]!;
|
||||||
|
const dx = t.clientX - swipeStart.current.x;
|
||||||
|
const dy = t.clientY - swipeStart.current.y;
|
||||||
|
// Bail to scroll if vertical motion dominates before horizontal.
|
||||||
|
if (Math.abs(dy) > SWIPE_VERTICAL_BAIL_PX && Math.abs(dy) > Math.abs(dx)) {
|
||||||
|
swipeBailed.current = true;
|
||||||
|
setDragX(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only allow leftward drag (negative). Cap visual displacement.
|
||||||
|
const clamped = Math.max(-SWIPE_VISUAL_CAP, Math.min(0, dx));
|
||||||
|
setDragX(clamped);
|
||||||
|
}
|
||||||
|
function onPillTouchEnd(): void {
|
||||||
|
const finalDx = dragX;
|
||||||
|
swipeStart.current = null;
|
||||||
|
if (swipeBailed.current) {
|
||||||
|
setDragX(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (finalDx <= -SWIPE_CLOSE_PX && panes.length > 1) {
|
||||||
|
suppressClick.current = true;
|
||||||
|
// Reset dragX after the close so subsequent re-renders look right.
|
||||||
|
setDragX(0);
|
||||||
|
onRemovePane(activePaneIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDragX(0);
|
||||||
|
}
|
||||||
|
function onPillClick(): void {
|
||||||
|
if (suppressClick.current) {
|
||||||
|
suppressClick.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
const swipeProgress = Math.min(1, Math.abs(dragX) / SWIPE_CLOSE_PX);
|
||||||
|
|
||||||
// Long-press mirrors ChatTabBar: synthesize a contextmenu event on the row
|
// Long-press mirrors ChatTabBar: synthesize a contextmenu event on the row
|
||||||
// so the trailing kebab's Radix DropdownMenu opens at the touch point.
|
// so the trailing kebab's Radix DropdownMenu opens at the touch point.
|
||||||
const longPress = useLongPress(({ clientX, clientY, target }) => {
|
const longPress = useLongPress(({ clientX, clientY, target }) => {
|
||||||
@@ -113,17 +177,39 @@ export function MobileTabSwitcher({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div
|
||||||
|
className="flex-1 relative min-w-0"
|
||||||
|
onTouchStart={onPillTouchStart}
|
||||||
|
onTouchMove={onPillTouchMove}
|
||||||
|
onTouchEnd={onPillTouchEnd}
|
||||||
|
onTouchCancel={onPillTouchEnd}
|
||||||
|
>
|
||||||
|
{/* v1.10.4: red "Close" hint behind the pill. Opacity tracks the
|
||||||
|
swipe progress (0 at rest, 1 at the close threshold). aria-hidden
|
||||||
|
because the actionable affordance is the swipe, not this label. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 flex items-center justify-end pr-4 rounded-full bg-destructive/80 text-destructive-foreground text-xs font-medium"
|
||||||
|
style={{ opacity: swipeProgress, pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(true)}
|
onClick={onPillClick}
|
||||||
className="flex-1 inline-flex items-center gap-1.5 min-h-[44px] px-3 text-sm rounded-full bg-muted/40 hover:bg-muted/70 text-foreground min-w-0"
|
className="flex-1 w-full inline-flex items-center gap-1.5 min-h-[44px] px-3 text-sm rounded-full bg-muted/40 hover:bg-muted/70 text-foreground min-w-0 relative"
|
||||||
aria-label="Switch pane"
|
aria-label="Switch pane"
|
||||||
|
style={{
|
||||||
|
transform: `translateX(${dragX}px)`,
|
||||||
|
transition: dragX === 0 ? 'transform 180ms ease-out' : 'none',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="shrink-0 text-muted-foreground">{paneIcon(active?.kind ?? 'chat')}</span>
|
<span className="shrink-0 text-muted-foreground">{paneIcon(active?.kind ?? 'chat')}</span>
|
||||||
<StatusDot chatId={activeChatId} />
|
<StatusDot chatId={activeChatId} />
|
||||||
<span className="truncate flex-1 text-left">{activeLabel}</span>
|
<span className="truncate flex-1 text-left">{activeLabel}</span>
|
||||||
<ChevronDown size={14} className="opacity-60 shrink-0" />
|
<ChevronDown size={14} className="opacity-60 shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<BottomSheet open={open} onClose={() => setOpen(false)} title="Panes">
|
<BottomSheet open={open} onClose={() => setOpen(false)} title="Panes">
|
||||||
<ul className="px-2 py-2 space-y-1">
|
<ul className="px-2 py-2 space-y-1">
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { PanelRight, MessageSquare, Terminal, Bot, X } from 'lucide-react';
|
import { PanelRight, MessageSquare, Terminal, Bot, Clipboard, X } from 'lucide-react';
|
||||||
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
||||||
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||||
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
import { terminalsRegistry } from '@/lib/events';
|
||||||
import { ChatPane } from '@/components/panes/ChatPane';
|
import { ChatPane } from '@/components/panes/ChatPane';
|
||||||
import { SettingsPane } from '@/components/panes/SettingsPane';
|
import { SettingsPane } from '@/components/panes/SettingsPane';
|
||||||
import { TerminalPane } from '@/components/panes/TerminalPane';
|
import { TerminalPane } from '@/components/panes/TerminalPane';
|
||||||
@@ -238,6 +239,23 @@ export function Workspace({
|
|||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{terminalLabels.get(pane.id) ?? 'Terminal'}
|
{terminalLabels.get(pane.id) ?? 'Terminal'}
|
||||||
</span>
|
</span>
|
||||||
|
{/* v1.10.4: iOS Safari restricts navigator.clipboard.readText
|
||||||
|
outside direct user gestures. A real button click IS a
|
||||||
|
gesture, so this works where keystroke-driven paste may
|
||||||
|
not on iOS. The action lives in TerminalPane behind the
|
||||||
|
registry's paste() callback. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
terminalsRegistry.get(pane.id)?.paste();
|
||||||
|
}}
|
||||||
|
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
||||||
|
aria-label="Paste from clipboard"
|
||||||
|
title="Paste from clipboard"
|
||||||
|
>
|
||||||
|
<Clipboard size={12} />
|
||||||
|
</button>
|
||||||
{panes.length > 1 && (
|
{panes.length > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -245,7 +263,7 @@ export function Workspace({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removePane(idx);
|
removePane(idx);
|
||||||
}}
|
}}
|
||||||
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
||||||
aria-label="Close terminal pane"
|
aria-label="Close terminal pane"
|
||||||
title="Close terminal pane"
|
title="Close terminal pane"
|
||||||
>
|
>
|
||||||
@@ -271,6 +289,7 @@ export function Workspace({
|
|||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
paneId={pane.id}
|
paneId={pane.id}
|
||||||
label={terminalLabels.get(pane.id) ?? 'Terminal'}
|
label={terminalLabels.get(pane.id) ?? 'Terminal'}
|
||||||
|
active={idx === activePaneIdx}
|
||||||
/>
|
/>
|
||||||
) : pane.kind === 'chat' && pane.chatId ? (
|
) : pane.kind === 'chat' && pane.chatId ? (
|
||||||
<ChatPane
|
<ChatPane
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onForceSend={streaming ? handleForceSend : undefined}
|
onForceSend={streaming ? handleForceSend : undefined}
|
||||||
onSlashCommand={handleSlashCommand}
|
onSlashCommand={handleSlashCommand}
|
||||||
|
chatId={chatId}
|
||||||
|
chatLabel={sessionChats?.find((c) => c.id === chatId)?.name ?? 'Chat'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||||
|
|
||||||
@@ -11,8 +12,11 @@ function generateId(): string {
|
|||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyPane(): WorkspacePane {
|
// v1.10.3: optional id arg lets addSplitPane lift id generation out of the
|
||||||
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
|
// setPanes updater so the new pane's id can be returned synchronously to the
|
||||||
|
// caller (needed for mobile URL state).
|
||||||
|
function emptyPane(id: string = generateId()): WorkspacePane {
|
||||||
|
return { id, kind: 'empty', chatIds: [], activeChatIdx: -1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatPane(chatId: string): WorkspacePane {
|
function chatPane(chatId: string): WorkspacePane {
|
||||||
@@ -23,8 +27,8 @@ function chatPane(chatId: string): WorkspacePane {
|
|||||||
// tmux window key on booterm — see apps/booterm/src/pty/manager.ts. They
|
// tmux window key on booterm — see apps/booterm/src/pty/manager.ts. They
|
||||||
// persist in localStorage along with chat panes so a refresh resumes the
|
// persist in localStorage along with chat panes so a refresh resumes the
|
||||||
// same tmux window via the idempotent start endpoint.
|
// same tmux window via the idempotent start endpoint.
|
||||||
function terminalPane(): WorkspacePane {
|
function terminalPane(id: string = generateId()): WorkspacePane {
|
||||||
return { id: generateId(), kind: 'terminal', chatIds: [], activeChatIdx: -1 };
|
return { id, kind: 'terminal', chatIds: [], activeChatIdx: -1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
||||||
@@ -80,7 +84,11 @@ export interface UseWorkspacePanesResult {
|
|||||||
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
|
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
|
||||||
closeAllTabs: (paneIdx: number) => void;
|
closeAllTabs: (paneIdx: number) => void;
|
||||||
showLandingPage: (paneIdx: number) => void;
|
showLandingPage: (paneIdx: number) => void;
|
||||||
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void;
|
// v1.10.3: returns the new pane's id (or null if the operation was a no-op:
|
||||||
|
// 'agent' kind is a toast stub, or max panes reached). Callers can use the
|
||||||
|
// id to update mobile URL state so the URL-sync effect doesn't fight the
|
||||||
|
// freshly-set activePaneIdx.
|
||||||
|
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => string | null;
|
||||||
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
||||||
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
||||||
// falls back to an empty pane to preserve the "always one pane" invariant.
|
// falls back to an empty pane to preserve the "always one pane" invariant.
|
||||||
@@ -241,22 +249,29 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
|
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent'): string | null => {
|
||||||
if (kind === 'agent') {
|
if (kind === 'agent') {
|
||||||
toast('Agent panes coming in BooCoder');
|
toast('Agent panes coming in BooCoder');
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
// Generate the id outside the updater so we can return it deterministically.
|
||||||
|
// setPanes's updater can be invoked twice in strict mode; using a fixed id
|
||||||
|
// ensures both invocations agree and the returned id matches what landed.
|
||||||
|
const newPaneId = generateId();
|
||||||
|
let success = false;
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
// v1.9: settings panes are excluded from the MAX cap (decision c).
|
// v1.9: settings panes are excluded from the MAX cap (decision c).
|
||||||
if (nonSettingsCount(prev) >= MAX_PANES) {
|
if (nonSettingsCount(prev) >= MAX_PANES) {
|
||||||
toast.error(`Maximum ${MAX_PANES} panes`);
|
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const newPane = kind === 'terminal' ? terminalPane() : emptyPane();
|
const newPane = kind === 'terminal' ? terminalPane(newPaneId) : emptyPane(newPaneId);
|
||||||
const next = [...prev, newPane];
|
const next = [...prev, newPane];
|
||||||
setActivePaneIdx(next.length - 1);
|
setActivePaneIdx(next.length - 1);
|
||||||
|
success = true;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
return success ? newPaneId : null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleSettingsPane = useCallback(() => {
|
const toggleSettingsPane = useCallback(() => {
|
||||||
@@ -288,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.
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
//
|
//
|
||||||
// Also exposes a tiny registry of currently-mounted terminal panes so the
|
// Also exposes a tiny registry of currently-mounted terminal panes so the
|
||||||
// MessageBubble context menu can list them. TerminalPane registers on mount,
|
// MessageBubble context menu can list them. TerminalPane registers on mount,
|
||||||
// unregisters on unmount.
|
// unregisters on unmount. v1.10.4 adds a parallel ChatInput registry used by
|
||||||
|
// the terminal floating menu's "Send to chat" submenu.
|
||||||
|
|
||||||
type Listener<T> = (payload: T) => void;
|
type Listener<T> = (payload: T) => void;
|
||||||
|
|
||||||
@@ -41,9 +42,25 @@ export interface SendToTerminalPayload {
|
|||||||
|
|
||||||
export const sendToTerminal = createEvent<SendToTerminalPayload>();
|
export const sendToTerminal = createEvent<SendToTerminalPayload>();
|
||||||
|
|
||||||
|
// v1.10.4: reverse direction. Terminal floating menu "Send to chat" emits this
|
||||||
|
// with the target chat's chat_id; ChatInput subscribes and appends to its draft.
|
||||||
|
export interface SendToChatPayload {
|
||||||
|
chat_id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendToChat = createEvent<SendToChatPayload>();
|
||||||
|
|
||||||
export interface TerminalRegistration {
|
export interface TerminalRegistration {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
// v1.10.3 kbd-shortcuts: Cmd+` needs to focus the active terminal's xterm
|
||||||
|
// input layer. TerminalPane binds this to term.focus().
|
||||||
|
focus: () => void;
|
||||||
|
// v1.10.4: Cmd+F opens the search bar over the active terminal. Workspace
|
||||||
|
// also binds a "Paste" button in the terminal pane header to paste().
|
||||||
|
openSearch: () => void;
|
||||||
|
paste: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const terminalRegistry = new Map<string, TerminalRegistration>();
|
const terminalRegistry = new Map<string, TerminalRegistration>();
|
||||||
@@ -60,8 +77,14 @@ function notifyRegistry(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const terminalsRegistry = {
|
export const terminalsRegistry = {
|
||||||
register(paneId: string, label: string): () => void {
|
register(
|
||||||
terminalRegistry.set(paneId, { paneId, label });
|
paneId: string,
|
||||||
|
label: string,
|
||||||
|
focus: () => void,
|
||||||
|
openSearch: () => void,
|
||||||
|
paste: () => void,
|
||||||
|
): () => void {
|
||||||
|
terminalRegistry.set(paneId, { paneId, label, focus, openSearch, paste });
|
||||||
notifyRegistry();
|
notifyRegistry();
|
||||||
return () => {
|
return () => {
|
||||||
terminalRegistry.delete(paneId);
|
terminalRegistry.delete(paneId);
|
||||||
@@ -71,6 +94,9 @@ export const terminalsRegistry = {
|
|||||||
list(): TerminalRegistration[] {
|
list(): TerminalRegistration[] {
|
||||||
return Array.from(terminalRegistry.values());
|
return Array.from(terminalRegistry.values());
|
||||||
},
|
},
|
||||||
|
get(paneId: string): TerminalRegistration | undefined {
|
||||||
|
return terminalRegistry.get(paneId);
|
||||||
|
},
|
||||||
subscribe(listener: Listener<void>): () => void {
|
subscribe(listener: Listener<void>): () => void {
|
||||||
registryListeners.add(listener);
|
registryListeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -78,3 +104,48 @@ export const terminalsRegistry = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v1.10.4: parallel registry of mounted ChatInput components so the terminal
|
||||||
|
// floating menu's "Send to chat" submenu can list open chats. Mirrors
|
||||||
|
// terminalsRegistry exactly; same subscriber pattern.
|
||||||
|
export interface ChatInputRegistration {
|
||||||
|
chatId: string;
|
||||||
|
label: string;
|
||||||
|
focus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatInputRegistry = new Map<string, ChatInputRegistration>();
|
||||||
|
const chatInputListeners = new Set<Listener<void>>();
|
||||||
|
|
||||||
|
function notifyChatInputs(): void {
|
||||||
|
for (const l of chatInputListeners) {
|
||||||
|
try {
|
||||||
|
l();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatInputsRegistry = {
|
||||||
|
register(chatId: string, label: string, focus: () => void): () => void {
|
||||||
|
chatInputRegistry.set(chatId, { chatId, label, focus });
|
||||||
|
notifyChatInputs();
|
||||||
|
return () => {
|
||||||
|
chatInputRegistry.delete(chatId);
|
||||||
|
notifyChatInputs();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
list(): ChatInputRegistration[] {
|
||||||
|
return Array.from(chatInputRegistry.values());
|
||||||
|
},
|
||||||
|
get(chatId: string): ChatInputRegistration | undefined {
|
||||||
|
return chatInputRegistry.get(chatId);
|
||||||
|
},
|
||||||
|
subscribe(listener: Listener<void>): () => void {
|
||||||
|
chatInputListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
chatInputListeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ChevronRight, FolderTree, Menu } from 'lucide-react';
|
|||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Project, Session as SessionType } from '@/api/types';
|
import type { Project, Session as SessionType } from '@/api/types';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { terminalsRegistry } from '@/lib/events';
|
||||||
import { useActivePane } from '@/hooks/useActivePane';
|
import { useActivePane } from '@/hooks/useActivePane';
|
||||||
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||||
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||||
@@ -170,6 +171,122 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
[setActivePaneIdx, isMobile, panes, navigate, location.pathname, location.search],
|
[setActivePaneIdx, isMobile, panes, navigate, location.pathname, location.search],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// v1.10.3 fix: addSplitPane sets activePaneIdx, but on mobile the URL-sync
|
||||||
|
// effect below sees a stale ?pane= and immediately resets the index. Push
|
||||||
|
// the new pane's id to the URL atomically so the effect's next pass sees a
|
||||||
|
// matching id and is a no-op. Desktop has no URL pane state — fall through.
|
||||||
|
const addPaneAndSwitch = useCallback(
|
||||||
|
(kind: 'chat' | 'terminal' | 'agent') => {
|
||||||
|
const newPaneId = addSplitPane(kind);
|
||||||
|
if (newPaneId === null) return;
|
||||||
|
if (isMobile) {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
params.set('pane', newPaneId);
|
||||||
|
navigate(`${location.pathname}?${params.toString()}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addSplitPane, isMobile, navigate, location.pathname, location.search],
|
||||||
|
);
|
||||||
|
|
||||||
|
// v1.10.3 keyboard shortcuts. Window-level keydown so they fire from
|
||||||
|
// anywhere in the session view. Only Cmd/Ctrl-Shift-C defers to the xterm
|
||||||
|
// (which has its own copy binding for that combo); everything else fires
|
||||||
|
// regardless of focus. Cmd-W and Cmd-T are typically reserved by the
|
||||||
|
// browser — preventDefault() works in most browsers but not all.
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
if (!mod) return;
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
const target = e.target;
|
||||||
|
const inXterm = target instanceof Element && target.closest('.xterm') !== null;
|
||||||
|
|
||||||
|
// Cmd/Ctrl + ` — focus the active terminal or jump to the most recent
|
||||||
|
// terminal pane and focus it. No-op if there are no terminal panes.
|
||||||
|
if (key === '`') {
|
||||||
|
e.preventDefault();
|
||||||
|
const activePane = panes[activePaneIdx];
|
||||||
|
if (activePane?.kind === 'terminal') {
|
||||||
|
terminalsRegistry.get(activePane.id)?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let lastTermIdx = -1;
|
||||||
|
for (let i = panes.length - 1; i >= 0; i--) {
|
||||||
|
if (panes[i]?.kind === 'terminal') {
|
||||||
|
lastTermIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastTermIdx < 0) return;
|
||||||
|
const target = panes[lastTermIdx];
|
||||||
|
switchActivePane(lastTermIdx);
|
||||||
|
if (target) {
|
||||||
|
// The terminal may have just mounted on mobile (it was return-null
|
||||||
|
// before the switch). Defer focus until the new render commits.
|
||||||
|
setTimeout(() => terminalsRegistry.get(target.id)?.focus(), 80);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd/Ctrl + Shift + T — new terminal pane and switch to it.
|
||||||
|
if (key === 't' && e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
addPaneAndSwitch('terminal');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd/Ctrl + Shift + C — new chat pane and switch to it. The xterm's
|
||||||
|
// own Shift-C binding is "copy selection" — defer to it when in xterm.
|
||||||
|
if (key === 'c' && e.shiftKey) {
|
||||||
|
if (inXterm) return;
|
||||||
|
e.preventDefault();
|
||||||
|
addPaneAndSwitch('chat');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd/Ctrl + W — close the active pane.
|
||||||
|
if (key === 'w' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
removePane(activePaneIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.10.4: Cmd/Ctrl + F — when the active pane is a terminal, open the
|
||||||
|
// scrollback search bar. When it isn't, fall through to the browser's
|
||||||
|
// native find (no preventDefault, no early return).
|
||||||
|
if (key === 'f' && !e.shiftKey) {
|
||||||
|
const activePane = panes[activePaneIdx];
|
||||||
|
if (activePane?.kind === 'terminal') {
|
||||||
|
e.preventDefault();
|
||||||
|
terminalsRegistry.get(activePane.id)?.openSearch();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd/Ctrl + Tab / Shift+Tab — cycle through panes.
|
||||||
|
if (key === 'tab') {
|
||||||
|
if (panes.length <= 1) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const dir = e.shiftKey ? -1 : 1;
|
||||||
|
const next = (activePaneIdx + dir + panes.length) % panes.length;
|
||||||
|
switchActivePane(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd/Ctrl + 1..9 — direct jump to pane N.
|
||||||
|
if (/^[1-9]$/.test(key)) {
|
||||||
|
const idx = parseInt(key, 10) - 1;
|
||||||
|
if (idx < panes.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
switchActivePane(idx);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [panes, activePaneIdx, switchActivePane, addPaneAndSwitch, removePane]);
|
||||||
|
|
||||||
async function saveName() {
|
async function saveName() {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
@@ -264,7 +381,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
onRenameChat={renameChat}
|
onRenameChat={renameChat}
|
||||||
/>
|
/>
|
||||||
<NewPaneMenu
|
<NewPaneMenu
|
||||||
onAddPane={addSplitPane}
|
onAddPane={addPaneAndSwitch}
|
||||||
disabled={panes.length >= MAX_PANES}
|
disabled={panes.length >= MAX_PANES}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
87
pnpm-lock.yaml
generated
87
pnpm-lock.yaml
generated
@@ -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,15 +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-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
|
||||||
@@ -1840,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==}
|
||||||
|
|
||||||
@@ -3903,22 +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-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'}
|
||||||
@@ -5592,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:
|
||||||
@@ -7963,16 +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-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: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user