Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
"@dnd-kit/sortable": "10.0.0",
"@react-three/drei": "10.7.8",
"@react-three/fiber": "9.7.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/xterm": "6.0.0",
"lucide-react": "1.42.0",
Expand Down
18 changes: 7 additions & 11 deletions client/src/hooks/useShellSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { useEffect, useRef, useState, useCallback } from 'react';
import useMounted from './useMounted';
import { useSearchParams, useParams, useNavigate } from 'react-router';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import '@xterm/xterm/css/xterm.css';
import { useSocket } from './useSocket';
import { useThemeContext } from '../components/ThemeContext';
import { buildTerminalTheme, parseCssColorToHex } from '../lib/terminalTheme';
import { attachDictationBridge } from '../lib/terminalDictation';
import { fitTerminal } from '../lib/terminalFit';
import {
attachTerminalTouchScroll,
attachTerminalWheelScroll,
Expand Down Expand Up @@ -107,7 +107,6 @@ export function useShellSession({ isFullscreen } = {}) {
const navigate = useNavigate();
const terminalRef = useRef(null);
const termInstanceRef = useRef(null);
const fitAddonRef = useRef(null);
const sessionIdRef = useRef(null);
const initialOptsRef = useRef(null);
const hasInitializedRef = useRef(false);
Expand Down Expand Up @@ -267,10 +266,8 @@ export function useShellSession({ isFullscreen } = {}) {
allowProposedApi: true
});

const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();

term.loadAddon(fitAddon);
term.loadAddon(webLinksAddon);

term.open(terminalRef.current);
Expand All @@ -286,18 +283,16 @@ export function useShellSession({ isFullscreen } = {}) {
const detachWheelScroll = attachTerminalWheelScroll(term);

requestAnimationFrame(() => {
fitAddon.fit();
fitTerminal(term);
});

termInstanceRef.current = term;
fitAddonRef.current = fitAddon;

return () => {
detachTouchScroll();
detachWheelScroll();
term.dispose();
termInstanceRef.current = null;
fitAddonRef.current = null;
};
}, []);

Expand All @@ -314,13 +309,14 @@ export function useShellSession({ isFullscreen } = {}) {

// Refit the terminal to its container and tell the PTY about the new size.
const refitTerminal = useCallback(() => {
if (!fitAddonRef.current || !termInstanceRef.current) return;
fitAddonRef.current.fit();
const term = termInstanceRef.current;
if (!term) return;
fitTerminal(term);
if (socket && sessionIdRef.current) {
socket.emit('shell:resize', {
sessionId: sessionIdRef.current,
cols: termInstanceRef.current.cols,
rows: termInstanceRef.current.rows
cols: term.cols,
rows: term.rows
});
}
}, [socket]);
Expand Down
1 change: 1 addition & 0 deletions client/src/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ grep -i "what you want to do" client/src/lib/README.md
| `tabNotation.js` | Guitar-tab / chord-sheet / ChordPro text parser for SongBook (`/songbook`). `parseTabSheet(text)` → `{ lines, meta, errors }` classifying each line (`section`/`chords`/`lyric`/`tabstaff`/`chordlyric`/`blank`/`text`) with chord names + column offsets, plus ChordPro meta (`{title:}`/`{artist:}`/`{key:}`/`{capo:}`); `detectFormat(text)` → `'chordpro' \| 'tab' \| 'plain'`; `normalizePastedTab(text)` cleans HTML remnants/entities, CRLF, tabs→spaces, blank-line runs; `transposeChordName(name, n)` / `transposeText(text, n)` transpose chord symbols (slash chords, sensible sharp/flat spelling, column-preserving on chord lines); `chordLineSegments`, `pairedLineChunks`, and `chordLyricRows` build aligned, word-wrapping chord/lyric display rows; `TAB_ARTICULATIONS` documents tab-staff technique characters for the viewer legend; `CHORD_TOKEN_RE` exported for reuse, plus `NOTE_TO_PC` / `spellPitchClass` exported minimally so `chordShapes.js` derives voicings from the same pitch-class tables. Forgiving — never throws, unknown lines classify as `text`. NOT the lead-sheet parser (`scoreNotation.js`). |
| `tailnetPeer.js` | `isTailnetPeer(peer)` — is this registered peer reachable ONLY over the tailnet? A faithful port of `server/lib/tailnetPeer.js` (MagicDNS `.ts.net`, the 100.64.0.0/10 CGNAT range with every octet range-checked, and the `fd7a:115c:a1e0::/48` ULA; an explicit non-tailnet `host` overrides a tailnet-looking `address`). FAIL-CLOSED — anything not positively recognized is not tailnet. Used to keep the unattended-render-routing picker from OFFERING a peer the server would refuse under ADR [federated visual prompts](../../../docs/decisions/2026-08-20-federated-visual-prompts.md) rule 5; the server stays authoritative and refuses such a route both on save and on every enqueue. Keep the table in `tailnetPeer.test.js` identical to the server suite’s. |
| `terminalDictation.js` | Voice-dictation/IME bridge for the Shell's xterm.js terminal. Apple dictation streams progressively refined guesses and *replaces* what it already typed; xterm forwards each insertion and drops the matching deletions, so the PTY accumulates a garble (`determin` + `determine` + `determines`…). `attachDictationBridge(terminal, sendData)` binds capture-phase listeners on `terminal.element` — ahead of xterm's own textarea listeners, which it stops — and forwards a diff instead: `TERMINAL_DEL` (`0x7f`) per dropped character, then the added text. `sendData` returns `false` to report a dropped send, which leaves the mirror where the PTY actually is. Paths that reach the PTY some other way (a keystroke xterm handles itself, paste, blur, composition end, screen-reader mode) resync the mirror without emitting. `planFieldEdit(mirror, next, floor)` → `{ data, committed }` is the pure core: `floor` is the prefix we did not write and must never rewind through, and `committed` (what the PTY holds afterward, which is *not* the field's value when the floor blocked a rewind) is what the caller must track. The prefix scan never splits a surrogate pair — cutting between the halves of an astral character would send a lone surrogate that serializes to `U+FFFD`. Wired in `useShellSession`; the test suite pins the seam against a real `Terminal`. |
| `terminalFit.js` | In-tree xterm sizing helper. `fitTerminal(terminal)` measures the parent and terminal CSS box, accounts for padding and the overview-ruler scrollbar, clamps the calculated geometry to at least 2 columns × 1 row, and resizes only when the dimensions change. |
| `terminalScroll.js` | Scrolling the Shell terminal by touch, wheel, and page controls. xterm 6 binds **no touch handlers at all**, and a TUI’s ALTERNATE screen buffer has no terminal scrollback, so `scrollLines()` clamps to a no-op there. Normal shell scrollback uses `scrollLines()`; alternate-screen wheel gestures are captured before xterm’s mouse listener and translated to standard PageUp/PageDown input, which OpenCode supports for its message viewport, while apps that explicitly enable terminal mouse tracking keep xterm’s native wheel path. `attachTerminalTouchScroll(terminal)` (→ detach fn) reserves one-finger panning while preserving pinch zoom and adds bounded flick momentum to normal shell scrollback; mouse-aware apps receive row-granular wheel events at the touch coordinates, and only apps without wheel support fall back to page keys. `attachTerminalWheelScroll(terminal)` handles native wheel input, and `scrollTerminalPage(terminal, direction)` powers the `SCROLL_KEYS` buttons. `measureTerminalGeometry(terminal)` takes one layout read per gesture; `planTouchScrollSteps(accumPx, rowHeightPx)` is the pure sub-row-remainder core. Wired in `useShellSession`. |
| `terminalTheme.js` | Pure xterm.js palette builder for the Shell terminal. `buildTerminalTheme({ bg, fg, accent, card, error, success, warning }, mode)` assembles the xterm `theme` object — base ANSI colors from the active theme's CSS vars, the rest from mode-tuned literals (`ANSI_NIGHT` bright/pastel for dark backgrounds, `ANSI_DAY` darkened/saturated so colored CLI output stays legible on daytime themes). `parseCssColorToHex(raw, fallback)` normalizes both the `15 15 15` triple form and the `rgb(7 7 7 / 0.86)` function form (used by `--port-terminal-*` tokens) to `#rrggbb`, dropping alpha. No DOM reads — `Shell.jsx` resolves CSS vars and re-applies on theme switch. |
| `textUtils.js` | `escapeRegExp(value)`, `countWords(text)`, `isStr(v)` and `isNonBlankStr(v)` re-exported from `server/lib/textUtils.js` — the two members the bundle has callers for (the Writers Room editor and exercise panel and the autobiography tab count words with the same `\S+` rule the server stores). Named exports rather than `export *`, so the file stays the list of what the browser actually uses. Also `pluralize(count, singular, pluralForm?)` — the shared "N item/items" phrasing helper for singular/plural counts. |
Expand Down
1 change: 1 addition & 0 deletions client/src/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ export * from './syncCounts.js';
export * from './tabNotation.js';
export * from './tailnetPeer.js';
export * from './terminalDictation.js';
export * from './terminalFit.js';
export * from './terminalScroll.js';
export * from './terminalTheme.js';
export * from './textUtils.js';
Expand Down
37 changes: 37 additions & 0 deletions client/src/lib/terminalFit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const MINIMUM_COLS = 2;
const MINIMUM_ROWS = 1;
const DEFAULT_SCROLLBAR_WIDTH = 14;

const cssPixels = (style, property) => Number.parseInt(style.getPropertyValue(property), 10);

/** Fit an xterm instance to its parent, preserving the addon's sizing contract. */
export const fitTerminal = (terminal) => {
const element = terminal?.element;
const parent = element?.parentElement;
const cell = terminal?._core?._renderService?.dimensions?.css?.cell;
if (!element || !parent || !(cell?.width > 0) || !(cell?.height > 0)
|| typeof window === 'undefined' || typeof window.getComputedStyle !== 'function') return;

const scrollbarWidth = terminal.options?.scrollback === 0
? 0
: terminal.options?.overviewRuler?.width || DEFAULT_SCROLLBAR_WIDTH;
const parentStyle = window.getComputedStyle(parent);
const elementStyle = window.getComputedStyle(element);
const parentHeight = cssPixels(parentStyle, 'height');
const parentWidth = Math.max(0, cssPixels(parentStyle, 'width'));
const paddingVertical = cssPixels(elementStyle, 'padding-top')
+ cssPixels(elementStyle, 'padding-bottom');
const paddingHorizontal = cssPixels(elementStyle, 'padding-right')
+ cssPixels(elementStyle, 'padding-left');
const cols = Math.max(MINIMUM_COLS, Math.floor(
(parentWidth - paddingHorizontal - scrollbarWidth) / cell.width,
));
const rows = Math.max(MINIMUM_ROWS, Math.floor(
(parentHeight - paddingVertical) / cell.height,
));
if (Number.isNaN(cols) || Number.isNaN(rows)
|| (terminal.cols === cols && terminal.rows === rows)) return;

terminal._core?._renderService?.clear?.();
terminal.resize(cols, rows);
};
112 changes: 112 additions & 0 deletions client/src/lib/terminalFit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fitTerminal } from './terminalFit.js';

const makeTerminal = ({
parentWidth = 820,
parentHeight = 500,
padding = { top: 5, bottom: 5, left: 10, right: 20 },
cell = { width: 10, height: 20 },
scrollback = 5000,
overviewRulerWidth = 18,
cols = 80,
rows = 24,
} = {}) => {
const parent = document.createElement('div');
const element = document.createElement('div');
parent.appendChild(element);
document.body.appendChild(parent);
const styles = new Map([
[parent, { height: parentHeight, width: parentWidth }],
[element, {
'padding-top': padding.top,
'padding-bottom': padding.bottom,
'padding-left': padding.left,
'padding-right': padding.right,
}],
]);
vi.spyOn(window, 'getComputedStyle').mockImplementation((target) => ({
getPropertyValue: (property) => `${styles.get(target)?.[property] ?? 0}px`,
}));

const terminal = {
element,
options: { scrollback, overviewRuler: { width: overviewRulerWidth } },
cols,
rows,
_core: {
_renderService: {
dimensions: { css: { cell } },
clear: vi.fn(),
},
},
resize: vi.fn((nextCols, nextRows) => {
terminal.cols = nextCols;
terminal.rows = nextRows;
}),
};
return terminal;
};

afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});

describe('fitTerminal', () => {
it('fits to the parent while accounting for padding and the overview ruler', () => {
const terminal = makeTerminal();

fitTerminal(terminal);

expect(terminal.resize).toHaveBeenCalledWith(77, 24);
expect(terminal._core._renderService.clear).toHaveBeenCalledOnce();
});

it('omits the scrollbar allowance when scrollback is disabled', () => {
const terminal = makeTerminal({
parentWidth: 35,
parentHeight: 21,
padding: { top: 0, bottom: 0, left: 0, right: 0 },
scrollback: 0,
cols: 80,
rows: 24,
});

fitTerminal(terminal);

expect(terminal.resize).toHaveBeenCalledWith(3, 1);
});

it('clamps dimensions to the minimum geometry', () => {
const terminal = makeTerminal({
parentWidth: 1,
parentHeight: 1,
padding: { top: 20, bottom: 20, left: 20, right: 20 },
cols: 80,
rows: 24,
});

fitTerminal(terminal);

expect(terminal.resize).toHaveBeenCalledWith(2, 1);
});

it('does not invalidate or resize when the geometry is unchanged', () => {
const terminal = makeTerminal({ cols: 77, rows: 24 });

fitTerminal(terminal);

expect(terminal.resize).not.toHaveBeenCalled();
expect(terminal._core._renderService.clear).not.toHaveBeenCalled();
});

it('is inert before the terminal has a parent or usable cell metrics', () => {
const terminal = makeTerminal({ cell: { width: 0, height: 20 } });
terminal.element.parentElement.removeChild(terminal.element);

fitTerminal(terminal);

expect(terminal.resize).not.toHaveBeenCalled();
expect(terminal._core._renderService.clear).not.toHaveBeenCalled();
});
});
2 changes: 1 addition & 1 deletion docs/DEPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Before removing a Tier 3 candidate, run a transitive-dep check (`npm ls <pkg>`).
| `@react-three/fiber` | 1 | KEEP | CyberCity 3D | React renderer for Three |
| `@scalar/api-reference-react` | — | REMOVED | Dev Tools → API Explorer | 2026-09-11 → native REST Reference tab (issue #7012). Was the heaviest client dep (~half the lockfile, 3.24 MB dist, Vue 3 + Vercel AI SDK). See detailed finding |
| `@xterm/xterm` | 1 | KEEP | browser terminal | |
| `@xterm/addon-fit` | 1 | KEEP | xterm sizing | |
| `@xterm/addon-fit` | | REMOVED | xterm sizing | 2026-09-12 → in-tree terminal sizing helper (issue #7014) |
| `@xterm/addon-web-links` | 1 | KEEP | xterm links | |
| `lucide-react` | 1 | KEEP | icons | Widely-used |
| `react` | 1 | KEEP | UI | |
Expand Down