mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edeaa15986 | ||
|
|
d2ff1814ed | ||
|
|
9e2091255b | ||
|
|
6030a520bd | ||
|
|
465b842e97 | ||
|
|
c4b74415ee | ||
|
|
d8a9e2f2bb | ||
|
|
708cb2cbf0 |
@@ -1,5 +1,38 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 1.28.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 708cb2c: fix(tabs): let a wrapped desktop tab strip grow the header instead of clipping itself
|
||||||
|
|
||||||
|
The wrapped tab strip carried fixed height caps (120px for the manual two-row layout,
|
||||||
|
96px for measured auto-wrap) that were row counts in disguise. A third row of tabs was
|
||||||
|
clipped into a roughly 4px scroller, so the tab being looked for sat off-screen inside a
|
||||||
|
container nothing invites you to scroll, while the header had the whole page below it to
|
||||||
|
grow into. The header is `min-height` plus `flex-shrink: 0`, and terminal-ui's
|
||||||
|
ResizeObserver refits the terminal on its own, so growing it costs nothing.
|
||||||
|
|
||||||
|
Both wrapped layouts now share one rule capped at `var(--tab-strip-max-height, 40vh)`.
|
||||||
|
That cap is a safety net for an absurd session count rather than a row limit: past it the
|
||||||
|
scroller comes back, which still beats a header that swallows the terminal. Nothing sets
|
||||||
|
`--tab-strip-max-height` yet, so today it is the 40vh fallback plus a hook for a future
|
||||||
|
control.
|
||||||
|
|
||||||
|
Desktop only in effect. `tabs-auto-wrap` is applied by `updateTabOverflowMode()`, which
|
||||||
|
returns early for anything that is not a desktop viewport, and below 1024px `mobile.css`
|
||||||
|
pins the header to `max-height: 48px` so it cannot grow at all. The two rules are
|
||||||
|
comma-grouped rather than wrapped in `:is()`, so each arm keeps its own (0,2,0)
|
||||||
|
specificity and `mobile.css`'s matching overrides still win on source order.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
1.28.1 is a same-day follow-on to 1.28.0, so the thanks for this pair belong here too:
|
||||||
|
|
||||||
|
- **@shenlvkang-collab** for the path picker's typed-path jump and name/date sort (#399), and for the care in the edges: the retry is bounded to one parent level, a typo keeps the listing you had instead of resetting to the root, and a full file path lands in its folder with the entry already selected.
|
||||||
|
- **@irisitymichaelgrundberg** for Claude truecolor in panes (#409), and above all for flagging the one reading they could not prove: that suppressing truecolor may have made Claude's block collapse into the background rather than fixing anything. That paragraph is why this got measured instead of taken on trust, and the measurement changed the changelog.
|
||||||
|
- **@timkjr** for trapping Ctrl+Z in agent sessions (#404), for finding that Caps Lock flips `ev.key` to `'Z'` without setting `shiftKey` so a plain `=== 'z'` check misses exactly the keystroke the guard exists for, and for stating up front that an agent CLI already holds its tty with ISIG off rather than overselling the fix.
|
||||||
|
|
||||||
## 1.28.0
|
## 1.28.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
@@ -72,6 +105,11 @@
|
|||||||
`terminal-overrides ",*:Tc"` on its own tmux server, so 24-bit color already reaches the
|
`terminal-overrides ",*:Tc"` on its own tmux server, so 24-bit color already reaches the
|
||||||
browser for the CLIs that ask for it.
|
browser for the CLIs that ask for it.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
- **@shenlvkang-collab** for the path picker's typed-path jump and name/date sort (#399), and for the care in the edges: the retry is bounded to one parent level, a typo keeps the listing you had instead of resetting to the root, and a full file path lands in its folder with the entry already selected.
|
||||||
|
- **@irisitymichaelgrundberg** for Claude truecolor in panes (#409), and above all for flagging the one reading they could not prove: that suppressing truecolor may have made Claude's block collapse into the background rather than fixing anything. That paragraph is why this got measured instead of taken on trust, and the measurement changed the changelog.
|
||||||
|
- **@timkjr** for trapping Ctrl+Z in agent sessions (#404), for finding that Caps Lock flips `ev.key` to `'Z'` without setting `shiftKey` so a plain `=== 'z'` check misses exactly the keystroke the guard exists for, and for stating up front that an agent CLI already holds its tty with ISIG off rather than overselling the fix.
|
||||||
|
|
||||||
## 1.27.0
|
## 1.27.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
@@ -168,6 +206,15 @@
|
|||||||
and nothing ever deleted them (236 orphans on a working machine); the sweep keeps
|
and nothing ever deleted them (236 orphans on a working machine); the sweep keeps
|
||||||
every live session's file and only takes orphans older than seven days.
|
every live session's file and only takes orphans older than seven days.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
1.26.0 carries no contributor PRs of its own. It lands the day after 1.25.0, so the thanks for that pair belong here too:
|
||||||
|
|
||||||
|
- @mtiller for the reverse-proxy base URL (#381).
|
||||||
|
- @dignfei for attaching cases to running containers (#357).
|
||||||
|
- @shenlvkang-collab for the response viewer fix (#369), the first-hand conversation hook (#367) and the phone Add Case fix (#368).
|
||||||
|
- @opticon454 for the case picker default (#383).
|
||||||
|
|
||||||
## 1.25.0
|
## 1.25.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
@@ -261,6 +308,12 @@
|
|||||||
case, which without the plugin falls back to the classic builder Docker has deprecated.
|
case, which without the plugin falls back to the classic builder Docker has deprecated.
|
||||||
`docker-compose` is not copied; Codeman never shells out to it.
|
`docker-compose` is not copied; Codeman never shells out to it.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
1.24.4 is a same-day follow-on to 1.24.3, so the thanks for that pair belong here too:
|
||||||
|
|
||||||
|
- @opticon454 for #349, and for a write-up that made an infrastructure PR quick to review
|
||||||
|
|
||||||
## 1.24.3
|
## 1.24.3
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
@@ -341,6 +394,13 @@
|
|||||||
modules, handler counts, frontend module count and app.js size, install.sh size) and
|
modules, handler counts, frontend module count and app.js size, install.sh size) and
|
||||||
documenting several subsystems that had no entry.
|
documenting several subsystems that had no entry.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
1.24.2 is a hotfix on top of 1.24.1, so the thanks for that pair belong here too:
|
||||||
|
|
||||||
|
- @opticon454 for #350, with a reproduction that made this a confirmation rather than a hunt
|
||||||
|
- @timkjr for reporting #352, and for finding it while verifying Docker support for someone else's PR
|
||||||
|
|
||||||
## 1.24.1
|
## 1.24.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
@@ -440,6 +500,12 @@
|
|||||||
so cancelling a rename stored an EMPTY session name and the tab fell back to its
|
so cancelling a rename stored an EMPTY session name and the tab fell back to its
|
||||||
folder label. Escape now cancels without a request, in every layout.
|
folder label. Escape now cancels without a request, in every layout.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
1.23.0 carries no contributor PRs of its own. It lands the day after 1.22.0, so the thanks for that pair belong here too:
|
||||||
|
|
||||||
|
- **@aakhter** built both halves of the new tab experience: the owner-scoped, server-authoritative tab-layout foundation with recipient-safe SSE publication and an unusually deep test suite (#335), and the resizable vertical session rail with accessible pointer/keyboard sizing and careful FitAddon handoff (#334). Fifth and sixth merged PRs, and the layout work also fixed real multi-user ordering leaks along the way.
|
||||||
|
|
||||||
## 1.22.0
|
## 1.22.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
@@ -452,6 +518,10 @@
|
|||||||
|
|
||||||
- Fix the file preview's dead pop-out control: a real detach button now opens the previewed file in a browser tab (raw route for PDFs/images/media/text, converted-PDF preview for docx/pptx) and the copy button reports when a preview has no text to copy instead of silently doing nothing. Review-driven hardening for the new tab features: PUT /api/session-order drops unknown ids again instead of rejecting the whole write (a session deleted inside the browser's debounce window could silently lose the user's reorder), a failed mux restore no longer blocks explicit session/webview deletion for the process lifetime (the automated stale sweep stays fail-closed), and the vertical rail gains the axis-awareness the sidebar-only predicates missed: correct drag-reorder insertion, active-tab scroll-into-view, floating windows anchored beside rail tabs, connector redraws on rail scroll, server-seeded orientation applied on first load, a pre-paint stamp so vertical mode no longer flashes through the header strip, and a 12px session-name default matching the sidebar's historical size so untouched installs are not restyled.
|
- Fix the file preview's dead pop-out control: a real detach button now opens the previewed file in a browser tab (raw route for PDFs/images/media/text, converted-PDF preview for docx/pptx) and the copy button reports when a preview has no text to copy instead of silently doing nothing. Review-driven hardening for the new tab features: PUT /api/session-order drops unknown ids again instead of rejecting the whole write (a session deleted inside the browser's debounce window could silently lose the user's reorder), a failed mux restore no longer blocks explicit session/webview deletion for the process lifetime (the automated stale sweep stays fail-closed), and the vertical rail gains the axis-awareness the sidebar-only predicates missed: correct drag-reorder insertion, active-tab scroll-into-view, floating windows anchored beside rail tabs, connector redraws on rail scroll, server-seeded orientation applied on first load, a pre-paint stamp so vertical mode no longer flashes through the header strip, and a 12px session-name default matching the sidebar's historical size so untouched installs are not restyled.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
- **@aakhter** built both halves of the new tab experience: the owner-scoped, server-authoritative tab-layout foundation with recipient-safe SSE publication and an unusually deep test suite (#335), and the resizable vertical session rail with accessible pointer/keyboard sizing and careful FitAddon handoff (#334). Fifth and sixth merged PRs, and the layout work also fixed real multi-user ordering leaks along the way.
|
||||||
|
|
||||||
## 1.21.0
|
## 1.21.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ When user says "COM":
|
|||||||
|
|
||||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||||
|
|
||||||
**Version**: 1.27.0 (must match `package.json`)
|
**Version**: 1.28.1 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -318,6 +318,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
|
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
|
||||||
|
|
||||||
|
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): bold text on the theme's default foreground carries exactly ONE cue, the weight step. Claude Code marks its markdown bold with a bare `ESC[1m` and no colour change, and xterm substitutes a bright colour for bold only when the foreground is a palette index 0-7, so the substitution never fires there. A two-face family keeps that step small and 400 stays 400 whatever family is chosen, which is why the NORMAL slot is settable at all. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves both slots, each against **its own** xterm default, so an unset bold weight can never inherit `normal`. ⚠️ **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). ⚠️ `_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.
|
||||||
|
|
||||||
**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)
|
**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)
|
||||||
|
|
||||||
**Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](docs/architecture-invariants.md#foldable-settings-identity)
|
**Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](docs/architecture-invariants.md#foldable-settings-identity)
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.28.0",
|
"version": "1.28.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.28.0",
|
"version": "1.28.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.28.0",
|
"version": "1.28.1",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -709,6 +709,54 @@ function resolveTerminalFontFamily(custom) {
|
|||||||
return `${families.join(', ')}, ${TERMINAL_FONT_DEFAULT_STACK}`;
|
return `${families.join(', ')}, ${TERMINAL_FONT_DEFAULT_STACK}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xterm's own defaults for the two weight slots, one per slot.
|
||||||
|
*
|
||||||
|
* They are deliberately kept apart rather than collapsed into a single
|
||||||
|
* fallback: handing the bold slot `normal` (or the normal slot `bold`) would
|
||||||
|
* turn an unset setting into a visible change, which is exactly the thing this
|
||||||
|
* feature exists to make controllable.
|
||||||
|
*/
|
||||||
|
const TERMINAL_FONT_WEIGHT_DEFAULTS = { fontWeight: 'normal', fontWeightBold: 'bold' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve ONE weight slot against xterm's validation rules.
|
||||||
|
*
|
||||||
|
* xterm accepts a number in 1..1000, or one of its own keyword/numeric-string
|
||||||
|
* options, and silently falls back to the slot default for anything else
|
||||||
|
* (`OptionsService._sanitizeAndValidateOption`). Resolving here instead means a
|
||||||
|
* stored value the picker does not list (a hand-set 350) still reaches the
|
||||||
|
* terminal, while junk in localStorage never does.
|
||||||
|
*/
|
||||||
|
function resolveTerminalFontWeightSlot(value, fallback) {
|
||||||
|
if (value === 'normal' || value === 'bold') return value;
|
||||||
|
const numeric = typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN;
|
||||||
|
if (!Number.isFinite(numeric) || numeric < 1 || numeric > 1000) return fallback;
|
||||||
|
return Math.round(numeric);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve both xterm weight slots from the per-device settings blob.
|
||||||
|
*
|
||||||
|
* Bold text on the theme's default foreground carries exactly ONE cue, the
|
||||||
|
* weight step: Claude Code marks its markdown bold with a bare `ESC[1m` and no
|
||||||
|
* colour, and xterm's bold-to-bright substitution only fires for palette
|
||||||
|
* indices 0-7, so it never applies to default-foreground text. A family that
|
||||||
|
* ships only a regular and a bold face keeps that step small, and 400 stays
|
||||||
|
* 400 whatever family is chosen — lowering the NORMAL weight is the only way
|
||||||
|
* to widen the gap.
|
||||||
|
*/
|
||||||
|
function resolveTerminalFontWeights(settings) {
|
||||||
|
const s = settings && typeof settings === 'object' ? settings : {};
|
||||||
|
return {
|
||||||
|
fontWeight: resolveTerminalFontWeightSlot(s.terminalFontWeight, TERMINAL_FONT_WEIGHT_DEFAULTS.fontWeight),
|
||||||
|
fontWeightBold: resolveTerminalFontWeightSlot(
|
||||||
|
s.terminalFontWeightBold,
|
||||||
|
TERMINAL_FONT_WEIGHT_DEFAULTS.fontWeightBold
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Auto Copy (copy-on-select). Pure decision, so every guard below is testable
|
// Auto Copy (copy-on-select). Pure decision, so every guard below is testable
|
||||||
// without a terminal, a clipboard, or a browser.
|
// without a terminal, a clipboard, or a browser.
|
||||||
@@ -809,6 +857,8 @@ if (typeof window !== 'undefined') {
|
|||||||
window.CodemanTerminalFont = {
|
window.CodemanTerminalFont = {
|
||||||
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
|
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
|
||||||
resolve: resolveTerminalFontFamily,
|
resolve: resolveTerminalFontFamily,
|
||||||
|
WEIGHT_DEFAULTS: TERMINAL_FONT_WEIGHT_DEFAULTS,
|
||||||
|
resolveWeights: resolveTerminalFontWeights,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -335,6 +335,12 @@
|
|||||||
'Terminal font': '终端字体',
|
'Terminal font': '终端字体',
|
||||||
'Prepended to the built-in stack, so fallbacks (including bundled Nerd Font symbols) keep working. Must be installed on this device. Leave empty for the default.':
|
'Prepended to the built-in stack, so fallbacks (including bundled Nerd Font symbols) keep working. Must be installed on this device. Leave empty for the default.':
|
||||||
'置于内置字体栈之前,回退字体(包括内置的 Nerd Font 图标)仍然生效。需已安装在本设备上。留空使用默认值。',
|
'置于内置字体栈之前,回退字体(包括内置的 Nerd Font 图标)仍然生效。需已安装在本设备上。留空使用默认值。',
|
||||||
|
'Normal font weight': '常规字重',
|
||||||
|
'Weight for ordinary terminal text. Lowering it widens the step up to bold, which for a family shipping only a regular and a bold face is the only cue bold text carries. Needs a family with faces at that weight; the bundled font covers 100 to 800.':
|
||||||
|
'终端普通文本的字重。调低可拉大与粗体之间的差距;对于只提供常规和粗体两种字形的字体,这一差距是粗体文本唯一的视觉提示。需要字体具备该字重的字形,内置字体覆盖 100 至 800。',
|
||||||
|
'Bold font weight': '粗体字重',
|
||||||
|
'Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face.':
|
||||||
|
'终端粗体文本的字重。仅当字体提供比其粗体更重的字形时才有意义。',
|
||||||
'Local Echo': '本地回显',
|
'Local Echo': '本地回显',
|
||||||
'CJK Input': '中日韩输入',
|
'CJK Input': '中日韩输入',
|
||||||
'Extended Keyboard Bar': '扩展键盘栏',
|
'Extended Keyboard Bar': '扩展键盘栏',
|
||||||
|
|||||||
@@ -1704,6 +1704,42 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="text" id="appSettingsTerminalFont" class="set-input" placeholder='e.g. JetBrainsMono Nerd Font'>
|
<input type="text" id="appSettingsTerminalFont" class="set-input" placeholder='e.g. JetBrainsMono Nerd Font'>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row has-field" data-search="terminal font weight normal regular light thin bold contrast">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Normal font weight</span>
|
||||||
|
<span class="set-row-desc">Weight for ordinary terminal text. Lowering it widens the step up to bold, which for a family shipping only a regular and a bold face is the only cue bold text carries. Needs a family with faces at that weight; the bundled font covers 100 to 800.</span>
|
||||||
|
</div>
|
||||||
|
<select id="appSettingsTerminalFontWeight" class="set-select">
|
||||||
|
<option value="">Default (normal)</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="200">200</option>
|
||||||
|
<option value="300">300</option>
|
||||||
|
<option value="400">400</option>
|
||||||
|
<option value="500">500</option>
|
||||||
|
<option value="600">600</option>
|
||||||
|
<option value="700">700</option>
|
||||||
|
<option value="800">800</option>
|
||||||
|
<option value="900">900</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="set-row has-field" data-search="terminal bold font weight heavy black emphasis">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Bold font weight</span>
|
||||||
|
<span class="set-row-desc">Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face.</span>
|
||||||
|
</div>
|
||||||
|
<select id="appSettingsTerminalFontWeightBold" class="set-select">
|
||||||
|
<option value="">Default (bold)</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="200">200</option>
|
||||||
|
<option value="300">300</option>
|
||||||
|
<option value="400">400</option>
|
||||||
|
<option value="500">500</option>
|
||||||
|
<option value="600">600</option>
|
||||||
|
<option value="700">700</option>
|
||||||
|
<option value="800">800</option>
|
||||||
|
<option value="900">900</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2282,10 +2282,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fontSettings = this.loadAppSettingsFromStorage?.() || {};
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
theme: { ...window.codemanCurrentXtermTheme() },
|
theme: { ...window.codemanCurrentXtermTheme() },
|
||||||
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||||
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
|
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,
|
fontSize: 12,
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
|
|||||||
@@ -335,6 +335,32 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// App Settings Modal
|
// App Settings Modal
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point one terminal-weight select at its stored value.
|
||||||
|
*
|
||||||
|
* A stored value the picker does not list (a hand-set 350, or a weight from a
|
||||||
|
* build whose options differ) is ADDED to the select rather than dropped:
|
||||||
|
* otherwise `select.value = '350'` silently selects nothing, the next save
|
||||||
|
* reads back '' and the setting resets itself just for having been opened.
|
||||||
|
* Empty means "use xterm's default for this slot".
|
||||||
|
*/
|
||||||
|
populateTerminalFontWeight(select, value) {
|
||||||
|
if (!select) return;
|
||||||
|
const stored = value === undefined || value === null ? '' : String(value).trim();
|
||||||
|
if (stored && !Array.from(select.options).some((opt) => opt.value === stored)) {
|
||||||
|
const extra = document.createElement('option');
|
||||||
|
extra.value = stored;
|
||||||
|
extra.textContent = `${stored} (custom)`;
|
||||||
|
select.appendChild(extra);
|
||||||
|
}
|
||||||
|
select.value = stored;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Read one terminal-weight select back. '' means default; the resolver in constants.js validates. */
|
||||||
|
readTerminalFontWeight(select) {
|
||||||
|
return select?.value.trim() || '';
|
||||||
|
},
|
||||||
|
|
||||||
openAppSettings() {
|
openAppSettings() {
|
||||||
// Load current settings
|
// Load current settings
|
||||||
const settings = this.loadAppSettingsFromStorage();
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
@@ -411,6 +437,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// a way to read, so it is opt-in rather than a default anyone has to discover.
|
// a way to read, so it is opt-in rather than a default anyone has to discover.
|
||||||
document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true;
|
document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true;
|
||||||
document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || '';
|
document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || '';
|
||||||
|
this.populateTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight'), settings.terminalFontWeight);
|
||||||
|
this.populateTerminalFontWeight(
|
||||||
|
document.getElementById('appSettingsTerminalFontWeightBold'),
|
||||||
|
settings.terminalFontWeightBold
|
||||||
|
);
|
||||||
document.getElementById('appSettingsTerminalWheelLocal').checked =
|
document.getElementById('appSettingsTerminalWheelLocal').checked =
|
||||||
settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false;
|
settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false;
|
||||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||||
@@ -2091,6 +2122,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||||
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
|
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
|
||||||
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
|
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
|
||||||
|
terminalFontWeight: this.readTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight')),
|
||||||
|
terminalFontWeightBold: this.readTerminalFontWeight(
|
||||||
|
document.getElementById('appSettingsTerminalFontWeightBold')
|
||||||
|
),
|
||||||
terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked,
|
terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked,
|
||||||
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
||||||
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
||||||
@@ -2145,6 +2180,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.saveAppSettingsToStorage(settings);
|
this.saveAppSettingsToStorage(settings);
|
||||||
this._updateLocalEchoState();
|
this._updateLocalEchoState();
|
||||||
this.applyTerminalFontFamily?.(settings.terminalFontFamily);
|
this.applyTerminalFontFamily?.(settings.terminalFontFamily);
|
||||||
|
this.applyTerminalFontWeights?.(settings);
|
||||||
|
|
||||||
// A real OFF→ON flip of the WebGL toggle retires the GPU-stall auto-fallback
|
// A real OFF→ON flip of the WebGL toggle retires the GPU-stall auto-fallback
|
||||||
// marker so the next reload actually re-tries WebGL. Only the transition
|
// marker so the next reload actually re-tries WebGL. Only the transition
|
||||||
@@ -2302,6 +2338,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Per-device by nature (the font must exist on the device) and absent
|
// Per-device by nature (the font must exist on the device) and absent
|
||||||
// from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT.
|
// from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT.
|
||||||
terminalFontFamily: _tff,
|
terminalFontFamily: _tff,
|
||||||
|
// Same two reasons: which weights a family can actually render is a
|
||||||
|
// property of the faces installed on THIS device, and neither key is
|
||||||
|
// declared in the .strict() schema.
|
||||||
|
terminalFontWeight: _tfw,
|
||||||
|
terminalFontWeightBold: _tfwb,
|
||||||
// Per-device header/toolbar button toggles — client-only, and absent from
|
// Per-device header/toolbar button toggles — client-only, and absent from
|
||||||
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
|
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
|
||||||
showSessionButton: _ssb,
|
showSessionButton: _ssb,
|
||||||
@@ -3076,7 +3117,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||||
'terminalFontFamily',
|
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||||
'language',
|
'language',
|
||||||
'terminalWheelLocalScrollback',
|
'terminalWheelLocalScrollback',
|
||||||
'autoCopySelection',
|
'autoCopySelection',
|
||||||
|
|||||||
@@ -9,11 +9,20 @@
|
|||||||
font-weight: 400 800;
|
font-weight: 400 800;
|
||||||
src: url('fonts/manrope-variable.woff2') format('woff2');
|
src: url('fonts/manrope-variable.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
/* The declared range is what the browser will synthesize from, NOT what the
|
||||||
|
file carries: the woff2 behind this has a `wght` axis of 100 to 800, and a
|
||||||
|
narrower descriptor clamps it — at `400 700`, requesting 100, 200 or 300
|
||||||
|
rendered identically to 400 and 800 identically to 700. The terminal
|
||||||
|
font-weight settings would then be a no-op for anyone on the bundled face,
|
||||||
|
which is most installs (the two families ahead of it in the stack, Fira Code
|
||||||
|
and Cascadia Code, exist only if the user installed them). Nothing in the
|
||||||
|
stylesheets asks for a monospace weight outside 400-700, so widening it
|
||||||
|
changes nothing that renders today. */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'JetBrains Mono';
|
font-family: 'JetBrains Mono';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-weight: 400 700;
|
font-weight: 100 800;
|
||||||
src: url('fonts/jetbrains-mono-variable.woff2') format('woff2');
|
src: url('fonts/jetbrains-mono-variable.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
/* Icons-only per-glyph fallback for the terminal (Symbols Nerd Font Mono, MIT,
|
/* Icons-only per-glyph fallback for the terminal (Symbols Nerd Font Mono, MIT,
|
||||||
@@ -545,18 +554,21 @@ body {
|
|||||||
contain: layout;
|
contain: layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-tabs.tabs-two-rows {
|
/* A wrapped strip GROWS the header instead of scrolling inside it. The old
|
||||||
flex-wrap: wrap;
|
fixed caps (120px tall-tabs / 96px auto-wrap) were row counts in disguise:
|
||||||
overflow-x: hidden;
|
a third row of tabs was clipped into a 4px scroller, so the tabs you were
|
||||||
overflow-y: auto;
|
looking for were off-screen in a container nothing tells you to scroll —
|
||||||
max-height: 120px;
|
while the header had the whole page below it to grow into (it is
|
||||||
}
|
`min-height` + `flex-shrink: 0`, and terminal-ui's ResizeObserver refits
|
||||||
|
the terminal on its own). `--tab-strip-max-height` is a safety net for an
|
||||||
|
absurd session count, not a row limit: past it the scroller comes back,
|
||||||
|
which beats a header that swallows the terminal. */
|
||||||
|
.session-tabs.tabs-two-rows,
|
||||||
.session-tabs.tabs-auto-wrap {
|
.session-tabs.tabs-auto-wrap {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: 96px;
|
max-height: var(--tab-strip-max-height, 40vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-tabs::-webkit-scrollbar {
|
.session-tabs::-webkit-scrollbar {
|
||||||
|
|||||||
@@ -248,9 +248,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;
|
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;
|
||||||
|
|
||||||
this._destroyKeyCode229Recovery();
|
this._destroyKeyCode229Recovery();
|
||||||
|
const fontSettings = this.loadAppSettingsFromStorage?.() || {};
|
||||||
this.terminal = new Terminal({
|
this.terminal = new Terminal({
|
||||||
theme: { ...window.codemanCurrentXtermTheme() },
|
theme: { ...window.codemanCurrentXtermTheme() },
|
||||||
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
|
fontFamily: window.CodemanTerminalFont.resolve(fontSettings.terminalFontFamily),
|
||||||
|
// Both weight slots, each falling back to xterm's own default for that
|
||||||
|
// slot, so an untouched install renders exactly as it always has.
|
||||||
|
...window.CodemanTerminalFont.resolveWeights(fontSettings),
|
||||||
// Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line)
|
// Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line)
|
||||||
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
|
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
@@ -4905,6 +4909,57 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._predictiveEcho?.refreshFont();
|
this._predictiveEcho?.refreshFont();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the per-device terminal font WEIGHTS to every live xterm.
|
||||||
|
*
|
||||||
|
* Both slots move together because they are resolved together: passing a
|
||||||
|
* settings blob with neither key restores xterm's own `normal`/`bold`.
|
||||||
|
*
|
||||||
|
* Three things follow the option write and none of them is optional:
|
||||||
|
*
|
||||||
|
* - The echo overlays cache `terminal.options.fontWeight` and paint it into
|
||||||
|
* their spans, so without `refreshFont()` the characters being typed keep
|
||||||
|
* the old weight while the rest of the screen changes. Most visible on a
|
||||||
|
* phone, where local echo is on by default.
|
||||||
|
* - Agent Teams panes read these options at CONSTRUCTION, so a live save
|
||||||
|
* would otherwise leave an open pane at the old weight beside a repainted
|
||||||
|
* terminal. `applyTerminalSkin()` propagates for the same reason.
|
||||||
|
* - The refit is insurance. `CharSizeService` measures through the CSS
|
||||||
|
* `font` shorthand, which resets the weight, so the canvas path measures
|
||||||
|
* the 400 face at every setting — but `DomRenderer` styles its measure
|
||||||
|
* span with `span:not(.xterm-bold)`, where the normal weight really can
|
||||||
|
* move the cell.
|
||||||
|
*/
|
||||||
|
applyTerminalFontWeights(settings) {
|
||||||
|
const { fontWeight, fontWeightBold } = window.CodemanTerminalFont.resolveWeights(settings);
|
||||||
|
if (!this.terminal) return;
|
||||||
|
if (this.terminal.options.fontWeight === fontWeight && this.terminal.options.fontWeightBold === fontWeightBold) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.terminal.options.fontWeight = fontWeight;
|
||||||
|
this.terminal.options.fontWeightBold = fontWeightBold;
|
||||||
|
// Same race as a live family change: the option write makes xterm
|
||||||
|
// re-measure immediately, against a face the browser may not have
|
||||||
|
// rasterized yet. Re-arm the wait and fit again once it settles; the fit
|
||||||
|
// below still runs, so the terminal is never left unfitted.
|
||||||
|
this._terminalFontReady = this._awaitTerminalFont().then(() => {
|
||||||
|
if (this.terminal?.options?.fontWeight === fontWeight) this.fitAddon?.fit();
|
||||||
|
});
|
||||||
|
this.fitAddon?.fit();
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
loadFontSize() {
|
loadFontSize() {
|
||||||
const saved = localStorage.getItem('codeman-font-size');
|
const saved = localStorage.getItem('codeman-font-size');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Terminal font weight: live apply, and the plumbing around it.
|
||||||
|
*
|
||||||
|
* Bold text on the theme's default foreground carries exactly ONE cue, the
|
||||||
|
* weight step. Claude Code marks its markdown bold with a bare `ESC[1m` and no
|
||||||
|
* colour change, and xterm substitutes a bright colour for bold only when the
|
||||||
|
* foreground is a palette index below 8, so nothing else distinguishes it. A
|
||||||
|
* family that ships only a regular and a bold face keeps that step small, and
|
||||||
|
* 400 stays 400 whatever family is picked — which is why the NORMAL slot is
|
||||||
|
* settable at all.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
* - 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`
|
||||||
|
* descriptor, not the file, is what the browser synthesizes from: at
|
||||||
|
* `400 700` every weight below 400 renders identically to 400, so the
|
||||||
|
* setting would be inert for anyone without Fira Code or Cascadia Code
|
||||||
|
* installed.
|
||||||
|
*
|
||||||
|
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
|
||||||
|
* box; see connection-indicator.test.ts), matching terminal-font-settle.test.ts.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
|
||||||
|
function loadTerminalMixin(): Record<string, unknown> {
|
||||||
|
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
performance,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
CodemanApp: FakeCodemanApp,
|
||||||
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
document: undefined,
|
||||||
|
});
|
||||||
|
const constants = readFileSync(resolve(publicDir, 'constants.js'), 'utf8');
|
||||||
|
const source = readFileSync(resolve(publicDir, 'terminal-ui.js'), 'utf8');
|
||||||
|
vm.runInContext(`${constants}\n${source}`, context);
|
||||||
|
// constants.js publishes CodemanTerminalFont onto the context's window, which
|
||||||
|
// is the one the mixin closes over.
|
||||||
|
return FakeCodemanApp.prototype;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mixin = loadTerminalMixin();
|
||||||
|
|
||||||
|
function fakeTerminal(options: Record<string, unknown> = {}) {
|
||||||
|
return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTerminal> | 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,
|
||||||
|
_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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('applyTerminalFontWeights', () => {
|
||||||
|
it('writes both slots to the live terminal', () => {
|
||||||
|
const { app, fit } = makeApp();
|
||||||
|
|
||||||
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||||
|
terminalFontWeight: '300',
|
||||||
|
terminalFontWeightBold: '800',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(app.terminal?.options.fontWeight).toBe(300);
|
||||||
|
expect(app.terminal?.options.fontWeightBold).toBe(800);
|
||||||
|
expect(fit).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes the echo overlays, which cache the weight and paint it', () => {
|
||||||
|
// Without this the characters being typed keep the old weight while the
|
||||||
|
// rest of the screen changes — most visible on a phone, where local echo
|
||||||
|
// is on by default.
|
||||||
|
const { app } = makeApp();
|
||||||
|
|
||||||
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||||
|
terminalFontWeight: '300',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(app._localEchoOverlay.refreshFont).toHaveBeenCalledTimes(1);
|
||||||
|
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 }) });
|
||||||
|
|
||||||
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({});
|
||||||
|
|
||||||
|
expect(app.terminal?.options.fontWeight).toBe('normal');
|
||||||
|
expect(app.terminal?.options.fontWeightBold).toBe('bold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when neither slot changed', () => {
|
||||||
|
// saveAppSettings runs on every close of the modal.
|
||||||
|
const { app, fit } = makeApp({ terminal: fakeTerminal({ fontWeight: 300, fontWeightBold: 'bold' }) });
|
||||||
|
|
||||||
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||||
|
terminalFontWeight: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fit).not.toHaveBeenCalled();
|
||||||
|
expect(app._localEchoOverlay.refreshFont).not.toHaveBeenCalled();
|
||||||
|
expect(app._awaitTerminalFont).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-arms the font wait, so a fit lands once the face is rasterized', () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
|
||||||
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||||
|
terminalFontWeight: '300',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(app._awaitTerminalFont).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app._terminalFontReady).toBeInstanceOf(Promise);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a terminal that does not exist yet', () => {
|
||||||
|
const { app } = makeApp({ terminal: null });
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||||
|
terminalFontWeight: '300',
|
||||||
|
})
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bundled terminal face', () => {
|
||||||
|
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
||||||
|
|
||||||
|
it('is declared over its full weight axis, not xterm’s default span', () => {
|
||||||
|
// The woff2 carries a `wght` axis of 100 to 800. A narrower @font-face
|
||||||
|
// descriptor CLAMPS it: at `400 700`, 100/200/300 all render identically to
|
||||||
|
// 400 and 800 identically to 700, so the settings above would be a no-op
|
||||||
|
// for every install without Fira Code or Cascadia Code.
|
||||||
|
const face = styles.slice(styles.indexOf("font-family: 'JetBrains Mono'"));
|
||||||
|
const declared = /font-weight:\s*(\d+)\s+(\d+)/.exec(face.slice(0, face.indexOf('}')));
|
||||||
|
expect(declared, 'the bundled mono face must declare a weight RANGE').not.toBeNull();
|
||||||
|
expect(Number(declared![1])).toBeLessThanOrEqual(100);
|
||||||
|
expect(Number(declared![2])).toBeGreaterThanOrEqual(800);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('terminal font weight settings plumbing', () => {
|
||||||
|
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
|
||||||
|
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
|
||||||
|
const keys = ['terminalFontWeight', 'terminalFontWeightBold'] as const;
|
||||||
|
|
||||||
|
it('offers both selects with a Default entry and the 100-900 steps', () => {
|
||||||
|
for (const id of ['appSettingsTerminalFontWeight', 'appSettingsTerminalFontWeightBold']) {
|
||||||
|
const start = html.indexOf(`<select id="${id}"`);
|
||||||
|
expect(start, `${id} missing from index.html`).toBeGreaterThan(-1);
|
||||||
|
const select = html.slice(start, html.indexOf('</select>', start));
|
||||||
|
expect(select).toContain('<option value="">');
|
||||||
|
for (let w = 100; w <= 900; w += 100) expect(select).toContain(`<option value="${w}">`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats both as per-device, which is TWO separate decisions', () => {
|
||||||
|
// Membership in displayKeys keeps one device from overwriting another's
|
||||||
|
// value; the strip before the PUT is what stops the .strict() schema from
|
||||||
|
// 400-ing the whole settings save.
|
||||||
|
const displayKeys = settingsUi.slice(settingsUi.indexOf('const displayKeys = new Set(['));
|
||||||
|
const listed = displayKeys.slice(0, displayKeys.indexOf(']);'));
|
||||||
|
const stripped = settingsUi.slice(settingsUi.indexOf('const {', settingsUi.indexOf('async saveAppSettings()')));
|
||||||
|
for (const key of keys) {
|
||||||
|
expect(listed, `${key} must be a display key`).toContain(`'${key}'`);
|
||||||
|
expect(stripped.slice(0, stripped.indexOf('} = settings;')), `${key} must be stripped from the PUT`).toContain(
|
||||||
|
`${key}: _`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the save to the live terminal', () => {
|
||||||
|
const save = settingsUi.slice(settingsUi.indexOf('async saveAppSettings()'));
|
||||||
|
expect(save.slice(0, save.indexOf('\n },'))).toContain('this.applyTerminalFontWeights?.(settings)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,8 @@ function loadFontHelper() {
|
|||||||
CodemanTerminalFont: {
|
CodemanTerminalFont: {
|
||||||
DEFAULT_STACK: string;
|
DEFAULT_STACK: string;
|
||||||
resolve: (custom?: unknown) => string;
|
resolve: (custom?: unknown) => string;
|
||||||
|
WEIGHT_DEFAULTS: { fontWeight: string; fontWeightBold: string };
|
||||||
|
resolveWeights: (settings?: unknown) => { fontWeight: string | number; fontWeightBold: string | number };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
).CodemanTerminalFont;
|
).CodemanTerminalFont;
|
||||||
@@ -56,3 +58,60 @@ describe('CodemanTerminalFont', () => {
|
|||||||
expect(font.resolve('Hack, monospace')).toBe(`Hack, ${font.DEFAULT_STACK}`);
|
expect(font.resolve('Hack, monospace')).toBe(`Hack, ${font.DEFAULT_STACK}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CodemanTerminalFont.resolveWeights', () => {
|
||||||
|
const DEFAULTS = { fontWeight: 'normal', fontWeightBold: 'bold' };
|
||||||
|
|
||||||
|
it("leaves an untouched install on xterm's own defaults", () => {
|
||||||
|
// The whole feature has to be invisible until someone asks for it.
|
||||||
|
expect(font.resolveWeights(undefined)).toEqual(DEFAULTS);
|
||||||
|
expect(font.resolveWeights({})).toEqual(DEFAULTS);
|
||||||
|
expect(font.resolveWeights('nonsense')).toEqual(DEFAULTS);
|
||||||
|
expect(font.WEIGHT_DEFAULTS).toEqual(DEFAULTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves each slot independently', () => {
|
||||||
|
expect(font.resolveWeights({ terminalFontWeight: 300 })).toEqual({ fontWeight: 300, fontWeightBold: 'bold' });
|
||||||
|
expect(font.resolveWeights({ terminalFontWeightBold: 800 })).toEqual({ fontWeight: 'normal', fontWeightBold: 800 });
|
||||||
|
expect(font.resolveWeights({ terminalFontWeight: 300, terminalFontWeightBold: 800 })).toEqual({
|
||||||
|
fontWeight: 300,
|
||||||
|
fontWeightBold: 800,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never hands one slot the other slot default', () => {
|
||||||
|
// A shared fallback would turn an unset bold weight into a visible change.
|
||||||
|
for (const bad of [null, '', ' ', 'heavy', NaN, {}, [], true]) {
|
||||||
|
expect(font.resolveWeights({ terminalFontWeight: bad, terminalFontWeightBold: bad })).toEqual(DEFAULTS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the string values the select stores', () => {
|
||||||
|
expect(font.resolveWeights({ terminalFontWeight: '300', terminalFontWeightBold: '900' })).toEqual({
|
||||||
|
fontWeight: 300,
|
||||||
|
fontWeightBold: 900,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a hand-set weight the picker does not offer', () => {
|
||||||
|
expect(font.resolveWeights({ terminalFontWeight: '350' }).fontWeight).toBe(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through xterm's own keywords unchanged", () => {
|
||||||
|
expect(font.resolveWeights({ terminalFontWeight: 'bold', terminalFontWeightBold: 'normal' })).toEqual({
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontWeightBold: 'normal',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects what xterm would reject, rather than letting it silently reset the slot', () => {
|
||||||
|
// OptionsService accepts a number in 1..1000 and falls back otherwise, so
|
||||||
|
// anything outside that range must resolve to the default here instead of
|
||||||
|
// reaching the terminal and being swapped out underneath the setting.
|
||||||
|
expect(font.resolveWeights({ terminalFontWeight: 0 }).fontWeight).toBe('normal');
|
||||||
|
expect(font.resolveWeights({ terminalFontWeight: -400 }).fontWeight).toBe('normal');
|
||||||
|
expect(font.resolveWeights({ terminalFontWeight: 1001 }).fontWeight).toBe('normal');
|
||||||
|
expect(font.resolveWeights({ terminalFontWeight: 1000 }).fontWeight).toBe(1000);
|
||||||
|
expect(font.resolveWeights({ terminalFontWeight: 1 }).fontWeight).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user