mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
chore(web): remove the teammate terminal windows nothing can open
A subagent window could host an xterm attached to a teammate's tmux pane, but only for a teammate listed in teammatePanesByName, and the SSE listeners that filled that map were removed in February. Since then the branch could not be taken, and the code behind it was unreachable: - initTeammateTerminal and the lazy dispose / restore pair that parked a teammate terminal while its window was minimized or hidden; - the two maps (teammatePanesByName, teammateTerminals) and every loop over them: window close and reconnect cleanup, the live font-weight save and the skin switch; - the two early returns in renderSubagentWindowContent that protected a terminal which could not exist. Subagent windows render their activity list exactly as before, and a teammate still gets its name and badge from the agent description. The server side is untouched: TeamWatcher still holds respawn back while teammates are active, and the teammate-pane routes remain. Tests and docs that described "Agent Teams panes" now name what a font or skin change really reaches: the main terminal, the tiles and the split pane. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
259cabd738
commit
d44b049c51
9 files changed
+14
-344
No files matched your search
@@ -1123,7 +1123,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t
|
||||
|
||||
**Adding a skin means touching four places, and each one fails silently on its own**: (1) the `html[data-skin="…"]` token block in `styles.css`; (2) the xterm ANSI palette in `CODEMAN_XTERM_THEMES` (terminal-ui.js) — a missing entry falls back to `daylight-blue`, so the terminal simply stays the wrong color; (3) the pre-paint allowlist array in `index.html` — a skin missing there is coerced to `daylight-blue` on every reload, which reads as "my theme keeps resetting"; (4) the `<select>` in App Settings → Appearance → Theme. `test/skin-themes.test.ts` is the static four-way guard.
|
||||
|
||||
**Light skins carry three extra obligations.** `color-scheme: light` on the `html[data-skin]` block, or the UA renders native `<select>` popups, date pickers and scrollbars as dark widgets on a light page. xterm `minimumContrastRatio: 4.5` (set at construction in terminal-ui.js **and** panels-ui.js for teammate terminals, and re-applied in `applyTerminalSkin()`), because CLIs emit colors chosen for dark backgrounds; dark skins keep `1` to avoid the per-cell contrast work. And `applyTerminalSkin()` must call `this._localEchoOverlay?.refreshFont()` — the zero-lag overlay **caches** the terminal fg/bg at construction, so without the refresh typed-but-not-yet-flushed text keeps the previous theme's dark backing after a live skin switch.
|
||||
**Light skins carry three extra obligations.** `color-scheme: light` on the `html[data-skin]` block, or the UA renders native `<select>` popups, date pickers and scrollbars as dark widgets on a light page. xterm `minimumContrastRatio: 4.5` (set at construction in terminal-ui.js **and** terminal-tile.js, and re-applied in `applyTerminalSkin()`), because CLIs emit colors chosen for dark backgrounds; dark skins keep `1` to avoid the per-cell contrast work. And `applyTerminalSkin()` must call `this._localEchoOverlay?.refreshFont()` — the zero-lag overlay **caches** the terminal fg/bg at construction, so without the refresh typed-but-not-yet-flushed text keeps the previous theme's dark backing after a live skin switch.
|
||||
|
||||
**Shared surface tokens, not literals.** Elevated/floating surfaces resolve through `--floating-bg`, `--control-bg{,-hover}`, `--control-border{,-hover}`, `--banner-bg-a/b`, `--modal-backdrop` and `--elevated-shadow`, so modals, the command palette, dropdowns, subagent/ultracode windows, file previews and the mobile sheets follow the skin instead of hardcoded near-black rgba. ⚠️ Every skin that wants its own elevated look must override these — a skin that omits `--floating-bg` silently inherits the `:root` value, which is how the OG skin's near-black modals drifted slate. `styles.css` also defines **compatibility aliases** (`--bg-primary/secondary/tertiary`, `--text-primary/secondary`, `--border-color`, `--accent-color`, `--success`, `--error`, `--danger`, `--font-mono`, `--shadow-lg`) that forward to the canonical tokens; panels written against those names previously resolved to *nothing* (invalid at computed-value time), so the aliases are load-bearing, not cosmetic.
|
||||
|
||||
@@ -1255,7 +1255,7 @@ The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTime
|
||||
|
||||
⚠️ **The `@font-face` descriptor, not the file, is what the browser synthesizes from**: `fonts/jetbrains-mono-variable.woff2` carries a `wght` axis of 100-800, and while `styles.css` declared it `400 700` every weight below 400 rendered identically to 400 and 800 identically to 700 — measured — so the setting was a no-op for anyone without Fira Code or Cascadia Code installed, which is most installs. It is declared `100 800`; re-narrowing it silently guts the feature (`test/terminal-font-weight.test.ts` pins the range).
|
||||
|
||||
⚠️ A live save must reach **both echo overlays** (`refreshFont()` — they cache `terminal.options.fontWeight` and paint it into their spans, so typed characters otherwise keep the old weight, most visible on a phone) **and open Agent Teams panes** (they read their options at construction, exactly like `applyTerminalSkin()` propagates).
|
||||
⚠️ A live save must reach **both echo overlays** (`refreshFont()` — they cache `terminal.options.fontWeight` and paint it into their spans, so typed characters otherwise keep the old weight, most visible on a phone) **and every open tile and split pane** (`_forEachTile`: they read their options at construction, exactly like `applyTerminalSkin()` propagates).
|
||||
|
||||
⚠️ `_awaitTerminalFont()` is deliberately untouched: `CharSizeService` measures through the CSS `font` shorthand, which RESETS the weight, so the measured face is always the 400 one and a weighted descriptor would ask for nothing new.
|
||||
|
||||
@@ -1321,7 +1321,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
||||
|
||||
### PTY and browser terminal geometry
|
||||
|
||||
**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY** (one documented exception, the keyboard row hold in the next paragraph). Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px Line truncated
|
||||
**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and the tiles own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY** (one documented exception, the keyboard row hold in the next paragraph). Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px swipe reaLine truncated
|
||||
|
||||
**The one exception to COLUMNS ONLY: rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): every PTY height change makes Claude Code's inline renderer clear the screen and reprint only the transcript tail that fits the new height, so the rows above that tail are gone from tmux and older ones stay in history as stale copies, and the phone keyboard opening and closing was such a change each time. **Measured** on Claude Code 2.1.306 with tmux 3.2a, a fresh session already at the phone's size printing 100 numbered lines: five keyboard cycles (PTY 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in `tmux capture-pane` history; with the rows held the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained. So `KeyboardHandler` captures `terminal.rows` on the show transition (`app._keyboardHeldRows`) and `getTerminalDimensions()` never reports fewer rows while the keyboard is visible; columns still follow the container, so the #464 width rule is untouched. ⚠️ **Why this is not the regression the COLUMNS ONLY note describes.** There the extra rows came from ANOTHER device's sizing claim, adopted from `ptyGeometry`, and nothing kept the composer in view: an `.xterm-screen` taller than its container with the input line below it and no gesture able to reach it. Here the rows are this device's own keyboard-free rows, held on purpose only while the keyboard is up, and the screen is translated up by `--kb-clip` (`_applyKeyboardClip()`, pure math in `keyboardClipPx()` in constants.js) so the last content row sits at the bottom of the visible box: following live output, the cursor counts only within `KEYBOARD_CLIP_CURSOR_REACH` (2) rows below the last content (a TUI that parks its cursor far below what it drew must not scroll its content away); reading history, the offset follows the scroll position so the top of the scrollback stays reachable. The local-echo overlay is bounded to the visible rows through `setRowLimit()`, which narrows the overlay renderer's existing `totalRows` bound rather than adding a second one, so a long draft grows upward from the last row the user can see. `reconcilePtyGeometry` still adopts columns only: nothing here takes rows from the PTY's report. `test/keyboard-held-rows.test.ts` pins the composer row inside the visible box with the rows held, against a real headless xterm and the shipped `_applyKeyboardClip()`. ⚠️ Holding goes through `getTerminalDimensions()`, never one caller, so the fit, the PTY resize and the capture-geometry comparisons agree; `syncTerminalGeometry()` skips `fitAddon.fit()` while held, so xterm never visits the short size. ⚠️ A shape change (rotation, fold) releases the hold AND arms the viewport settle (`_scheduleViewportSettle()`), which refits and tells the PTY: with the keyboard still up `throttledResize` stands aside and a rotation alone arms nothing, so without it xterm kept the held rows with no clip and the composer sat under the keyboard until it closed. A rotation with no hold arms nothing, as before. ⚠️ ONLY inline renderers qualify (`_keyboardHoldEligible()`): the registry's `capabilities.keyboardHold: 'inline'` (Claude alone; read through `window.__codemanKeyboardHoldModes`, never an id literal), never while `cliMouseTracking` reports Claude's fullscreen mode (it lays out the full height and has not been measured losing lines, so it resizes as before), never remote or docker (their local pane hosts a nested client). A full-height layout (codex: banner at the top, composer pinned to the bottom) cannot fit both ends above the keyboard, which is what the first, unscoped version got wrong. ⚠️ The hold lives on the shared terminal, so `_reconcileKeyboardHold()` releases it whenever the active session stops qualifying (tab switch, a `session:updated`, a reconnect, the setting turned off), and keyboard close releases it too; it never re-holds with the keyboard already up, so a session that becomes eligible mid-keyboard keeps the short rows until the keyboard closes.
|
||||
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. The one opt-in exception is File Viewer (#759): `mobile.css` no longer hides it at phone width, so it stays off a phone only through the handheld default in `getDefaultSettings()` and the `html.mobile-init` startup rule (both pinned by that test), and shows once the setting is switched on on that phone. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](../../docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
|
||||
|
||||
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and open Agent Teams panes. ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](../../docs/architecture-invariants.md#terminal-font-weight)
|
||||
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and every open tile and split pane (`_forEachTile`). ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](../../docs/architecture-invariants.md#terminal-font-weight)
|
||||
|
||||
**Theme skins / branding / i18n**: `skin` selects a palette via `data-skin` on `<html>`, applied by an **inline pre-paint script** in `index.html` reading `localStorage['codeman:skin']` to avoid a flash of wrong theme. ⚠️ A skin is **four things that must stay in sync**, and missing any one degrades silently: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist, and the Settings picker (both in `index.html`). `test/skin-themes.test.ts` is the static guard. Light skins additionally need `color-scheme: light` and xterm `minimumContrastRatio: 4.5`, and `applyTerminalSkin()` must call the local-echo overlay's `refreshFont()` because it caches the terminal fg/bg. `displayName` changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. `language` (`en`/`zh-CN`) keeps English as the canonical source so live switching stays reversible. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. → [architecture-invariants#theme-skins](../../docs/architecture-invariants.md#theme-skins)
|
||||
|
||||
|
||||
@@ -855,10 +855,6 @@ class CodemanApp {
|
||||
// Once set, never recalculated. Persisted to localStorage and server.
|
||||
this.subagentParentMap = new Map();
|
||||
|
||||
// Teammate tmux pane terminals (Agent Teams feature)
|
||||
this.teammatePanesByName = new Map(); // Map<name, { paneTarget, sessionId, color }>
|
||||
this.teammateTerminals = new Map(); // Map<agentId, { terminal, fitAddon, paneTarget, sessionId, resizeObserver }>
|
||||
|
||||
this.terminalBufferCache = new Map(); // Map<sessionId, string> — client-side cache for instant tab re-visits (max 20)
|
||||
|
||||
this.ralphStatePanelCollapsed = true; // Default to collapsed
|
||||
|
||||
+2
-160
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @fileoverview Subagent panel (discovery, detail view, kill), subagent parent tracking,
|
||||
* agent teams (tasks panel, teammate badges, terminals), project insights (bash tool tracking),
|
||||
* agent teams (teammate badges), project insights (bash tool tracking),
|
||||
* file browser (directory tree, preview), log viewer (floating file streamers),
|
||||
* image popups (auto-popup for screenshots), mux sessions, monitor panel,
|
||||
* token statistics, toast notifications, and system stats.
|
||||
@@ -2135,16 +2135,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Show window (unless it was minimized by user)
|
||||
if (!windowInfo.minimized) {
|
||||
windowInfo.element.style.display = 'flex';
|
||||
// Lazily re-create teammate terminal if it was disposed when hidden
|
||||
if (windowInfo._lazyTerminal) {
|
||||
this._restoreTeammateTerminalFromLazy(agentId);
|
||||
}
|
||||
}
|
||||
windowInfo.hidden = false;
|
||||
} else {
|
||||
// Hide window (but don't close it)
|
||||
// Dispose teammate terminal to free memory while hidden on inactive tab
|
||||
this._disposeTeammateTerminalForMinimize(agentId);
|
||||
windowInfo.element.style.display = 'none';
|
||||
windowInfo.hidden = true;
|
||||
}
|
||||
@@ -2210,23 +2204,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
windowData.element.remove();
|
||||
this.subagentWindows.delete(agentId);
|
||||
}
|
||||
// Clean up teammate terminal if present
|
||||
const termData = this.teammateTerminals.get(agentId);
|
||||
if (termData) {
|
||||
if (termData.resizeObserver) {
|
||||
termData.resizeObserver.disconnect();
|
||||
}
|
||||
if (termData.terminal) {
|
||||
try { termData.terminal.dispose(); } catch {}
|
||||
}
|
||||
this.teammateTerminals.delete(agentId);
|
||||
}
|
||||
},
|
||||
|
||||
// Debounced wrapper — coalesces rapid subagent events (tool_call, progress,
|
||||
// message) into a single DOM update per 100ms per agent window.
|
||||
scheduleSubagentWindowRender(agentId) {
|
||||
// Skip DOM updates for windows with lazy (disposed) terminals — they're minimized
|
||||
// Skip DOM updates for minimized windows
|
||||
const windowData = this.subagentWindows.get(agentId);
|
||||
if (windowData?.minimized) return;
|
||||
|
||||
@@ -2241,12 +2224,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
renderSubagentWindowContent(agentId) {
|
||||
// Skip if this window has a live terminal (don't overwrite xterm with activity HTML)
|
||||
if (this.teammateTerminals.has(agentId)) return;
|
||||
// Skip if this window has a lazy (disposed) terminal — it will be re-created on restore
|
||||
const windowData = this.subagentWindows.get(agentId);
|
||||
if (windowData?._lazyTerminal) return;
|
||||
|
||||
const body = document.getElementById(`subagent-window-body-${agentId}`);
|
||||
if (!body) return;
|
||||
|
||||
@@ -2399,141 +2376,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Agent Teams
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Initialize an xterm.js terminal for a teammate's tmux pane */
|
||||
initTeammateTerminal(agentId, paneInfo, windowElement) {
|
||||
const body = windowElement.querySelector('.subagent-window-body');
|
||||
if (!body) return;
|
||||
|
||||
// Clear the activity log content
|
||||
body.innerHTML = '';
|
||||
body.classList.add('teammate-terminal-body');
|
||||
windowElement.classList.add('has-terminal');
|
||||
|
||||
const sessionId = paneInfo.sessionId;
|
||||
|
||||
// Buffer incoming terminal data until xterm is ready
|
||||
const pendingData = [];
|
||||
this.teammateTerminals.set(agentId, {
|
||||
terminal: null,
|
||||
fitAddon: null,
|
||||
paneTarget: paneInfo.paneTarget,
|
||||
sessionId,
|
||||
resizeObserver: null,
|
||||
pendingData,
|
||||
});
|
||||
|
||||
// Defer terminal creation to next frame so the body element has computed dimensions
|
||||
requestAnimationFrame(() => {
|
||||
// Safety: if window was closed before we got here, bail out
|
||||
if (!document.contains(body)) {
|
||||
this.teammateTerminals.delete(agentId);
|
||||
return;
|
||||
}
|
||||
|
||||
const fontSettings = this.loadAppSettingsFromStorage?.() || {};
|
||||
const terminal = new Terminal({
|
||||
theme: { ...window.codemanCurrentXtermTheme() },
|
||||
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||
fontFamily: window.CodemanTerminalFont.resolve(fontSettings.terminalFontFamily),
|
||||
// A pane opened after a weight change must match the main terminal;
|
||||
// one open across the change is repainted by applyTerminalFontWeights().
|
||||
...window.CodemanTerminalFont.resolveWeights(fontSettings),
|
||||
fontSize: 12,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
scrollback: DEFAULT_SCROLLBACK,
|
||||
allowTransparency: true,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
|
||||
if (typeof Unicode11Addon !== 'undefined') {
|
||||
try {
|
||||
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
|
||||
terminal.loadAddon(unicode11Addon);
|
||||
terminal.unicode.activeVersion = '11';
|
||||
} catch (_e) { /* Unicode11 addon failed */ }
|
||||
}
|
||||
|
||||
try {
|
||||
terminal.open(body);
|
||||
} catch (err) {
|
||||
console.warn('[TeammateTerminal] Failed to open terminal:', err);
|
||||
this.teammateTerminals.delete(agentId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Z (SIGTSTP/job-control suspend): a teammate/subagent pane is always
|
||||
// running an agent CLI (Task-tool dispatched, never a plain shell), so
|
||||
// unlike the main terminal's mode-gated trap this one is unconditional.
|
||||
// Mirrors the main terminal's guard in terminal-ui.js's
|
||||
// attachCustomKeyEventHandler — case-insensitive so Caps Lock (which
|
||||
// flips ev.key to 'Z' without setting shiftKey) can't slip a suspend past it.
|
||||
terminal.attachCustomKeyEventHandler((ev) => {
|
||||
if (
|
||||
ev.type === 'keydown' &&
|
||||
ev.key.toLowerCase() === 'z' &&
|
||||
ev.ctrlKey &&
|
||||
!ev.altKey &&
|
||||
!ev.metaKey &&
|
||||
!ev.shiftKey
|
||||
) {
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Wait for terminal renderer to fully initialize before any writes.
|
||||
// xterm.js needs a few frames after open() before write() is safe.
|
||||
setTimeout(() => {
|
||||
try { fitAddon.fit(); } catch {}
|
||||
|
||||
// Fetch initial pane buffer
|
||||
fetch(`/api/sessions/${sessionId}/teammate-pane-buffer/${encodeURIComponent(paneInfo.paneTarget)}`)
|
||||
.then(r => r.json())
|
||||
.then(resp => {
|
||||
if (resp.success && resp.data?.buffer) {
|
||||
try { terminal.write(resp.data.buffer); } catch {}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[TeammateTerminal] Failed to fetch buffer:', err));
|
||||
|
||||
// Flush any data that arrived while terminal was initializing
|
||||
for (const chunk of pendingData) {
|
||||
try { terminal.write(chunk); } catch {}
|
||||
}
|
||||
pendingData.length = 0;
|
||||
}, 100);
|
||||
|
||||
// Forward keyboard input to the teammate's pane
|
||||
terminal.onData((data) => {
|
||||
fetch(`/api/sessions/${sessionId}/teammate-pane-input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paneTarget: paneInfo.paneTarget, input: data }),
|
||||
}).catch(err => console.error('[TeammateTerminal] Failed to send input:', err));
|
||||
});
|
||||
|
||||
// Resize observer to refit terminal when window is resized
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(() => { try { fitAddon.fit(); } catch {} });
|
||||
});
|
||||
resizeObserver.observe(body);
|
||||
|
||||
// Update the stored entry with the real terminal
|
||||
const entry = this.teammateTerminals.get(agentId);
|
||||
if (entry) {
|
||||
entry.terminal = terminal;
|
||||
entry.fitAddon = fitAddon;
|
||||
entry.resizeObserver = resizeObserver;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/** Check if a subagent is a teammate and return its info */
|
||||
getTeammateInfo(agent) {
|
||||
if (!agent?.description) return null;
|
||||
|
||||
@@ -328,83 +328,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._applyLineEntrances?.(svg);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Lazy Terminal Lifecycle
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Teammate terminal windows use xterm.js Terminal instances that consume
|
||||
// ~75KB of DOM memory each. With 50 agents minimized, that's ~3.75MB of
|
||||
// invisible terminals. To avoid this, we dispose the Terminal when a
|
||||
// window is minimized and lazily re-create it when restored.
|
||||
//
|
||||
// Flow:
|
||||
// minimize → _disposeTeammateTerminalForMinimize() → sets _lazyTerminal flag
|
||||
// restore → _restoreTeammateTerminalFromLazy() → re-creates Terminal
|
||||
// create (hidden/minimized) → skip initTeammateTerminal, set _lazyTerminal
|
||||
//
|
||||
// The tmux pane buffer is re-fetched from the API on restore. Regular
|
||||
// (non-teammate) subagent windows use activity HTML and are unaffected
|
||||
// by this optimization.
|
||||
|
||||
/**
|
||||
* Dispose a teammate terminal when its window is minimized.
|
||||
* Saves pane metadata so the terminal can be re-created on restore.
|
||||
* No-op if the window has no teammate terminal.
|
||||
*/
|
||||
_disposeTeammateTerminalForMinimize(agentId) {
|
||||
const termData = this.teammateTerminals.get(agentId);
|
||||
if (!termData) return; // Not a teammate terminal window
|
||||
|
||||
const windowData = this.subagentWindows.get(agentId);
|
||||
|
||||
// Save pane metadata needed to re-create the terminal on restore
|
||||
if (windowData) {
|
||||
windowData._lazyTerminal = true;
|
||||
windowData._lazyPaneTarget = termData.paneTarget;
|
||||
windowData._lazySessionId = termData.sessionId;
|
||||
}
|
||||
|
||||
// Dispose the resize observer
|
||||
if (termData.resizeObserver) {
|
||||
termData.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
// Dispose the xterm.js Terminal instance (frees DOM nodes and internal buffers)
|
||||
if (termData.terminal) {
|
||||
try {
|
||||
termData.terminal.dispose();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Remove from teammateTerminals map so renderSubagentWindowContent won't skip this window
|
||||
// (the activity HTML can serve as a lightweight placeholder while minimized)
|
||||
this.teammateTerminals.delete(agentId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-create a teammate terminal when its window is restored from minimized state.
|
||||
* Fetches the current pane buffer from the API (tmux is the source of truth).
|
||||
* No-op if the window doesn't have the _lazyTerminal flag.
|
||||
*/
|
||||
_restoreTeammateTerminalFromLazy(agentId) {
|
||||
const windowData = this.subagentWindows.get(agentId);
|
||||
if (!windowData || !windowData._lazyTerminal) return;
|
||||
|
||||
const paneTarget = windowData._lazyPaneTarget;
|
||||
const sessionId = windowData._lazySessionId;
|
||||
|
||||
// Clear lazy state
|
||||
windowData._lazyTerminal = false;
|
||||
windowData._lazyPaneTarget = null;
|
||||
windowData._lazySessionId = null;
|
||||
|
||||
if (!paneTarget || !sessionId) return;
|
||||
|
||||
// Re-create the terminal using the same initTeammateTerminal flow
|
||||
const paneInfo = { paneTarget, sessionId };
|
||||
this.initTeammateTerminal(agentId, paneInfo, windowData.element);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Subagent Floating Windows
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -612,24 +535,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
win.style.display = 'none';
|
||||
}
|
||||
|
||||
// Render content — check if this teammate has a tmux pane
|
||||
const paneInfo = teammateInfo ? this.teammatePanesByName.get(teammateInfo.name) : null;
|
||||
if (paneInfo) {
|
||||
if (shouldHide) {
|
||||
// Window starts hidden — defer terminal creation until visible (lazy init).
|
||||
// Saves ~75KB of DOM memory per hidden teammate terminal window.
|
||||
const windowEntry = this.subagentWindows.get(agentId);
|
||||
if (windowEntry) {
|
||||
windowEntry._lazyTerminal = true;
|
||||
windowEntry._lazyPaneTarget = paneInfo.paneTarget;
|
||||
windowEntry._lazySessionId = paneInfo.sessionId;
|
||||
}
|
||||
} else {
|
||||
this.initTeammateTerminal(agentId, paneInfo, win);
|
||||
}
|
||||
} else {
|
||||
this.renderSubagentWindowContent(agentId);
|
||||
}
|
||||
this.renderSubagentWindowContent(agentId);
|
||||
|
||||
// Focus on click
|
||||
win.addEventListener('mousedown', () => {
|
||||
@@ -701,10 +607,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.setAgentParentSessionId(agentId, parentSessionId);
|
||||
}
|
||||
|
||||
// Dispose teammate terminal on minimize to free DOM/memory (~75KB per instance).
|
||||
// The terminal will be lazily re-created on restore via initTeammateTerminal().
|
||||
this._disposeTeammateTerminalForMinimize(agentId);
|
||||
|
||||
// Always minimize to tab
|
||||
windowData.element.style.display = 'none';
|
||||
windowData.minimized = true;
|
||||
@@ -775,18 +677,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
this.subagentWindows.clear();
|
||||
|
||||
// Clean up all teammate terminals
|
||||
for (const [, termData] of this.teammateTerminals) {
|
||||
if (termData.resizeObserver) termData.resizeObserver.disconnect();
|
||||
if (termData.terminal) {
|
||||
try {
|
||||
termData.terminal.dispose();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
this.teammateTerminals.clear();
|
||||
this.teammatePanesByName.clear();
|
||||
|
||||
// Clean up all log viewer windows with their EventSources and drag listeners
|
||||
for (const data of this.logViewerWindows.values()) {
|
||||
if (data.eventSource) {
|
||||
@@ -868,12 +758,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
windowData.minimized = false;
|
||||
|
||||
// Lazily re-create teammate terminal if it was disposed on minimize.
|
||||
// Only re-create when the window is actually becoming visible.
|
||||
if (shouldShow && windowData._lazyTerminal) {
|
||||
this._restoreTeammateTerminalFromLazy(agentId);
|
||||
}
|
||||
|
||||
this.updateConnectionLines();
|
||||
// Restack all visible mobile windows so restored ones don't overlap
|
||||
this.relayoutMobileSubagentWindows();
|
||||
|
||||
@@ -428,8 +428,8 @@
|
||||
|
||||
// Per-skin xterm.js palettes. The 'daylight-blue' object equals the legacy hardcoded
|
||||
// theme, so default behavior is unchanged. Shared at module scope and exported on the
|
||||
// global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals,
|
||||
// a separate IIFE) can read the current skin's palette.
|
||||
// global so the main terminal and every other module that builds a terminal (tiles,
|
||||
// the split pane) can read the current skin's palette.
|
||||
//
|
||||
// ⚠️ The selection key is `selectionBackground`, xterm's name for it since v5 (#360).
|
||||
// An ITheme is a plain object handed straight to xterm, so an unknown key is not an
|
||||
@@ -6585,16 +6585,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._refitAfterCellSizeChange();
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
for (const [, entry] of this.teammateTerminals || []) {
|
||||
if (!entry?.terminal) continue;
|
||||
entry.terminal.options.fontWeight = fontWeight;
|
||||
entry.terminal.options.fontWeightBold = fontWeightBold;
|
||||
try {
|
||||
entry.fitAddon?.fit();
|
||||
} catch {
|
||||
/* pane not laid out yet — its own resize observer refits it */
|
||||
}
|
||||
}
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.fontWeight = fontWeight;
|
||||
tile.terminal.options.fontWeightBold = fontWeightBold;
|
||||
@@ -7158,7 +7148,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}, 100);
|
||||
},
|
||||
|
||||
// Re-theme all live xterm terminals (main + teammate) to the given skin's palette.
|
||||
// Re-theme all live xterm terminals (main pane and tiles) to the given skin's palette.
|
||||
// Uses the xterm v5+ live setter (full object assignment triggers a repaint for both
|
||||
// DOM and WebGL renderers) plus a belt-and-suspenders refresh().
|
||||
applyTerminalSkin(skin) {
|
||||
@@ -7176,17 +7166,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.refresh(0, this.terminal.rows - 1);
|
||||
} catch {}
|
||||
}
|
||||
if (this.teammateTerminals) {
|
||||
for (const [, entry] of this.teammateTerminals) {
|
||||
if (entry && entry.terminal) {
|
||||
entry.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
entry.terminal.options.theme = { ...theme };
|
||||
try {
|
||||
entry.terminal.refresh(0, entry.terminal.rows - 1);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
tile.terminal.options.theme = { ...theme };
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* only session:terminal is filtered by subscription)
|
||||
* - Tab switching: terminal buffer loading, session creation + switch
|
||||
* - Terminal data cap / backpressure recovery
|
||||
* - Lazy teammate terminal lifecycle
|
||||
* - SSE padding strategy (only latency-sensitive events)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
|
||||
@@ -138,23 +138,19 @@ describe('Codeman light skins', () => {
|
||||
|
||||
it('switches live terminals between light and dark contrast policies', () => {
|
||||
const main = { options: {} as Record<string, unknown>, rows: 24, refresh: vi.fn() };
|
||||
const teammate = { options: {} as Record<string, unknown>, rows: 12, refresh: vi.fn() };
|
||||
const refreshFont = vi.fn();
|
||||
const app = {
|
||||
terminal: main,
|
||||
teammateTerminals: new Map([['agent-1', { terminal: teammate }]]),
|
||||
_localEchoOverlay: { refreshFont },
|
||||
};
|
||||
|
||||
terminal.mixin.applyTerminalSkin.call(app, 'paper-gray');
|
||||
expect(main.options.minimumContrastRatio).toBe(4.5);
|
||||
expect(teammate.options.minimumContrastRatio).toBe(4.5);
|
||||
expect((main.options.theme as Record<string, string>).background).toBe('#f6f8fa');
|
||||
expect(refreshFont).toHaveBeenCalledTimes(1);
|
||||
|
||||
terminal.mixin.applyTerminalSkin.call(app, 'daylight-blue');
|
||||
expect(main.options.minimumContrastRatio).toBe(1);
|
||||
expect(teammate.options.minimumContrastRatio).toBe(1);
|
||||
expect((main.options.theme as Record<string, string>).background).toBe('#161b23');
|
||||
expect(refreshFont).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -11,11 +11,9 @@
|
||||
*
|
||||
* Three things are pinned here because each fails silently:
|
||||
*
|
||||
* - A live save reaches the echo overlays and the Agent Teams panes. Both
|
||||
* cache the weight (the overlays paint it into their spans, the panes read
|
||||
* their options at construction), so without the propagation the characters
|
||||
* being typed, or a pane left open across the save, keep the old weight
|
||||
* beside a repainted terminal.
|
||||
* - A live save reaches the echo overlays. They cache the weight and paint
|
||||
* it into their spans, so without the propagation the characters being
|
||||
* typed keep the old weight beside a repainted terminal.
|
||||
* - An unchanged save is a no-op, so opening and closing App Settings does not
|
||||
* churn the terminal.
|
||||
* - The bundled face is declared over its full axis. The `@font-face`
|
||||
@@ -65,19 +63,11 @@ function fakeTerminal(options: Record<string, unknown> = {}) {
|
||||
|
||||
function makeApp(
|
||||
opts: {
|
||||
teammates?: number;
|
||||
terminal?: ReturnType<typeof fakeTerminal> | null;
|
||||
splitPane?: { terminal: ReturnType<typeof fakeTerminal>; fit: () => void } | null;
|
||||
} = {}
|
||||
) {
|
||||
const fit = vi.fn();
|
||||
const teammateFits: ReturnType<typeof vi.fn>[] = [];
|
||||
const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>();
|
||||
for (let i = 0; i < (opts.teammates ?? 0); i++) {
|
||||
const teammateFit = vi.fn();
|
||||
teammateFits.push(teammateFit);
|
||||
teammateTerminals.set(`agent-${i}`, { terminal: fakeTerminal(), fitAddon: { fit: teammateFit } });
|
||||
}
|
||||
const app = {
|
||||
applyTerminalFontWeights: mixin.applyTerminalFontWeights,
|
||||
// Secondary panes (the split pane's second terminal) are reached through
|
||||
@@ -99,12 +89,11 @@ function makeApp(
|
||||
_awaitTerminalFont: vi.fn(() => Promise.resolve()),
|
||||
terminal: opts.terminal === undefined ? fakeTerminal() : opts.terminal,
|
||||
fitAddon: { fit },
|
||||
teammateTerminals,
|
||||
_localEchoOverlay: { refreshFont: vi.fn() },
|
||||
_predictiveEcho: { refreshFont: vi.fn() },
|
||||
_terminalFontReady: null as unknown,
|
||||
};
|
||||
return { app, fit, teammateFits, teammateTerminals };
|
||||
return { app, fit };
|
||||
}
|
||||
|
||||
describe('applyTerminalFontWeights', () => {
|
||||
@@ -152,21 +141,6 @@ describe('applyTerminalFontWeights', () => {
|
||||
expect(app._predictiveEcho.refreshFont).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reaches Agent Teams panes, which read their options at construction', () => {
|
||||
const { app, teammateTerminals, teammateFits } = makeApp({ teammates: 2 });
|
||||
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||
terminalFontWeight: '300',
|
||||
terminalFontWeightBold: '800',
|
||||
});
|
||||
|
||||
for (const [, entry] of teammateTerminals) {
|
||||
expect(entry.terminal.options.fontWeight).toBe(300);
|
||||
expect(entry.terminal.options.fontWeightBold).toBe(800);
|
||||
}
|
||||
for (const teammateFit of teammateFits) expect(teammateFit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores xterm’s own defaults when the setting is cleared', () => {
|
||||
const { app } = makeApp({ terminal: fakeTerminal({ fontWeight: 300, fontWeightBold: 800 }) });
|
||||
|
||||
|
||||
Reference in new issue
Block a user