Compare commits

..

14 Commits

Author SHA1 Message Date
8cea4a899c v1.10.5: inference XML tool-call fallback parser
Some local models (qwen3-coder via llama-swap) emit tool calls as inline XML
inside delta.content rather than structured delta.tool_calls. streamCompletion
now buffers delta.content, extracts complete <tool_call>...</tool_call> blocks
via parseXmlToolCall, and pushes synthetic entries (id prefix xml_call_) into
the existing toolCallsBuffer. Native JSON path unchanged — both coexist.
Partial openers are held back so a tool tag never leaks to the chat mid-tag.
Unclosed XML at end-of-stream is flushed as plain content (no silent drops).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:32:42 +00:00
3fceea064a booterm: fitFull() bypasses FitAddon scrollbar subtraction; push initial PTY size
FitAddon's proposeDimensions() always subtracts a phantom scrollbar width even
when CSS hides the scrollbar — losing one column of usable width. fitFull()
divides host clientWidth/clientHeight by the renderer's reported cell size
directly. Also POSTs the resized cols/rows back to /api/term/.../resize on
initial mount and after fonts.ready so bash/opencode get the correct PTY
size before the user types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:32:42 +00:00
fccab20920 merge v1.10.4-booterm-mobile 2026-05-19 17:16:50 +00:00
ea9d261f0f v1.10.4: booterm mobile UX — copy/paste, swipe-close, send-to-chat, search
- Long-press selection + floating menu (mobile + desktop right-click): Copy,
  Paste, Select All, Search, Send to chat. Tap-outside / Esc dismiss.
- Pane-header Paste button (📋) for iOS user-gesture clipboard read.
- Swipe-left-to-close on mobile pane pill with red "Close" overlay and
  translateX visual hint; spring-back below 80px threshold.
- Send-to-chat reverse path: chatInputsRegistry + sendToChat event mirror
  the existing terminalsRegistry pattern. ChatInput appends with newline
  separator on receive and focuses (no auto-send).
- Scrollback search via xterm-addon-search@^0.13.0: SearchBar overlay with
  N-of-M match counter (onDidChangeResults), Enter/Shift-Enter cycling.
- Cmd/Ctrl+F intercept in Session.tsx when active pane is terminal; xterm
  also intercepts when focused. Browser native find passes through elsewhere.
- terminalsRegistry signature extended with openSearch + paste callbacks.

Includes deferred CLAUDE.md updates documenting v1.10/v1.10.1/v1.10.2/v1.10.3
learnings (uid 1000 collision, libc match, two event buses, vite proxy order,
mobile pane URL sync, xterm canvas selection).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:16:47 +00:00
4d466c5710 merge v1.10.3-booterm-ux 2026-05-19 13:52:50 +00:00
875db86e31 v1.10.3: booterm mobile/UX fixes + global keyboard shortcuts
Five issues + keyboard shortcuts across booterm and the workspace shell.

Auto-switch on create (mobile): addSplitPane now returns the new pane id;
Session.tsx wraps it with addPaneAndSwitch which pushes ?pane=<newId> on
mobile so the URL-sync effect doesn't fight the just-set activePaneIdx.
NewPaneMenu uses the wrapper; desktop Split dropdown is unaffected.

Tab-away reconnect: TerminalPane has a connect()/manualReconnect() state
machine. ws.onclose backs off 500ms/1s/2s × 3 attempts, then surfaces a
[Disconnected] banner with a Reconnect button. visibilitychange listener
calls manualReconnect when the tab returns and the WS isn't OPEN. tmux
session persists server-side so scrollback is intact on resume.

Copy/paste: attachCustomKeyEventHandler binds Cmd/Ctrl-C (copy if
selection, else send ^C), Cmd/Ctrl-Shift-C (always swallow — copy if any,
no-op otherwise — never sends ^C), Cmd/Ctrl-V and Cmd/Ctrl-Shift-V
(navigator.clipboard.readText → ws.send). No custom right-click menu —
browser's native menu is preserved.

Scroll: removed `set -g mouse on` from tmux.conf so xterm.js sees wheel
and touch events natively. scrollback: 10_000, fastScrollModifier: 'shift',
altClickMovesCursor: false. Container has touch-action: pan-y for mobile.

Right-edge gap: inline <style> overrides xterm's defaults to width:100%
height:100% and hides the scrollbar chrome. Host container is
flex-1 min-w-0 self-stretch w-full. Three refit triggers: ResizeObserver
(rAF-wrapped), document.fonts.ready, and useEffect on the new active prop.
Background color matched between outer div, inner div, and xterm theme.

Keyboard shortcuts in Session.tsx (window-level keydown):
  Cmd/Ctrl+`              focus active terminal, else jump to last
  Cmd/Ctrl+Shift+T        new terminal pane
  Cmd/Ctrl+Shift+C        new chat pane (defers to xterm copy if focused)
  Cmd/Ctrl+W              close active pane
  Cmd/Ctrl+Tab/Shift+Tab  cycle next / prev pane
  Cmd/Ctrl+1..9           jump to pane N
terminalsRegistry gains a focus() callback per registration so Cmd+`
can call term.focus() on the active terminal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:52:44 +00:00
8eaf9591dc merge v1.10.2-booterm-glibc 2026-05-19 13:14:25 +00:00
5d52b79a07 v1.10.2: booterm runtime on bookworm-slim (glibc), su-exec → gosu
Switched the booterm runtime + proddeps stages from node:20-alpine (musl)
to node:20-bookworm-slim (glibc) so host-installed glibc binaries (Claude
Code, opencode, nvm node) run inside the container when invoked from the
terminal pane. node-pty's native .node has to be compiled in the same
libc env as the runtime, so both stages flip together; the TypeScript-only
builder stage stays on alpine.

su-exec is alpine-only; Debian replacement is gosu — swapped in both the
runtime apt install and the tmux default-command. uid/gid 1000 collision
with the bookworm `node` user handled via userdel/groupdel before
groupadd/useradd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:14:21 +00:00
ead7cb9d01 merge v1.10.1-booterm-user 2026-05-19 13:07:59 +00:00
d04b30687f v1.10.1: booterm runs shells as samkintop with login bash 2026-05-19 13:07:59 +00:00
9250632ac3 merge v1.10-booterm 2026-05-18 14:06:46 +00:00
7486e7d3e0 v1.10: booterm container — xterm.js + tmux + node-pty 2026-05-18 14:06:46 +00:00
d85b17081e v1.9.7: ask_user_input elicitation tool 2026-05-18 02:15:18 +00:00
adb5d7b3bb Merge v1.9-skills: skills + /skill slash command 2026-05-18 01:52:15 +00:00
38 changed files with 3623 additions and 54 deletions

View File

@@ -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).
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
```bash
@@ -35,7 +37,7 @@ Tests: `pnpm -C apps/server test` runs 23 vitest tests. No test harness on `apps
## 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/`)
@@ -99,6 +101,10 @@ 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.
- 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).
- `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.
## Conventions
@@ -109,3 +115,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`).
- 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.
- 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.

64
apps/booterm/Dockerfile Normal file
View File

@@ -0,0 +1,64 @@
# syntax=docker/dockerfile:1.7
# ---- Build stage: compile TypeScript ----
FROM node:20-alpine AS builder
ENV COREPACK_DEFAULT_TO_LATEST=0
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
RUN apk add --no-cache python3 make g++
WORKDIR /build
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
COPY apps/server/package.json ./apps/server/
COPY apps/web/package.json ./apps/web/
COPY apps/booterm/package.json ./apps/booterm/
RUN pnpm install --frozen-lockfile
COPY apps/booterm ./apps/booterm
RUN pnpm --filter=@boocode/booterm build
# ---- Prod-deps stage: hoisted, native built via npm rebuild ----
# 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
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /prod
COPY apps/booterm/package.json ./package.json
RUN pnpm install --prod --config.node-linker=hoisted --config.strict-peer-dependencies=false
# pnpm 10 ignores build scripts; force compile with npm directly.
# node-gyp is bundled with npm in the node:20-bookworm-slim image.
RUN cd node_modules/node-pty && npm run install
# 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)
# ---- Runtime ----
# v1.10.2: switched from node:20-alpine (musl) to node:20-bookworm-slim (glibc)
# so glibc-linked binaries from /home/samkintop (Claude Code, opencode, the
# 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
RUN apt-get update && apt-get install -y --no-install-recommends \
tmux bash gosu ca-certificates procps \
&& rm -rf /var/lib/apt/lists/*
# 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.
# bookworm-slim ships a `node` user at 1000 — wipe whatever sits on uid/gid
# 1000 first, then create samkintop fresh.
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
COPY --from=builder /build/apps/booterm/dist ./dist
COPY --from=proddeps /prod/package.json ./package.json
COPY --from=proddeps /prod/node_modules ./node_modules
COPY apps/booterm/tmux.conf /etc/booterm/tmux.conf
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/index.js"]

27
apps/booterm/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@boocode/booterm",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/websocket": "^10.0.1",
"fastify": "^4.28.1",
"node-pty": "^1.0.0",
"pg": "^8.13.0",
"tslib": "^2.6.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.14.10",
"@types/pg": "^8.11.10",
"tsx": "^4.16.2",
"typescript": "^5.5.0"
}
}

11
apps/booterm/src/auth.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { FastifyRequest } from 'fastify';
// Mirrors the boocode pattern: there is no app-layer auth — Authelia handles
// it at the reverse proxy (CLAUDE.md). All broker.publishUser calls use
// 'default' as the user key. We accept Remote-User when present (set by the
// proxy in prod) and fall back to 'default' on direct Tailscale access.
export function getUser(req: FastifyRequest): string {
const header = req.headers['remote-user'];
if (typeof header === 'string' && header.length > 0) return header;
return 'default';
}

View File

@@ -0,0 +1,26 @@
import { z } from 'zod';
const ConfigSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
HOST: z.string().default('0.0.0.0'),
DATABASE_URL: z.string().url(),
LOG_LEVEL: z.string().default('info'),
TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'),
});
export type Config = z.infer<typeof ConfigSchema>;
let cached: Config | null = null;
export function loadConfig(): Config {
if (cached) return cached;
const parsed = ConfigSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration:');
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
cached = parsed.data;
return cached;
}

46
apps/booterm/src/db.ts Normal file
View File

@@ -0,0 +1,46 @@
import pg from 'pg';
const { Pool } = pg;
let pool: pg.Pool | null = null;
export function getPool(databaseUrl: string): pg.Pool {
if (pool) return pool;
pool = new Pool({ connectionString: databaseUrl, max: 5, idleTimeoutMillis: 30_000 });
return pool;
}
export interface SessionInfo {
id: string;
project_id: string;
project_path: string;
}
export async function getSessionInfo(sessionId: string): Promise<SessionInfo | null> {
if (!pool) throw new Error('db pool not initialized');
const res = await pool.query<SessionInfo>(
`SELECT s.id, s.project_id, p.path AS project_path
FROM sessions s
JOIN projects p ON p.id = s.project_id
WHERE s.id = $1`,
[sessionId],
);
return res.rows[0] ?? null;
}
export async function pingDb(): Promise<boolean> {
if (!pool) return false;
try {
await pool.query('SELECT 1');
return true;
} catch {
return false;
}
}
export async function closeDb(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
}
}

60
apps/booterm/src/index.ts Normal file
View File

@@ -0,0 +1,60 @@
import Fastify from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import { loadConfig } from './config.js';
import { getPool, closeDb } from './db.js';
import { registerHealthRoutes } from './routes/health.js';
import { registerTerminalRoutes } from './routes/terminals.js';
import { registerWsAttachRoute } from './ws/attach.js';
async function main(): Promise<void> {
const config = loadConfig();
const app = Fastify({
logger: { level: config.LOG_LEVEL },
});
app.removeContentTypeParser(['application/json']);
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
const str = (body as string) ?? '';
if (str.trim().length === 0) {
done(null, {});
return;
}
try {
done(null, JSON.parse(str));
} catch (err) {
done(err as Error, undefined);
}
});
getPool(config.DATABASE_URL);
await app.register(fastifyWebsocket);
registerHealthRoutes(app);
registerTerminalRoutes(app, config.TMUX_CONF_PATH);
registerWsAttachRoute(app, config.TMUX_CONF_PATH);
const shutdown = async (signal: string) => {
app.log.info(`received ${signal}, shutting down`);
try {
await app.close();
await closeDb();
process.exit(0);
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
await app.listen({ port: config.PORT, host: config.HOST });
app.log.info(`booterm listening on http://${config.HOST}:${config.PORT}`);
}
main().catch((err) => {
console.error('Fatal startup error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,102 @@
import { spawn } from 'node:child_process';
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}$/;
export function sanitizeId(raw: string): string | null {
if (!ID_RE.test(raw)) return null;
return raw.toLowerCase();
}
export function tmuxSessionName(sessionId: string): string {
return `bc-${sessionId}`;
}
export function tmuxWindowName(paneId: string): string {
return `term-${paneId}`;
}
interface CmdResult {
stdout: string;
stderr: string;
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> {
return new Promise((resolve) => {
const child = spawn('tmux', ['-f', tmuxConfPath, ...args], { shell: false });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); });
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); });
child.on('error', (err) => {
resolve({ stdout, stderr: stderr + String(err), code: 1 });
});
child.on('close', (code) => {
resolve({ stdout, stderr, code: code ?? 0 });
});
});
}
export async function hasSession(tmuxConfPath: string, sessionName: string): Promise<boolean> {
const res = await runTmux(tmuxConfPath, ['has-session', '-t', `=${sessionName}`]);
return res.code === 0;
}
export async function listWindows(tmuxConfPath: string, sessionName: string): Promise<string[]> {
const res = await runTmux(tmuxConfPath, ['list-windows', '-t', sessionName, '-F', '#{window_name}']);
if (res.code !== 0) return [];
return res.stdout.trim().split('\n').filter(Boolean);
}
export async function killWindow(
tmuxConfPath: 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,
log: FastifyBaseLogger,
): Promise<void> {
if (!(await hasSession(tmuxConfPath, sessionName))) {
log.info({ sessionName, windowName, projectRoot }, 'creating tmux session');
const res = await runTmux(tmuxConfPath, [
'new-session', '-d',
'-s', sessionName,
'-n', windowName,
'-c', projectRoot,
]);
if (res.code !== 0) {
log.error({ res }, 'tmux new-session failed');
throw new Error(`tmux new-session failed: ${res.stderr}`);
}
return;
}
const windows = await listWindows(tmuxConfPath, sessionName);
if (windows.includes(windowName)) return;
const res = await runTmux(tmuxConfPath, [
'new-window',
'-t', sessionName,
'-n', windowName,
'-c', projectRoot,
]);
if (res.code !== 0) {
log.error({ res }, 'tmux new-window failed');
throw new Error(`tmux new-window failed: ${res.stderr}`);
}
}

View File

@@ -0,0 +1,41 @@
import * as pty from 'node-pty';
import type { IPty } from 'node-pty';
export interface AttachPtyOptions {
sessionName: string;
windowName: string;
projectRoot: string;
cols: number;
rows: number;
tmuxConfPath: string;
}
function cleanEnv(): { [key: string]: string } {
const out: { [key: string]: string } = {};
for (const [k, v] of Object.entries(process.env)) {
if (typeof v === 'string') out[k] = v;
}
out['TERM'] = 'screen-256color';
return out;
}
// Spawns a tmux client attached to the given session+window. `-d` detaches any
// other client so a browser refresh takes over the same window without
// duplicate input. tmux server (and the window) persists across PTY exits.
export function attachPty(opts: AttachPtyOptions): IPty {
return pty.spawn(
'tmux',
[
'-f', opts.tmuxConfPath,
'attach-session', '-d',
'-t', `${opts.sessionName}:${opts.windowName}`,
],
{
name: 'xterm-256color',
cols: opts.cols,
rows: opts.rows,
cwd: opts.projectRoot,
env: cleanEnv(),
},
);
}

View File

@@ -0,0 +1,9 @@
import type { FastifyInstance } from 'fastify';
import { pingDb } from '../db.js';
export function registerHealthRoutes(app: FastifyInstance): void {
app.get('/api/term/health', async () => {
const dbOk = await pingDb();
return { ok: true, db: dbOk };
});
}

View File

@@ -0,0 +1,88 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { getSessionInfo } from '../db.js';
import {
sanitizeId,
tmuxSessionName,
tmuxWindowName,
ensureWindow,
killWindow,
hasSession,
listWindows,
} from '../pty/manager.js';
import { resizePane } from '../ws/attach.js';
const ParamsSchema = z.object({ sid: z.string(), pid: z.string() });
const ResizeBodySchema = z.object({
cols: z.coerce.number().int().min(1).max(2000),
rows: z.coerce.number().int().min(1).max(2000),
});
export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: string): void {
app.post<{ Params: { sid: string; pid: string } }>(
'/api/term/sessions/:sid/panes/:pid/start',
async (req, reply) => {
const p = ParamsSchema.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
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 session = await getSessionInfo(sid);
if (!session) return reply.code(404).send({ error: 'unknown_session' });
const sessionName = tmuxSessionName(sid);
const windowName = tmuxWindowName(pid);
try {
await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log);
} catch (err) {
req.log.error({ err }, 'ensureWindow failed');
return reply.code(500).send({ error: 'tmux_failed' });
}
return reply.code(200).send({ tmux_window: windowName });
},
);
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 });
},
);
app.post<{ Params: { sid: string; pid: string } }>(
'/api/term/sessions/:sid/panes/:pid/kill',
async (req, reply) => {
const p = ParamsSchema.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
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 sessionName = tmuxSessionName(sid);
const windowName = tmuxWindowName(pid);
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' });
}
const killed = await killWindow(tmuxConfPath, sessionName, windowName);
if (!killed) return reply.code(500).send({ error: 'tmux_kill_failed' });
return reply.code(200).send({ ok: true });
},
);
}

View File

@@ -0,0 +1,128 @@
import type { FastifyInstance } from 'fastify';
import type { IPty } from 'node-pty';
import { getSessionInfo } from '../db.js';
import { sanitizeId, tmuxSessionName, tmuxWindowName, ensureWindow } from '../pty/manager.js';
import { attachPty } from '../pty/pty.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 {
app.get<{
Params: { sid: string; pid: string };
Querystring: { cols?: string; rows?: string };
}>(
'/ws/term/sessions/:sid/panes/:pid',
{ websocket: true },
async (socket, req) => {
const sid = sanitizeId(req.params.sid);
const pid = sanitizeId(req.params.pid);
if (!sid || !pid) {
socket.close(1008, 'bad_id_format');
return;
}
const user = getUser(req);
req.log.info({ user, sid, pid }, 'ws attach');
const session = await getSessionInfo(sid);
if (!session) {
socket.close(1008, 'unknown_session');
return;
}
const sessionName = tmuxSessionName(sid);
const windowName = tmuxWindowName(pid);
try {
await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log);
} catch (err) {
req.log.error({ err }, 'ensureWindow failed in WS handler');
socket.close(1011, 'tmux_failed');
return;
}
const cols = parseInt(req.query.cols ?? '', 10) || 80;
const rows = parseInt(req.query.rows ?? '', 10) || 24;
let handle: IPty;
try {
handle = attachPty({
sessionName,
windowName,
projectRoot: session.project_path,
cols,
rows,
tmuxConfPath,
});
} catch (err) {
req.log.error({ err }, 'attachPty failed');
socket.close(1011, 'pty_spawn_failed');
return;
}
active.set(pid, handle);
const onData = (data: string) => {
if (socket.readyState !== socket.OPEN) return;
try {
socket.send(Buffer.from(data, 'utf8'), { binary: true });
} catch (err) {
req.log.warn({ err }, 'ws send failed');
}
};
handle.onData(onData);
socket.on('message', (data: Buffer | string) => {
try {
if (typeof data === 'string') {
handle.write(data);
} else {
handle.write(data.toString('utf8'));
}
} catch (err) {
req.log.warn({ err }, 'pty write failed');
}
});
handle.onExit(({ exitCode }) => {
try {
if (socket.readyState === socket.OPEN) {
socket.send(JSON.stringify({ type: 'exit', code: exitCode }));
}
} catch {
/* ignore */
}
try {
socket.close(1000);
} catch {
/* ignore */
}
if (active.get(pid) === handle) active.delete(pid);
});
// WS close kills the local PTY (the tmux client). The tmux server and
// window persist so a refresh resumes with full scrollback.
socket.on('close', () => {
if (active.get(pid) === handle) active.delete(pid);
try {
handle.kill();
} catch {
/* ignore */
}
});
},
);
}

20
apps/booterm/tmux.conf Normal file
View File

@@ -0,0 +1,20 @@
set -g default-terminal "screen-256color"
set -g history-limit 50000
# 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.
setw -g mode-keys vi
set -g status off
set -g destroy-unattached off
# 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
# gosu (and su-exec before it) only changes uid/gid — it leaves env intact,
# and the tmux server runs as root so HOME would otherwise be /root. bash -l
# then sources samkintop's ~/.profile / ~/.bashrc to pick up PATH (nvm,
# ~/.local/bin, ~/.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"

View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022"],
"types": ["node"],
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["**/*.test.ts"]
}

View File

@@ -123,6 +123,9 @@ async function main() {
chat_id: chatId,
});
},
publishSessionFrame: (sessionId, frame) => {
broker.publish(sessionId, frame);
},
});
registerSkillsRoutes(app, sql, {
enqueueInference: (sessionId, chatId, assistantId, user) => {

View File

@@ -1,7 +1,7 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Chat, Message, Session } from '../types/api.js';
import type { Chat, Message, Session, ToolCall } from '../types/api.js';
const SendBody = z.object({
content: z.string().min(1).max(64_000),
@@ -14,6 +14,39 @@ const ContinueBody = z.object({
sentinel_message_id: z.string().uuid(),
});
// Batch 9.7: ask_user_input answer submission. Defensive shape — the question
// content is echoed back for traceability but the server does NOT trust it
// (the source of truth is the assistant message's tool_calls.args.questions).
const AnswerUserInputBody = z.object({
tool_call_id: z.string().min(1),
answers: z
.array(
z.object({
question: z.string(),
selected_options: z.array(z.string()),
free_text: z.string().nullable(),
}),
)
.min(1)
.max(3),
});
// Same shape the model declared via the tool's zod input. Re-derived here so
// the route can validate args without depending on services/tools.ts (which
// would pull in fs/path_guard for nothing).
const AskUserInputArgs = z.object({
questions: z
.array(
z.object({
question: z.string(),
type: z.enum(['single_select', 'multi_select']),
options: z.array(z.string()).min(1),
}),
)
.min(1)
.max(3),
});
interface MessageHandlers {
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
enqueueCompact: (sessionId: string, chatId: string, compactMessageId: string, user: string) => void;
@@ -24,6 +57,13 @@ interface MessageHandlers {
content: string
) => void;
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
// Batch 9.7: lets the answer endpoint emit the tool_result frame that the
// pause path intentionally skipped. Matches SkillInvokeHandlers in
// routes/skills.ts so index.ts can pass the same broker.publish adapter.
publishSessionFrame: (
sessionId: string,
frame: Record<string, unknown> & { type: string }
) => void;
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
hasActiveInference: (chatId: string) => boolean;
}
@@ -389,4 +429,169 @@ export function registerMessageRoutes(
return result;
}
);
// Batch 9.7: resume an ask_user_input pause. Validates the body matches the
// question shape the model declared, UPDATEs the pending tool row's
// tool_results to the AnswerSet, publishes the deferred tool_result frame,
// and enqueues the next assistant turn. Error codes per spec:
// 400 invalid_body / mismatched_answer_shape
// 404 chat_not_found / unknown_tool_call_id
// 409 tool_call_already_answered
app.post<{ Params: { id: string } }>(
'/api/chats/:id/answer_user_input',
async (req, reply) => {
const parsed = AnswerUserInputBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid_body', details: parsed.error.flatten() };
}
const { tool_call_id, answers } = parsed.data;
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat_not_found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
// Find the assistant message that emitted this tool_call. Scoped by
// chat_id + role to avoid cross-chat lookups; ordered by created_at DESC
// because the most recent issuance wins when an LLM reuses call IDs
// across turns (the older, already-answered one is a different row with
// populated tool_results downstream).
const callerRows = await sql<{ id: string; tool_calls: ToolCall[] | null }[]>`
SELECT id, tool_calls FROM messages
WHERE chat_id = ${chat.id}
AND role = 'assistant'
AND tool_calls IS NOT NULL
ORDER BY created_at DESC
`;
let foundCall: ToolCall | null = null;
for (const row of callerRows) {
const match = row.tool_calls?.find((tc) => tc.id === tool_call_id);
if (match) {
foundCall = match;
break;
}
}
if (!foundCall) {
reply.code(404);
return { error: 'unknown_tool_call_id' };
}
if (foundCall.name !== 'ask_user_input') {
reply.code(400);
return { error: 'tool_call_not_ask_user_input' };
}
// Validate the args themselves — the LLM could have emitted bad JSON.
const argsParsed = AskUserInputArgs.safeParse(foundCall.args);
if (!argsParsed.success) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
}
const questions = argsParsed.data.questions;
if (answers.length !== questions.length) {
reply.code(400);
return {
error: 'mismatched_answer_shape',
detail: `expected ${questions.length} answer(s), got ${answers.length}`,
};
}
for (let i = 0; i < questions.length; i++) {
const q = questions[i]!;
const a = answers[i]!;
for (const sel of a.selected_options) {
if (!q.options.includes(sel)) {
reply.code(400);
return {
error: 'mismatched_answer_shape',
detail: `answer ${i + 1} contains option not in question: ${sel}`,
};
}
}
if (q.type === 'single_select' && a.selected_options.length > 1) {
reply.code(400);
return {
error: 'mismatched_answer_shape',
detail: `answer ${i + 1} has multiple selections on single_select`,
};
}
const hasOpt = a.selected_options.length > 0;
const hasText = a.free_text !== null && a.free_text.trim().length > 0;
if (!hasOpt && !hasText) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} is empty` };
}
}
// Find the pending tool row. ORDER BY created_at DESC + LIMIT 1 picks
// the most recent row with this tool_call_id; the already-answered
// check below guards against UPDATE-ing a stale answer.
const toolRows = await sql<{
id: string;
tool_results: { tool_call_id: string; output: unknown } | null;
}[]>`
SELECT id, tool_results FROM messages
WHERE chat_id = ${chat.id}
AND role = 'tool'
AND tool_results->>'tool_call_id' = ${tool_call_id}
ORDER BY created_at DESC
LIMIT 1
`;
const toolRow = toolRows[0];
if (!toolRow) {
reply.code(404);
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
}
if (toolRow.tool_results && toolRow.tool_results.output !== null) {
reply.code(409);
return { error: 'tool_call_already_answered' };
}
const answerSet = { answers };
const newToolResults = {
tool_call_id,
output: answerSet,
truncated: false,
};
const result = await sql.begin(async (tx) => {
await tx`
UPDATE messages
SET tool_results = ${tx.json(newToolResults as never)}
WHERE id = ${toolRow.id}
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return {
tool_message_id: toolRow.id,
assistant_message_id: assistantMsg!.id,
};
});
// Publish the deferred tool_result frame. useSessionStream's reducer
// updates the matching tool_run.result so AskUserInputCard flips into
// its read-only "answered" mode without a refetch.
handlers.publishSessionFrame(sessionId, {
type: 'tool_result',
tool_message_id: result.tool_message_id,
tool_call_id,
chat_id: chat.id,
output: answerSet,
truncated: false,
});
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
reply.code(202);
return result;
},
);
}

View File

@@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS session_panes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('chat', 'file_browser')),
kind TEXT NOT NULL CHECK (kind IN ('chat', 'file_browser', 'terminal')),
state JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
UNIQUE (session_id, position)

View File

@@ -15,9 +15,12 @@ const CACHE_TTL_MS = 60_000;
// explicit `tools:` field inherit the full default set (which now includes
// the skill tools); agents with an explicit `tools:` array must list any
// skill tool they want to use — strict opt-in.
// Batch 9.7: ask_user_input added — same opt-in semantics. Agents with an
// explicit tools list that omits it cannot trigger the interactive picker.
const ALL_TOOL_NAMES = [
'view_file', 'list_dir', 'grep', 'find_files', 'git_status',
'skill_find', 'skill_use', 'skill_resource',
'ask_user_input',
] as const;
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
const DEFAULT_TEMPERATURE = 0.7;

View File

@@ -310,6 +310,70 @@ interface StreamOptions {
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(
ctx: InferenceContext,
model: string,
@@ -344,6 +408,10 @@ async function streamCompletion(
}
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 promptTokens: number | null = null;
let completionTokens: number | null = null;
@@ -377,8 +445,50 @@ async function streamCompletion(
if (!choice) continue;
const delta = choice.delta ?? {};
if (typeof delta.content === 'string' && delta.content.length > 0) {
content += delta.content;
onDelta(delta.content);
// v1.10.5 XML fallback. Append, then extract any complete tool_call
// 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)) {
for (const tc of delta.tool_calls) {
@@ -393,6 +503,15 @@ async function streamCompletion(
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[] = [];
for (const [, t] of [...toolCallsBuffer.entries()].sort(([a], [b]) => a - b)) {
let args: Record<string, unknown> = {};
@@ -665,6 +784,12 @@ async function executeToolPhase(
model: session.model,
});
// Batch 9.7: ask_user_input pauses the loop. The tool row is still inserted
// (the answer endpoint needs a target row to UPDATE), but tool_results is
// pre-stamped with output=null as a "pending" sentinel and no tool_result
// frame goes out — the card renders from the tool_call frame alone. Mixed
// batches still execute the other tools normally.
let pausingForUserInput = false;
await Promise.all(
toolCalls.map(async (tc) => {
const [toolRow] = await ctx.sql<{ id: string }[]>`
@@ -673,6 +798,16 @@ async function executeToolPhase(
RETURNING id
`;
const toolMessageId = toolRow!.id;
if (tc.name === 'ask_user_input') {
pausingForUserInput = true;
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
await ctx.sql`
UPDATE messages
SET tool_results = ${ctx.sql.json(sentinel as never)}
WHERE id = ${toolMessageId}
`;
return;
}
const tres = await executeToolCall(projectRoot, tc);
const stored = {
tool_call_id: tc.id,
@@ -697,6 +832,23 @@ async function executeToolPhase(
})
);
if (pausingForUserInput) {
// Drop the dot back to idle — the card is the actionable surface now.
// The next inference turn fires from POST /api/chats/:id/answer_user_input
// once the user submits their answers.
ctx.publishUser({
type: 'chat_status',
chat_id: chatId,
status: 'idle',
at: new Date().toISOString(),
});
ctx.log.info(
{ sessionId, chatId, assistantMessageId },
'inference paused awaiting user input',
);
return;
}
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())

View File

@@ -405,6 +405,81 @@ export const skillResource: ToolDef<SkillResourceInputT> = {
},
};
// Batch 9.7: ask_user_input. Interactive elicitation. The model emits a tool
// call with 1-3 structured questions; the inference loop PAUSES (does not
// execute the tool server-side, does not recurse) and waits for the frontend
// to POST /api/chats/:id/answer_user_input with the user's selections. See
// routes/messages.ts for the resume path and services/inference.ts for the
// pause branch in executeToolPhase.
const AskUserInputInput = z.object({
questions: z
.array(
z.object({
question: z.string().min(1).max(200),
type: z.enum(['single_select', 'multi_select']),
options: z.array(z.string().min(1).max(80)).min(2).max(6),
}),
)
.min(1)
.max(3),
});
type AskUserInputInputT = z.infer<typeof AskUserInputInput>;
export const askUserInput: ToolDef<AskUserInputInputT> = {
name: 'ask_user_input',
description:
"Ask the user 1-3 structured questions through an inline picker UI. Use when you genuinely need a choice the user must make (e.g. scope, options, preferences) before continuing. Each question has 2-6 options and accepts free-text answers in addition. The tool call pauses the conversation until the user submits — the next assistant turn sees their answers as the tool result. Do not use for trivial yes/no clarifications you could infer; prefer it over multi-paragraph speculation about what the user might want.",
inputSchema: AskUserInputInput,
jsonSchema: {
type: 'function',
function: {
name: 'ask_user_input',
description:
'Ask the user 1-3 structured questions through an inline picker. Pauses the conversation until the user answers; the next turn sees their selections.',
parameters: {
type: 'object',
properties: {
questions: {
type: 'array',
minItems: 1,
maxItems: 3,
items: {
type: 'object',
properties: {
question: { type: 'string', description: '<=200 chars, shown to the user' },
type: {
type: 'string',
enum: ['single_select', 'multi_select'],
description: 'single_select = at most one option; multi_select = any subset',
},
options: {
type: 'array',
minItems: 2,
maxItems: 6,
items: { type: 'string' },
description: '2-6 strings, each <=80 chars; free-text input is always available alongside',
},
},
required: ['question', 'type', 'options'],
additionalProperties: false,
},
},
},
required: ['questions'],
additionalProperties: false,
},
},
},
// Server-side no-op. The "execution" of ask_user_input is the user's
// response, captured client-side and posted to /api/chats/:id/answer_user_input.
// The inference loop detects this tool by name and pauses before reaching
// executeToolCall — this fallback only runs if something bypasses that
// branch, in which case the pending sentinel matches the pause-path shape.
async execute(input) {
return { _pending: true, questions: input.questions };
},
};
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
viewFile as ToolDef<unknown>,
listDir as ToolDef<unknown>,
@@ -414,6 +489,7 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
skillFind as ToolDef<unknown>,
skillUse as ToolDef<unknown>,
skillResource as ToolDef<unknown>,
askUserInput as ToolDef<unknown>,
];
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
@@ -422,6 +498,8 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
// default (10). Every tool in v1.8.2 happens to be read-only, so the
// non-RO branch only takes effect once BooCoder lands write tools.
// Batch 9.6: skill_* added; all still read-only.
// Batch 9.7: ask_user_input added — it pauses execution but doesn't mutate
// project state, so it belongs in the read-only set for budget purposes.
export const READ_ONLY_TOOL_NAMES = [
'view_file',
'list_dir',
@@ -431,6 +509,7 @@ export const READ_ONLY_TOOL_NAMES = [
'skill_find',
'skill_use',
'skill_resource',
'ask_user_input',
] as const;
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(

View File

@@ -26,7 +26,11 @@
"shiki": "^1.29.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-search": "^0.13.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.0",

View File

@@ -11,6 +11,7 @@ import type {
AgentsResponse,
GitMeta,
Skill,
AskUserAnswer,
} from './types';
export class ApiError extends Error {
@@ -202,6 +203,17 @@ export const api = {
method: 'POST',
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }),
}),
// Batch 9.7: submit answers for a paused ask_user_input call. Server
// validates against the question shape, UPDATEs the pending tool row,
// publishes the deferred tool_result frame, and enqueues the next turn.
answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) =>
request<{ tool_message_id: string; assistant_message_id: string }>(
`/api/chats/${chatId}/answer_user_input`,
{
method: 'POST',
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
},
),
},
messages: {
@@ -249,4 +261,26 @@ export const api = {
sidebar: {
get: () => request<SidebarResponse>('/api/sidebar'),
},
// v1.10 booterm: REST control plane for terminal panes. WebSocket attach
// lives at /ws/term/sessions/:sid/panes/:pid (handled directly by
// TerminalPane). All three endpoints are tolerant of empty bodies on the
// POSTs that don't take parameters.
terminals: {
start: (sessionId: string, paneId: string) =>
request<{ tmux_window: string }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/start`,
{ method: 'POST' },
),
resize: (sessionId: string, paneId: string, cols: number, rows: number) =>
request<{ ok: true }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/resize`,
{ method: 'POST', body: JSON.stringify({ cols, rows }) },
),
kill: (sessionId: string, paneId: string) =>
request<{ ok: true }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/kill`,
{ method: 'POST' },
),
},
};

View File

@@ -241,6 +241,27 @@ export interface Skill {
mtime: number;
}
// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] } in the
// same order. AskUserInputCard renders questions and POSTs answers.
export type AskUserQuestionType = 'single_select' | 'multi_select';
export interface AskUserQuestion {
question: string;
type: AskUserQuestionType;
options: string[];
}
export interface AskUserAnswer {
question: string;
selected_options: string[];
free_text: string | null;
}
export interface AskUserAnswerSet {
answers: AskUserAnswer[];
}
// v1.9: 'settings' is an ephemeral pane kind — never persisted, always
// singleton per workspace. The pane hook filters it out before writing to
// localStorage and dedupes on insertion via toggleSettingsPane().

View File

@@ -0,0 +1,324 @@
import { useMemo, useState } from 'react';
import { Check } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Button } from '@/components/ui/button';
import type {
AskUserAnswer,
AskUserAnswerSet,
AskUserQuestion,
ToolCall,
ToolResult,
} from '@/api/types';
// Batch 9.7. Inline interactive picker. Renders inside MessageList in place of
// the standard ToolCallLine when the assistant emits an ask_user_input tool
// call. While the tool result is null (server pre-stamps a sentinel with
// output=null), shows the form; once the WS tool_result frame arrives with a
// real AnswerSet, flips to read-only review mode.
interface Props {
toolCall: ToolCall;
toolResult: ToolResult | null;
chatId: string;
}
function parseQuestions(raw: unknown): AskUserQuestion[] {
if (!raw || typeof raw !== 'object' || !('questions' in raw)) return [];
const arr = (raw as { questions: unknown }).questions;
if (!Array.isArray(arr)) return [];
const out: AskUserQuestion[] = [];
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
const q = item as { question?: unknown; type?: unknown; options?: unknown };
if (typeof q.question !== 'string') continue;
if (q.type !== 'single_select' && q.type !== 'multi_select') continue;
if (!Array.isArray(q.options)) continue;
const opts = q.options.filter((o): o is string => typeof o === 'string');
if (opts.length < 2) continue;
out.push({ question: q.question, type: q.type, options: opts });
}
return out;
}
function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null;
const arr = (raw as { answers: unknown }).answers;
if (!Array.isArray(arr)) return null;
const answers: AskUserAnswer[] = [];
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown };
if (typeof a.question !== 'string') continue;
if (!Array.isArray(a.selected_options)) continue;
if (a.free_text !== null && typeof a.free_text !== 'string') continue;
const sel = a.selected_options.filter((s): s is string => typeof s === 'string');
answers.push({
question: a.question,
selected_options: sel,
free_text: (a.free_text as string | null) ?? null,
});
}
return { answers };
}
export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
if (questions.length === 0) {
return (
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
ask_user_input: malformed tool args
</div>
);
}
// Tool result with a non-null output means the answer is already submitted.
// The pending sentinel uses output=null, so this branch only triggers after
// the real WS tool_result frame lands.
const answered = toolResult && toolResult.output !== null;
if (answered) {
const answerSet = parseAnswerSet(toolResult!.output);
return <AnsweredView questions={questions} answers={answerSet} />;
}
return (
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
);
}
function PendingView({
questions,
toolCallId,
chatId,
}: {
questions: AskUserQuestion[];
toolCallId: string;
chatId: string;
}) {
// Per-question selections + free text. Selections are option arrays so the
// multi_select case is uniform; single_select just constrains to length 1.
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
const [submitting, setSubmitting] = useState(false);
const singleQuestion = questions.length === 1;
const anyFreeText = freeTexts.some((t) => t.trim().length > 0);
// Submit button shows when:
// - more than one question (always batched), OR
// - one question and the user has typed free text (committing it needs an
// explicit Submit so an accidental Tab/click doesn't lose it).
// For one question with no free text, clicking an option submits inline.
const showSubmitButton = !singleQuestion || anyFreeText;
// Every question must have at least one of (option, free text).
const allComplete = questions.every((_, i) => {
return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0;
});
function buildAnswers(): AskUserAnswer[] {
return questions.map((q, i) => {
const freeText = freeTexts[i]!.trim();
return {
question: q.question,
selected_options: selections[i]!,
free_text: freeText.length > 0 ? freeText : null,
};
});
}
async function submit(answers: AskUserAnswer[]) {
if (submitting) return;
setSubmitting(true);
try {
await api.chats.answerUserInput(chatId, toolCallId, answers);
// Card stays mounted; the incoming WS tool_result frame will flip it
// into AnsweredView via the parent prop change.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'submit failed');
setSubmitting(false);
}
}
function pickSingle(qIdx: number, option: string) {
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
// Immediate submit for the single-question single-select shortcut. Only
// fires when no free text exists anywhere — once the user typed, the
// Submit button takes over so the typed text isn't silently dropped.
if (singleQuestion && !anyFreeText) {
const answers: AskUserAnswer[] = [
{
question: questions[0]!.question,
selected_options: [option],
free_text: null,
},
];
void submit(answers);
}
}
function toggleMulti(qIdx: number, option: string) {
setSelections((prev) =>
prev.map((arr, i) => {
if (i !== qIdx) return arr;
return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
}),
);
}
function setFreeText(qIdx: number, value: string) {
setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t)));
}
return (
<div className="rounded-lg border bg-muted/20 text-sm">
<div className="px-4 py-3 space-y-4">
{questions.map((q, i) => (
<div key={i} className="space-y-2">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
{q.type === 'single_select' ? (
<RadioGroup
value={selections[i]![0] ?? ''}
onValueChange={(v) => pickSingle(i, v)}
disabled={submitting}
className="gap-1.5"
>
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
<span>{opt}</span>
</label>
);
})}
</RadioGroup>
) : (
<div className="grid gap-1.5">
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
const checked = selections[i]!.includes(opt);
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<input
id={id}
type="checkbox"
checked={checked}
disabled={submitting}
onChange={() => toggleMulti(i, opt)}
className="mt-1 size-3.5 rounded border-input accent-primary"
/>
<span>{opt}</span>
</label>
);
})}
</div>
)}
<div className="pt-1 space-y-1">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Or type a custom answer
</div>
<input
type="text"
value={freeTexts[i]}
disabled={submitting}
placeholder="Free text…"
onChange={(e) => setFreeText(i, e.target.value)}
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
/>
</div>
</div>
))}
</div>
{showSubmitButton && (
<div className="flex justify-end gap-2 border-t px-4 py-2">
<Button
type="button"
size="sm"
disabled={!allComplete || submitting}
onClick={() => void submit(buildAnswers())}
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
)}
</div>
);
}
function AnsweredView({
questions,
answers,
}: {
questions: AskUserQuestion[];
answers: AskUserAnswerSet | null;
}) {
if (!answers) {
return (
<div className="rounded-lg border bg-muted/20 text-xs px-4 py-3 text-muted-foreground">
ask_user_input: answers unavailable
</div>
);
}
return (
<div className="rounded-lg border bg-muted/10 text-sm">
<div className="px-4 py-3 space-y-3">
{questions.map((q, i) => {
const a = answers.answers[i];
if (!a) return null;
return (
<div key={i} className="space-y-1.5">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
<div className="space-y-0.5">
{q.options.map((opt, j) => {
const selected = a.selected_options.includes(opt);
return (
<div
key={j}
className={
selected
? 'flex items-start gap-2 text-sm leading-snug text-foreground'
: 'flex items-start gap-2 text-sm leading-snug text-muted-foreground/60 line-through'
}
>
<span className="mt-0.5 size-3.5 shrink-0 inline-flex items-center justify-center">
{selected && <Check className="size-3 text-primary" />}
</span>
<span>{opt}</span>
</div>
);
})}
</div>
{a.free_text && (
<div className="rounded bg-background border px-2 py-1 text-xs font-mono whitespace-pre-wrap">
{a.free_text}
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -25,6 +25,7 @@ import { AgentPicker } from '@/components/AgentPicker';
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { chatInputsRegistry, sendToChat } from '@/lib/events';
import { useSkills } from '@/hooks/useSkills';
import { useViewport } from '@/hooks/useViewport';
@@ -51,9 +52,16 @@ interface Props {
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
// disables slash-command dispatch (input is sent as literal text).
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 [value, setValue] = useState('');
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) {
setAttachments(prev => prev.filter(a => a.id !== id));
}

View File

@@ -1,4 +1,4 @@
import { Children, cloneElement, isValidElement, useState } from 'react';
import { Children, cloneElement, isValidElement, useEffect, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -7,9 +7,19 @@ import { toast } from 'sonner';
import type { Chat, ErrorReason, Message } from '@/api/types';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
import { CapHitSentinel } from './CapHitSentinel';
import { CodeBlock } from './CodeBlock';
import { Button } from '@/components/ui/button';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
Dialog,
DialogContent,
@@ -19,6 +29,57 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
// v1.10 booterm: tiny subscription hook for the mounted-terminals registry.
// Used by the right-click "Send to terminal" submenu so it always reflects
// currently-open terminal panes without prop drilling from Workspace.
function useTerminals(): TerminalRegistration[] {
const [list, setList] = useState(() => terminalsRegistry.list());
useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []);
return list;
}
// Wrap a message body with a right-click context menu offering "Send to
// terminal → <pane name>". The submenu is disabled when nothing is selected
// or no terminal panes are open; clicking a target emits a sendToTerminal
// event that TerminalPane subscribes to (filtered by pane_id).
function SendToTerminalMenu({ children }: { children: ReactNode }) {
const [selection, setSelection] = useState('');
const terminals = useTerminals();
const canSend = selection.length > 0 && terminals.length > 0;
return (
<ContextMenu
onOpenChange={(open) => {
if (open) {
const sel = typeof window !== 'undefined' ? window.getSelection()?.toString() ?? '' : '';
setSelection(sel);
}
}}
>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub>
<ContextMenuSubTrigger disabled={!canSend}>Send to terminal</ContextMenuSubTrigger>
<ContextMenuSubContent>
{terminals.length === 0 ? (
<ContextMenuItem disabled>No terminal panes open</ContextMenuItem>
) : (
terminals.map((t) => (
<ContextMenuItem
key={t.paneId}
onSelect={() => sendToTerminal.emit({ pane_id: t.paneId, text: selection })}
>
{t.label}
</ContextMenuItem>
))
)}
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>
);
}
// v1.8.2: human labels for the machine-readable error reasons that ride on
// failed assistant messages via metadata.kind === 'error'. Kept short so the
// inline render under "message failed" stays a single muted line.
@@ -507,9 +568,11 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
if (message.role === 'user') {
return (
<div className="group flex flex-col items-end gap-1">
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
{message.content}
</div>
<SendToTerminalMenu>
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
{message.content}
</div>
</SendToTerminalMenu>
<ActionRow message={message} />
</div>
);
@@ -529,12 +592,14 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
return (
<div className="group flex flex-col gap-2">
{(hasContent || isStreaming) && (
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasContent ? <MarkdownBody content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</div>
<SendToTerminalMenu>
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasContent ? <MarkdownBody content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</div>
</SendToTerminalMenu>
)}
{failed && (
<div className="text-xs text-destructive">

View File

@@ -3,6 +3,7 @@ import type { Chat, Message } from '@/api/types';
import { MessageBubble } from './MessageBubble';
import { ToolCallGroup } from './ToolCallGroup';
import { ToolCallLine, type ToolRun } from './ToolCallLine';
import { AskUserInputCard } from './AskUserInputCard';
interface Props {
messages: Message[];
@@ -12,9 +13,11 @@ interface Props {
// v1.8.2: pre-render units. The single linear `messages` array gets walked
// into a render-time list where each tool_call is a first-class item and
// tool_result messages are folded onto their matching tool_run by id.
// Batch 9.7: tool_run carries chat_id so AskUserInputCard can post the
// answer without threading the chat id through MessageList's parent.
type RenderItem =
| { kind: 'message'; message: Message; capHitInfo?: { position: number; isLatest: boolean } }
| { kind: 'tool_run'; run: ToolRun; key: string }
| { kind: 'tool_run'; run: ToolRun; key: string; chatId: string }
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
const GROUP_THRESHOLD = 3;
@@ -50,7 +53,7 @@ function flatten(messages: Message[]): RenderItem[] {
for (const tc of m.tool_calls!) {
const run: ToolRun = { call: tc, result: null };
runsByCallId.set(tc.id, run);
items.push({ kind: 'tool_run', run, key: tc.id });
items.push({ kind: 'tool_run', run, key: tc.id, chatId: m.chat_id });
}
continue;
}
@@ -63,6 +66,9 @@ function flatten(messages: Message[]): RenderItem[] {
// Second pass: collapse runs of >=GROUP_THRESHOLD consecutive tool_run items
// of the same tool name into a single tool_group. Any other render item
// (text bubble, sentinel, user message) breaks the chain.
// Batch 9.7: ask_user_input never groups — each pause has its own card so
// grouping would render them as collapsed ToolCallLines which can't surface
// the interactive form.
function group(items: RenderItem[]): RenderItem[] {
const out: RenderItem[] = [];
let i = 0;
@@ -74,6 +80,11 @@ function group(items: RenderItem[]): RenderItem[] {
continue;
}
const name = item.run.call.name;
if (name === 'ask_user_input') {
out.push(item);
i += 1;
continue;
}
let j = i + 1;
while (
j < items.length &&
@@ -82,7 +93,12 @@ function group(items: RenderItem[]): RenderItem[] {
) {
j += 1;
}
const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>;
const run = items.slice(i, j) as Array<{
kind: 'tool_run';
run: ToolRun;
key: string;
chatId: string;
}>;
if (run.length >= GROUP_THRESHOLD) {
out.push({
kind: 'tool_group',
@@ -150,6 +166,16 @@ export function MessageList({ messages, sessionChats }: Props) {
);
}
if (item.kind === 'tool_run') {
if (item.run.call.name === 'ask_user_input') {
return (
<AskUserInputCard
key={item.key}
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
);
}
return <ToolCallLine key={item.key} run={item.run} />;
}
return <ToolCallGroup key={item.key} runs={item.runs} />;

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import {
Bot,
ChevronDown,
@@ -31,6 +31,15 @@ interface Props {
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']) {
if (kind === 'terminal') return <Terminal size={14} />;
if (kind === 'agent') return <Bot size={14} />;
@@ -70,11 +79,66 @@ export function MobileTabSwitcher({
const [open, setOpen] = useState(false);
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
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 activeLabel = active ? paneLabel(active, chats) : 'Empty';
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
// so the trailing kebab's Radix DropdownMenu opens at the touch point.
const longPress = useLongPress(({ clientX, clientY, target }) => {
@@ -113,17 +177,39 @@ export function MobileTabSwitcher({
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
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"
aria-label="Switch pane"
<div
className="flex-1 relative min-w-0"
onTouchStart={onPillTouchStart}
onTouchMove={onPillTouchMove}
onTouchEnd={onPillTouchEnd}
onTouchCancel={onPillTouchEnd}
>
<span className="shrink-0 text-muted-foreground">{paneIcon(active?.kind ?? 'chat')}</span>
<StatusDot chatId={activeChatId} />
<span className="truncate flex-1 text-left">{activeLabel}</span>
<ChevronDown size={14} className="opacity-60 shrink-0" />
</button>
{/* 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
type="button"
onClick={onPillClick}
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"
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>
<StatusDot chatId={activeChatId} />
<span className="truncate flex-1 text-left">{activeLabel}</span>
<ChevronDown size={14} className="opacity-60 shrink-0" />
</button>
</div>
<BottomSheet open={open} onClose={() => setOpen(false)} title="Panes">
<ul className="px-2 py-2 space-y-1">

View File

@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot, Clipboard, X } from 'lucide-react';
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport';
import { terminalsRegistry } from '@/lib/events';
import { ChatPane } from '@/components/panes/ChatPane';
import { SettingsPane } from '@/components/panes/SettingsPane';
import { TerminalPane } from '@/components/panes/TerminalPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import {
@@ -115,6 +117,20 @@ export function Workspace({
.filter((c): c is Chat => c !== undefined);
}
// v1.10 booterm: per-terminal label used by the registry that powers the
// MessageBubble "Send to terminal" submenu. Numbered in workspace order.
const terminalLabels = useMemo(() => {
const out = new Map<string, string>();
let n = 0;
for (const p of panes) {
if (p.kind === 'terminal') {
n += 1;
out.set(p.id, `Terminal ${n}`);
}
}
return out;
}, [panes]);
return (
<div className="flex flex-col h-full min-h-0">
{!isMobile && (
@@ -165,6 +181,7 @@ export function Workspace({
>
{panes.map((pane, idx) => {
const isSettings = pane.kind === 'settings';
const isTerminal = pane.kind === 'terminal';
// v1.9: when maximized, hide every pane except the settings one.
// display:none keeps the React tree mounted so streams / drafts
// survive the toggle without re-mount cost.
@@ -176,6 +193,9 @@ export function Workspace({
}
return null;
}
// Terminal panes own their tab strip (no chats, no ChatTabBar) and
// are not drag-reorderable for now — keeps the layout grid simple.
const isChromeless = isSettings || isTerminal;
return (
<div
key={pane.id}
@@ -187,19 +207,18 @@ export function Workspace({
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
)}
onClick={() => setActivePaneIdx(idx)}
onDragOver={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && !isSettings && panes.length > 1 ? handlePaneDrop(idx) : undefined}
onDragOver={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDrop(idx) : undefined}
>
<div
draggable={!isMobile && !isSettings && panes.length > 1}
onDragStart={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragEnd : undefined}
draggable={!isMobile && !isChromeless && panes.length > 1}
onDragStart={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined}
>
{/* Hidden on mobile per v1.8; settings panes own their own
section nav / maximize toggle so they skip ChatTabBar
entirely. */}
{!isMobile && !isSettings && (
{/* Hidden on mobile per v1.8; settings + terminal panes own
their own header (no chats, so no ChatTabBar). */}
{!isMobile && !isChromeless && (
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
@@ -214,6 +233,45 @@ export function Workspace({
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
)}
{isTerminal && (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
<Terminal size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{terminalLabels.get(pane.id) ?? 'Terminal'}
</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 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removePane(idx);
}}
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"
title="Close terminal pane"
>
<X size={12} />
</button>
)}
</div>
)}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
@@ -226,6 +284,13 @@ export function Workspace({
onClose={() => removePane(idx)}
isMobile={isMobile}
/>
) : isTerminal ? (
<TerminalPane
sessionId={sessionId}
paneId={pane.id}
label={terminalLabels.get(pane.id) ?? 'Terminal'}
active={idx === activePaneIdx}
/>
) : pane.kind === 'chat' && pane.chatId ? (
<ChatPane
sessionId={sessionId}

View File

@@ -196,6 +196,8 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
onSend={handleSend}
onForceSend={streaming ? handleForceSend : undefined}
onSlashCommand={handleSlashCommand}
chatId={chatId}
chatLabel={sessionChats?.find((c) => c.id === chatId)?.name ?? 'Chat'}
/>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -11,14 +11,25 @@ function generateId(): string {
return crypto.randomUUID();
}
function emptyPane(): WorkspacePane {
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
// v1.10.3: optional id arg lets addSplitPane lift id generation out of the
// 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 {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}
// v1.10 booterm: terminal panes carry no chats. Their `id` is used as the
// 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
// same tmux window via the idempotent start endpoint.
function terminalPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'terminal', chatIds: [], activeChatIdx: -1 };
}
// v1.9: settings pane factory. No chats, no state beyond identity — the
// SettingsPane component renders Session/Project sections from the
// surrounding session/project.
@@ -72,7 +83,11 @@ export interface UseWorkspacePanesResult {
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
closeAllTabs: (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
// 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.
@@ -233,25 +248,29 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
});
}, []);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
if (kind === 'terminal') {
toast('Terminal panes coming in BooTerm');
return;
}
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent'): string | null => {
if (kind === 'agent') {
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) => {
// v1.9: settings panes are excluded from the MAX cap (decision c).
if (nonSettingsCount(prev) >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const next = [...prev, emptyPane()];
const newPane = kind === 'terminal' ? terminalPane(newPaneId) : emptyPane(newPaneId);
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
success = true;
return next;
});
return success ? newPaneId : null;
}, []);
const toggleSettingsPane = useCallback(() => {

151
apps/web/src/lib/events.ts Normal file
View File

@@ -0,0 +1,151 @@
// Minimal pub/sub for ephemeral UI events that don't belong on the sessionEvents
// bus (sessionEvents is for DB-state changes; this file is for UI-only signals
// like "user clicked send-to-terminal on selected text").
//
// Also exposes a tiny registry of currently-mounted terminal panes so the
// MessageBubble context menu can list them. TerminalPane registers on mount,
// 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;
interface EventBus<T> {
emit(payload: T): void;
subscribe(listener: Listener<T>): () => void;
}
function createEvent<T>(): EventBus<T> {
const listeners = new Set<Listener<T>>();
return {
emit(payload) {
for (const l of listeners) {
try {
l(payload);
} catch {
/* one bad listener shouldn't break others */
}
}
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
export interface SendToTerminalPayload {
pane_id: string;
text: string;
}
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 {
paneId: 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 registryListeners = new Set<Listener<void>>();
function notifyRegistry(): void {
for (const l of registryListeners) {
try {
l();
} catch {
/* ignore */
}
}
}
export const terminalsRegistry = {
register(
paneId: string,
label: string,
focus: () => void,
openSearch: () => void,
paste: () => void,
): () => void {
terminalRegistry.set(paneId, { paneId, label, focus, openSearch, paste });
notifyRegistry();
return () => {
terminalRegistry.delete(paneId);
notifyRegistry();
};
},
list(): TerminalRegistration[] {
return Array.from(terminalRegistry.values());
},
get(paneId: string): TerminalRegistration | undefined {
return terminalRegistry.get(paneId);
},
subscribe(listener: Listener<void>): () => void {
registryListeners.add(listener);
return () => {
registryListeners.delete(listener);
};
},
};
// 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);
};
},
};

View File

@@ -10,6 +10,7 @@ import { ChevronRight, FolderTree, Menu } from 'lucide-react';
import { api } from '@/api/client';
import type { Project, Session as SessionType } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { terminalsRegistry } from '@/lib/events';
import { useActivePane } from '@/hooks/useActivePane';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
@@ -170,6 +171,122 @@ function SessionInner({ sessionId }: { sessionId: string }) {
[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() {
if (!session) return;
const trimmed = name.trim();
@@ -264,7 +381,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
onRenameChat={renameChat}
/>
<NewPaneMenu
onAddPane={addSplitPane}
onAddPane={addPaneAndSwitch}
disabled={panes.length >= MAX_PANES}
/>
</div>

View File

@@ -12,6 +12,24 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
// Booterm runs on a separate port (9501 in compose). Order matters:
// /api/term/* and /ws/term/* must be listed before the broader /api
// entry so Vite matches the more specific prefix first.
'/api/term': {
target: process.env.BOOTERM_DEV_URL ?? 'http://127.0.0.1:9501',
changeOrigin: true,
headers: {
'Remote-User': process.env.DEV_REMOTE_USER ?? 'sam',
},
},
'/ws/term': {
target: process.env.BOOTERM_DEV_URL ?? 'http://127.0.0.1:9501',
changeOrigin: true,
ws: true,
headers: {
'Remote-User': process.env.DEV_REMOTE_USER ?? 'sam',
},
},
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,

269
boocode_batch10.md Normal file
View File

@@ -0,0 +1,269 @@
# BooCode v1.1 — Batch 10
**Theme:** BooTerm. Second container, dedicated to in-browser terminals. Per-session tmux. xterm.js + node-pty in-container. New pane type wires into the BooCode shell.
**Status:** Planned. Largest batch in v1.1. Depends on Batch 3 (pane system), Batch 7 (settings drawer pattern reused).
**Repo:** `/opt/boocode/` (shared monorepo). New `apps/booterm/` subdirectory.
## Goals
1. New container `booterm` running Fastify + node-pty + tmux. Per-session tmux session keyed by `(user, session_id)`.
2. xterm.js terminal pane in the BooCode shell. Multiple terminal panes per session, each attached to a separate tmux window.
3. PTY traffic over WebSocket. Auth via `Remote-User`.
4. tmux as session manager so terminals survive WebSocket reconnects, page refreshes, even container restarts.
5. Read+write capability scoped to project root. No `cd ..` escape.
6. Path-based routing: `code.indifferentketchup.com/api/term/*` → booterm; `/ws/term/*` → booterm.
## Architecture
```
browser ──HTTPS──> Caddy (droplet) ──Tailscale──> Authelia
├── /api/chat/*, /ws/chat/* → boocode :9500
├── /api/term/*, /ws/term/* → booterm :9501
└── / → boocode (SPA)
booterm container:
- Fastify (Node 20)
- node-pty
- tmux installed in container (apk add tmux)
- same Postgres (boocode_db)
- mounts projects rw (scoped)
```
### Mount strategy
Decided: Option A. Per-project bind mounts in `docker-compose.yml`. Already applied: booterm has `/opt:/opt:rw` to keep parity with the existing boocode mount and avoid enumerating roots. Project root for any given session derives from `projects.root_path` and tmux launches with `cwd` set there.
### tmux session naming
Per-session tmux:
```
tmux session name: bc-<session_id> (UUID, sanitized — alphanumeric + hyphen)
tmux windows: term-<pane_id> (one window per terminal pane)
```
booterm spawns `tmux new-session -d -s bc-<sid> -c <project_root>` lazily on first attach. Subsequent attaches do `tmux new-window -t bc-<sid>` for additional panes, or `tmux attach -t bc-<sid>` and select window.
## Data model
| Column | On | Type | Default | Notes |
|---|---|---|---|---|
| (none) | — | — | — | terminals are tmux-managed, no DB rows |
| `kind = 'terminal'` | `session_panes.kind` CHECK | — | — | Extend CHECK to include `'terminal'` |
| `state.tmux_window` | `session_panes.state` JSONB | TEXT | NULL | Which tmux window this pane attaches to |
Schema (already applied to live DB + schema.sql):
```sql
ALTER TABLE session_panes DROP CONSTRAINT IF EXISTS session_panes_kind_check;
ALTER TABLE session_panes ADD CONSTRAINT session_panes_kind_check
CHECK (kind IN ('chat', 'file_browser', 'terminal'));
```
## Backend (booterm)
New app at `apps/booterm/`:
```
apps/booterm/
├── src/
│ ├── index.ts # Fastify + WS + auth
│ ├── auth.ts # Remote-User middleware (same pattern as boocode)
│ ├── db.ts # pg pool (shared boocode_db)
│ ├── routes/
│ │ ├── health.ts
│ │ └── terminals.ts # POST /api/term/sessions/:sid/panes/:pid/start (creates tmux window)
│ ├── pty/
│ │ ├── manager.ts # tmux process management
│ │ └── pty.ts # node-pty wrapper for `tmux attach -t ... -d`
│ └── ws/
│ └── attach.ts # WS /ws/term/sessions/:sid/panes/:pid → PTY bidi pipe
├── package.json
└── tsconfig.json
```
### Endpoints
| Method | Path | Notes |
|---|---|---|
| GET | `/api/term/health` | Ping |
| POST | `/api/term/sessions/:sid/panes/:pid/start` | Idempotent tmux window create. Returns `{tmux_window: "term-<pid>"}` |
| WS | `/ws/term/sessions/:sid/panes/:pid` | Attach PTY |
| POST | `/api/term/sessions/:sid/panes/:pid/resize` | `{cols, rows}` |
| POST | `/api/term/sessions/:sid/panes/:pid/kill` | Kill the tmux window |
WS frames (binary or text):
```
client → server: pty input (raw bytes, typed by user)
server → client: pty output (raw bytes from shell)
server → client: {type: "exit", code} on window close
```
### Auth + scoping
- `Remote-User` required on WS upgrade.
- `session_id` validated: lookup in `sessions` table; require row exists.
- `pane_id` validated: must exist in `session_panes` with `kind = 'terminal'` and matching `session_id`.
- Project root derived from `sessions.project_id → projects.root_path`. tmux starts `cd <root>` in that dir. **No chroot.** User can `cd /` and read anything mounted into the container.
- Future hardening: namespace/chroot. Out of v1.1 scope.
### tmux config
`apps/booterm/tmux.conf` bundled into image at `/etc/booterm/tmux.conf`; tmux invocations use `-f /etc/booterm/tmux.conf`:
```
set -g default-terminal "screen-256color"
set -g history-limit 50000
set -g mouse on
setw -g mode-keys vi
set -g status off
set -g destroy-unattached off
```
Boolab pattern (from `services/tmux_session.py`).
## Frontend
| File | Change |
|---|---|
| `apps/web/src/components/panes/TerminalPane.tsx` (NEW) | xterm.js mount, WS attach, resize handler |
| `apps/web/src/api/client.ts` | `api.terminals.start(sessionId, paneId)`, `api.terminals.resize(...)`, `api.terminals.kill(...)` |
| `apps/web/src/components/Workspace.tsx` | Add 'terminal' to the pane kind enum; spawn button → POST start → render TerminalPane. Tab UI lives in Workspace.tsx — there is no PaneTab.tsx file. |
| `apps/web/package.json` | `xterm` + `xterm-addon-fit` + `xterm-addon-web-links` |
### TerminalPane
```tsx
useEffect(() => {
const term = new Terminal({ fontFamily: 'JetBrains Mono', fontSize: 14, theme: ... });
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(containerRef.current);
fit.fit();
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${window.location.host}/ws/term/sessions/${sid}/panes/${pid}`);
ws.binaryType = 'arraybuffer';
ws.onmessage = e => term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data));
term.onData(data => ws.send(data));
term.onResize(({ cols, rows }) => api.terminals.resize(sid, pid, cols, rows));
const ro = new ResizeObserver(() => fit.fit());
ro.observe(containerRef.current);
return () => { ws.close(); term.dispose(); ro.disconnect(); };
}, [sid, pid]);
```
Dev: vite.config.ts needs `/api/term` and `/ws/term` proxy entries mirroring the existing `/api` and `/ws` ones.
## Send-to-terminal from chat
Boolab pattern: select text in a message → "Send to terminal" button → text becomes terminal input.
- Right-click context menu on selected text in chat → "Send to terminal" submenu lists open terminal panes.
- Click target → sends `<text>\n` to that pane's WS.
Implementation:
| File | Change |
|---|---|
| `apps/web/src/components/MessageBubble.tsx` | Selection handler + context menu |
| `apps/web/src/lib/events.ts` | New event `send_to_terminal` with payload `{pane_id, text}` |
| `apps/web/src/components/panes/TerminalPane.tsx` | Subscribe to event for its `pane_id`, write to WS |
## Docker compose (already applied)
booterm service is already in `docker-compose.yml` with:
- build context `.`, dockerfile `apps/booterm/Dockerfile`
- port `100.114.205.53:9501:3000`
- `/opt:/opt:rw` mount
- `DATABASE_URL` env pointing at `boocode_db`
- `boocode_net` network
- depends_on: `boocode_db`
Do not re-edit compose.
## Backend dependencies
`apps/booterm/package.json`:
- `fastify`
- `@fastify/websocket`
- `pg`
- `zod`
- `node-pty`
- `tslib`
`node-pty` requires native build. Dockerfile installs `python3 make g++` in build stage and `tmux` in runtime stage:
```dockerfile
FROM node:20-alpine AS build
RUN apk add --no-cache python3 make g++ tmux
WORKDIR /app
COPY ...
RUN pnpm install --frozen-lockfile && pnpm build
FROM node:20-alpine
RUN apk add --no-cache tmux
WORKDIR /app
COPY --from=build /app/apps/booterm/dist ./dist
COPY --from=build /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
```
## Files to touch
**New app:**
- `apps/booterm/` (entire subtree)
**Existing changes:**
- `apps/web/package.json`
- `apps/web/src/api/client.ts`
- `apps/web/src/api/types.ts`
- `apps/web/src/components/Workspace.tsx`
- `apps/web/src/components/MessageBubble.tsx`
- `apps/web/src/components/panes/TerminalPane.tsx` (NEW)
- `apps/web/src/lib/events.ts`
- `apps/web/vite.config.ts` (proxy entries)
**Already done by user — do not touch:**
- `docker-compose.yml` (booterm service added)
- `apps/server/src/schema.sql` (terminal CHECK constraint)
- Live DB constraint applied
## Verification
1. `docker compose up -d --build booterm` → container healthy.
2. `curl -s http://100.114.205.53:9501/api/term/health -H 'Remote-User: sam'` → 200.
3. Browser smoke test:
- Open a session. Workspace → "+ Terminal" → terminal pane appears with shell prompt in project root.
- Type `ls -la` → output.
- Type `vim test.txt`, write something, save, `:q` → file exists on host (since rw mount).
- Refresh browser → terminal reconnects, history intact (tmux persistence).
- Open second terminal pane → same project, separate tmux window. Both work independently.
- Select code in chat → right-click → "Send to terminal" → terminal pane receives the text.
- Container restart (`docker compose restart booterm`) → on reconnect, tmux session resumes from where it left off.
- Close pane via tab context menu → tmux window killed. Reopen pane → fresh shell.
## Constraints
- node-pty is a native dep. Image size grows.
- tmux history capped at 50k lines per window.
- WebSocket frames are bidirectional binary; `binaryType = 'arraybuffer'`.
- Resize debounced 100ms client-side; backend `tmux resize-window` per resize.
- No chroot/namespace isolation in v1.1. User has full read+write under `/opt/`. Acceptable for single-user homelab.
- Don't expose 9501 on 0.0.0.0. Tailscale binding only (already configured in compose).
## Open
- Color theme matching for xterm.js. Defer.
- File-drop into terminal (upload via terminal pane). Out of scope.
- Multi-user (each user gets own tmux server) — defer until BooCode goes multi-user, which isn't planned.
- BooCoder container — same skeleton as booterm but with edit_file / create_file tools instead of PTY. Will follow this pattern when built.

View File

@@ -19,6 +19,27 @@ services:
networks:
- boocode_net
booterm:
build:
context: .
dockerfile: apps/booterm/Dockerfile
container_name: booterm
restart: unless-stopped
ports:
- "100.114.205.53:9501:3000"
env_file: .env
environment:
NODE_ENV: production
PORT: 3000
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
volumes:
- /opt:/opt:rw
- /home/samkintop:/home/samkintop:rw
depends_on:
- boocode_db
networks:
- boocode_net
boocode_db:
image: postgres:16-alpine
container_name: boocode_db

204
pnpm-lock.yaml generated
View File

@@ -12,6 +12,40 @@ importers:
specifier: ^5.5.0
version: 5.9.3
apps/booterm:
dependencies:
'@fastify/websocket':
specifier: ^10.0.1
version: 10.0.1
fastify:
specifier: ^4.28.1
version: 4.29.1
node-pty:
specifier: ^1.0.0
version: 1.1.0
pg:
specifier: ^8.13.0
version: 8.20.0
tslib:
specifier: ^2.6.3
version: 2.8.1
zod:
specifier: ^3.23.8
version: 3.25.76
devDependencies:
'@types/node':
specifier: ^20.14.10
version: 20.19.41
'@types/pg':
specifier: ^8.11.10
version: 8.20.0
tsx:
specifier: ^4.16.2
version: 4.22.0
typescript:
specifier: ^5.5.0
version: 5.9.3
apps/server:
dependencies:
'@fastify/static':
@@ -102,6 +136,18 @@ importers:
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
xterm:
specifier: ^5.3.0
version: 5.3.0
xterm-addon-fit:
specifier: ^0.8.0
version: 0.8.0(xterm@5.3.0)
xterm-addon-search:
specifier: ^0.13.0
version: 0.13.0(xterm@5.3.0)
xterm-addon-web-links:
specifier: ^0.9.0
version: 0.9.0(xterm@5.3.0)
devDependencies:
'@tailwindcss/postcss':
specifier: ^4.3.0
@@ -1727,6 +1773,9 @@ packages:
'@types/node@20.19.41':
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
'@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
@@ -2964,6 +3013,9 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -2973,6 +3025,9 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-pty@1.1.0:
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
node-releases@2.0.44:
resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==}
@@ -3079,6 +3134,40 @@ packages:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
pg-connection-string@2.12.0:
resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-pool@3.13.0:
resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.13.0:
resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg@8.20.0:
resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -3112,6 +3201,22 @@ packages:
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
postgres-bytea@1.0.1:
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
engines: {node: '>=0.10.0'}
postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
postgres@3.4.9:
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
engines: {node: '>=12'}
@@ -3797,6 +3902,32 @@ packages:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
xterm-addon-fit@0.8.0:
resolution: {integrity: sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==}
deprecated: This package is now deprecated. Move to @xterm/addon-fit instead.
peerDependencies:
xterm: ^5.0.0
xterm-addon-search@0.13.0:
resolution: {integrity: sha512-sDUwG4CnqxUjSEFh676DlS3gsh3XYCzAvBPSvJ5OPgF3MRL3iHLPfsb06doRicLC2xXNpeG2cWk8x1qpESWJMA==}
deprecated: This package is now deprecated. Move to @xterm/addon-search instead.
peerDependencies:
xterm: ^5.0.0
xterm-addon-web-links@0.9.0:
resolution: {integrity: sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==}
deprecated: This package is now deprecated. Move to @xterm/addon-web-links instead.
peerDependencies:
xterm: ^5.0.0
xterm@5.3.0:
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
deprecated: This package is now deprecated. Move to @xterm/xterm instead.
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -5380,6 +5511,12 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/pg@8.20.0':
dependencies:
'@types/node': 20.19.41
pg-protocol: 1.13.0
pg-types: 2.2.0
'@types/prop-types@15.7.15': {}
'@types/react-dom@18.3.7(@types/react@18.3.28)':
@@ -6817,6 +6954,8 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
node-addon-api@7.1.1: {}
node-domexception@1.0.0: {}
node-fetch@3.3.2:
@@ -6825,6 +6964,10 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
node-pty@1.1.0:
dependencies:
node-addon-api: 7.1.1
node-releases@2.0.44: {}
npm-run-path@4.0.1:
@@ -6935,6 +7078,41 @@ snapshots:
pathval@2.0.1: {}
pg-cloudflare@1.3.0:
optional: true
pg-connection-string@2.12.0: {}
pg-int8@1.0.1: {}
pg-pool@3.13.0(pg@8.20.0):
dependencies:
pg: 8.20.0
pg-protocol@1.13.0: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.1
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg@8.20.0:
dependencies:
pg-connection-string: 2.12.0
pg-pool: 3.13.0(pg@8.20.0)
pg-protocol: 1.13.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.3.0
pgpass@1.0.5:
dependencies:
split2: 4.2.0
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -6974,6 +7152,16 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postgres-array@2.0.0: {}
postgres-bytea@1.0.1: {}
postgres-date@1.0.7: {}
postgres-interval@1.2.0:
dependencies:
xtend: 4.0.2
postgres@3.4.9: {}
powershell-utils@0.1.0: {}
@@ -7782,6 +7970,22 @@ snapshots:
is-wsl: 3.1.1
powershell-utils: 0.1.0
xtend@4.0.2: {}
xterm-addon-fit@0.8.0(xterm@5.3.0):
dependencies:
xterm: 5.3.0
xterm-addon-search@0.13.0(xterm@5.3.0):
dependencies:
xterm: 5.3.0
xterm-addon-web-links@0.9.0(xterm@5.3.0):
dependencies:
xterm: 5.3.0
xterm@5.3.0: {}
y18n@5.0.8: {}
yallist@3.1.1: {}