Compare commits

..
Author SHA1 Message Date
Codeman maintainer edeaa15986 feat(terminal): configurable normal and bold font weight (#403)
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
changes no colour, and xterm substitutes a bright colour for bold only
when the foreground is a palette index 0-7, so the substitution never
fires for default-foreground text. A family shipping only a regular and
a bold face keeps that step small (measured on Consolas: glyph ink rises
from 14.25% to 16.57%), and picking a different family does not help,
because 400 stays 400 whatever the family. Lowering the NORMAL weight is
the only way to widen the gap.

Two per-device settings beside "Terminal font" in the Font group, each
defaulting to xterm's own value for its slot, so an untouched install
renders exactly as it did before. Both thread into the main terminal and
the Agent Teams panes, and apply on save without a reload.

The bundled face had to be unclamped in the same change or the settings
would look broken on a stock install. fonts/jetbrains-mono-variable.woff2
carries a wght axis of 100 to 800, but styles.css declared the face
`400 700`, and the descriptor is what the browser synthesizes from: at
that range 100, 200 and 300 rendered identically to 400 and 800
identically to 700 (measured in headless Chromium, both directions).
The two families ahead of it in the default stack, Fira Code and Cascadia
Code, exist only if the user installed them, so for most installs
"normal = 300" would have been a no-op. Declared `100 800`, every step is
distinct: 61%, 77% and 90% of the ink at 400, and 800 adds ~14% over 700.
Nothing in the stylesheets asks for a monospace weight outside 400-700,
so widening it changes nothing that rendered before.

Details that are easy to get wrong and are pinned by tests:

- Each slot falls back to its OWN xterm default, so an unset bold weight
  can never inherit `normal` and become a visible change.
- A live save refreshes both echo overlays. They cache
  terminal.options.fontWeight and paint it into their spans, so without
  it 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.
- A live save reaches open Agent Teams panes, which read their options at
  construction, exactly as applyTerminalSkin() propagates its own.
- A stored weight the picker does not list (a hand-set 350) is added to
  the select rather than dropped, so merely opening App Settings cannot
  reset it.
- _awaitTerminalFont() is 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 request nothing new.

Verified end to end in a headless browser against a live server: the save
reaches the running terminal with no reload, the settings PUT stays 200
(both keys are display keys and are stripped before it, since
SettingsUpdateSchema is strict), the value survives a reload, and the
painted terminal really changes weight with the bundled font (lit-pixel
ink 0.83 / 0.95 / 1.00 / 1.13 / 1.21 at 100 / 300 / default / 700 / 800).

Proposed and analysed by @irisitymichaelgrundberg in discussion #403.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 14:09:13 +02:00
Codeman maintainer d2ff1814ed docs: close the last two Thanks gaps, 1.23.0 and 1.22.0
Auditing every release after the previous backfill turned up two more. 1.23.0
had no Thanks in either artifact; its three PRs (#337, #341, #338) are authored
by the maintainer, so like the others it credits the release it follows.
1.22.0 had the section on its GitHub release but never in CHANGELOG.md, which
is the drift that happens whenever the block is added post-hoc instead of in
the changeset.

Every release from 1.21.0 forward now carries a Thanks section in both places.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:45:42 +02:00
Codeman maintainer 9e2091255b docs: backfill Thanks sections for 1.26.0, 1.24.4 and 1.24.2
Those three shipped with maintainer-only commits and no Thanks section, on the
reasoning that a release with no contributor PRs has nobody to credit. That is
the wrong test: the newest tag is what GitHub marks Latest, so a contributor
who shipped in the release next door lands on a page acknowledging nobody.

Each now credits the release it follows and says so, rather than claiming work
its contributors did not do: 1.24.2 the hotfix on 1.24.1, 1.24.4 the same-day
follow-on to 1.24.3, 1.26.0 the day after 1.25.0. Wording is carried over
verbatim from those releases. The matching GitHub release bodies were edited to
match, since the two are separate artifacts once version-packages has run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:45:04 +02:00
Codeman maintainer 6030a520bd docs: add the Thanks section to the 1.28.1 changelog entry too
1.28.1 is a same-day follow-on to 1.28.0 and is the release people land on as
"Latest", so it credits the same three contributors rather than showing no
acknowledgement at all. Matches the section just added to its GitHub release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:43:40 +02:00
Codeman maintainer 465b842e97 docs: add the missing Thanks section to the 1.28.0 changelog entry
Every release credits its contributors in both places: a "### Thanks" block
and a comment on each merged PR. The PR comments went out, this did not.
Past releases carry it because the block was written INTO the changeset, which
is what feeds both CHANGELOG.md and the GitHub release body; mine went only on
the GitHub release, so the changelog was short a section. Put it in the
changeset next time rather than patching both by hand afterwards.

1.28.1 gets none on purpose: every commit in it is a maintainer commit, the
same call as 1.26.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:35:33 +02:00
Codeman maintainer c4b74415ee chore: sync CLAUDE.md version to 1.28.1
COM step 4. Staged as a single hunk: the shared checkout also holds another
session's in-progress pr-bot discussions work in this file, which is left
untouched and uncommitted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:17:53 +02:00
github-actions[bot]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> d8a9e2f2bb chore: version packages (#414)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-14 13:17:30 +02:00
Codeman maintainer 708cb2cbf0 fix(tabs): let a wrapped desktop tab strip grow the header instead of clipping itself
The fixed 120px/96px caps on the two wrapped layouts were row counts in disguise: a
third row was clipped into a ~4px scroller, hiding tabs inside a container nothing
invites you to scroll, while the header had the page below it to grow into. Both
layouts now share one rule capped at var(--tab-strip-max-height, 40vh), a safety net
for an absurd session count rather than a row limit.

Verified before shipping: .header is min-height + flex-shrink: 0 so it can grow, and
terminal-ui's ResizeObserver refits the terminal when it does; updateTabOverflowMode()
returns early for any non-desktop viewport, and below 1024px mobile.css pins the header
to max-height: 48px, so this is desktop-only in effect; the selector is comma-grouped
rather than :is(), so each arm keeps (0,2,0) and mobile.css's overrides still win on
source order. PostCSS parses the file cleanly (prettier ignores styles.css).

Authored in a parallel session against this shared checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:08:57 +02:00
github-actions[bot]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 90ac13da1a chore: version packages (#412)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-14 12:59:48 +02:00
Codeman maintainer 48f30f3055 style: drop em-dashes from the text added in c2114615
House style, and these land in the changelog. Only the sentences added in the
previous commit are touched; the em-dashes in contributor text and in the
pre-existing COD-54/COD-115 comments are left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 12:46:24 +02:00
Codeman maintainer c211461500 fix: merge-time follow-ups for #409, #404 and #399
#409 (Claude truecolor). The changeset becomes the changelog, and its premise
does not hold on tmux 3.2 or newer. Measured here on tmux 3.4: `default-terminal`
sits at its compiled default of `tmux-256color`, a live claude pane reports
`TERM=tmux-256color`, and supports-color reads that as 256 colors, where
rgb(55,55,55) lands on ESC[48;5;237m — visible, just not the color the theme
named. The invisible block the PR describes needs TERM to resolve to a 16-color
entry: tmux older than 3.2, or a ~/.tmux.conf setting `default-terminal screen`,
which Codeman's own tmux server does read (it passes no -f). Both the changeset
and the invariants paragraph now say that, so the next report here gets paired
with the reporter's tmux -V instead of being read as universal. The change itself
stands on the simpler argument: claude was one of two entries not asking for
truecolor while twelve do.

Also reorders buildClaudeEnv(). It applied the registry's unset/exports AFTER the
whole env was built, so a clis.json entry naming CODEMAN_HOOK_SECRET_FILE or PATH
would strip it on the direct-PTY path while the tmux pane kept it — buildEnvExports()
emits `...cliEnv` ahead of `export CODEMAN_MUX=1` and cannot. The block now runs
first and Codeman's own keys are assigned on top, matching the pane.

#404 (Ctrl+Z trap). Adds the missing changeset, and records what the trap does
not cover: an agent CLI already holds its tty with ISIG off (verified on three
live panes: `susp = ^Z -isig -icanon`), so this is defence for the startup window
rather than a fix for the steady state, and two input paths still reach the PTY
unfiltered — the mobile accessory bar's one-shot Ctrl and the CJK textarea.

#399 (path picker sort). The server sorts by name and cuts at 500, so the client
sorting those 500 by date gives "the newest of the first 500 by name", which is
wrong in exactly the >500-entry folder the date sort exists for. The status line
now says "(first 500 by name)" so the cut is legible, with the reasoning parked
on _sortEntries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 12:45:41 +02:00
Ark0N 37929cb671 Merge pull request #404 from timkjr/pr-ctrlz-suspend-trap
fix(terminal): trap Ctrl+Z in non-shell sessions to prevent accidental suspend
2026-09-14 12:35:33 +02:00
Ark0N e0ebbbdc91 Merge pull request #409 from irisitymichaelgrundberg/fix/claude-truecolor-in-panes
fix(terminal): let Claude use truecolor so its themed backgrounds render
2026-09-14 12:35:28 +02:00
Ark0N 8c237223b0 Merge pull request #399 from shenlvkang-collab/pr/path-picker-sort-jump
feat(files): let the path picker jump to a typed path and sort by name or date
2026-09-14 12:35:23 +02:00
Michael GrundbergandClaude Opus 5 dae2ac580f fix(terminal): read the colour env from the registry on every local spawn path
buildClaudeEnv(), the direct-PTY fallback taken when mux creation fails, now
reads getCli('claude').env and applies its unset and exports lists. It used to
delete COLORTERM and CLAUDECODE from a hand-maintained list of its own, which
left it contradicting the registry entry that the tmux pane and the attach
client both read. An engine value needing a mux name has nothing to resolve
against on this path, so it is skipped rather than guessed.

Claude no longer unsets NO_COLOR. The invisible-background bug does not need
it, and unsetting it overrides a preference the user set deliberately, so a
user who exports NO_COLOR globally keeps monochrome panes. The other seven
truecolor CLIs still unset it; that inconsistency is intentional and the
comment on the entry says so.

The invariants doc gains a Terminal colour env paragraph under Session launch
modes, where a reader looking up Claude will find it — the previous sentence
sat under a heading that lists only the non-Claude CLIs. It now says the lists
are the stock catalog and a clis.json override replaces them wholesale, and
that the declarations reach the tmux pane, its attach client and the direct
PTY but not a remote pane, whose command carries no env exports at all. Docker
hands COLORTERM=truecolor to every mode, including the two the registry says
must unset it.

The changeset named six peer CLIs and there are seven: deepseek also exports
truecolor. A test beside the existing OpenCode assertion pins the new
behaviour, so a future registry edit cannot make the backgrounds vanish again
in silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:42:30 +02:00
Michael GrundbergandClaude Opus 5 7767b16d4f fix(terminal): let Claude use truecolor so its themed backgrounds render
Claude draws the user's own messages as a block of background color, and
inside a Codeman pane that block was invisible. tmux hands each pane
TERM=screen, which supports-color reads as 16 colors, and Claude's registry
entry deleted COLORTERM on top of that. Claude therefore quantized every RGB
color its theme asked for down to the basic palette, where rgb(55, 55, 55)
and every other dark background becomes ESC[40m, the terminal's own black.
Changing the color in a custom Claude theme moved nothing on screen.

Claude now exports COLORTERM=truecolor and unsets NO_COLOR, matching codex,
gemini, antigravity, pi, grok and omp. CLAUDECODE stays unset, because Claude
reads it as a signal that it is running nested inside itself. Both the tmux
session and the attach client read this one registry entry, so they cannot
disagree.

PR #3 introduced the unset in February, citing xterm.js#484 for the claim
that xterm.js mishandles truecolor. xterm.js closed that issue in April 2019,
Codeman now depends on @xterm/xterm 6, and TmuxManager already sets
terminal-overrides ",*:Tc" on its own tmux server, so 24-bit color reaches
the browser today for every CLI that asks for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 12:15:28 +02:00
timkjrandClaude Sonnet 5 0cedf05d13 fix(terminal): trap Ctrl+Z in non-shell sessions to prevent accidental suspend
Ctrl+Z (SIGTSTP) suspends the foreground job on the pane's tty. In a plain
shell session that's the user's own job-control tool (suspend, fg back),
but in claude/omp/pi/codex/etc. sessions it stops an unattended agent loop
dead with no visible output — the same failure shape as an XOFF freeze,
just via job control instead of tty flow control. Ink-based TUIs usually
run in raw mode (ISIG off) where ^Z is inert, but that only holds once the
CLI is actually running and stays in raw mode; it's live at the shell
prompt before launch and during any raw-mode toggle.

Swallow it client-side in attachCustomKeyEventHandler, mode-gated so shell
sessions keep normal job control, mirroring the existing Ctrl+V/Ctrl+Backspace
interception pattern in the same handler. Case-insensitive key match (Caps
Lock flips ev.key to 'Z' without setting shiftKey, so a plain === 'z' check
let the exact suspend keystroke this exists to catch slip through).

Also cover subagent/teammate terminal windows (panels-ui.js's
initTeammateTerminal), which render a separate xterm instance with no
custom key handler at all and are always running an agent CLI — never a
shell — so the trap there is unconditional.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 18:30:48 -05:00
shenlvkang-collabandClaude Fable 5.1 58b4cb06d8 feat(files): let the path picker jump to a typed path and sort by name or date
The picker's current-folder line was a read-only breadcrumb, so reaching a
deep folder meant tapping through every level, and the listing was fixed to
name order, so the file an agent had just written was somewhere in a
500-entry list.

The current folder is now an editable field: Enter or Go jumps there, a full
file path lands in its folder with that file selected, and a path that does
not resolve keeps the listing you had and says so, instead of the reset to
the root that a stale initialPath gets. A Sort control orders the listing by
name or modified time in either direction, folders always first, and the
choice is remembered per device like the hidden toggle. Each entry shows a
compact modified time (time of day today, month-day this year, else the
date).

GET /api/filesystem/browse stamps every entry with mtimeMs to make that
possible; the stat that already fetched a file's size now serves both, so
it is still one stat per entry. Entries without an mtime (an older server,
the in-container listing) sort after dated ones and then by name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou
2026-09-10 12:42:33 +08:00
23 changed files with 1367 additions and 53 deletions
+142
View File
@@ -1,5 +1,115 @@
# 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
### Minor Changes
- 58b4cb0: feat(files): let the path picker jump to a typed path and sort by name or date
The picker's current-folder line was read-only, so reaching a deep folder meant tapping
through every level, and its listing was fixed to name order, so the file an agent had
just written was somewhere in a 500-entry list. The current folder is now an editable
field (Enter or Go jumps there, a full file path lands in its folder with the file
selected, and a typo keeps the listing you had instead of resetting to the root), the
listing can be sorted by name or modified time in either direction with folders always
first (the choice is remembered per device), and each entry shows a compact modified
time. `GET /api/filesystem/browse` entries carry `mtimeMs` to make that possible, with
one stat per entry.
### Patch Changes
- c211461: fix(terminal): swallow Ctrl+Z in agent sessions so it cannot suspend a running CLI
Ctrl+Z raises SIGTSTP on the pane's tty. In a `shell` session that is ordinary job control and
is left alone, but in an agent session suspending the CLI stops an unattended loop dead with no
visible output, the same failure shape as an XOFF freeze. The key is now swallowed in
`attachCustomKeyEventHandler` for every non-shell mode, and unconditionally in the
subagent/teammate terminals, which always run an agent CLI. The match is case-insensitive,
because Caps Lock flips `ev.key` to `'Z'` without setting `shiftKey` and a plain `=== 'z'`
check would let exactly the keystroke this exists to catch through.
This is defence in depth rather than a fix for the steady state: an agent CLI holds its tty in
raw mode with ISIG off, where ^Z is already inert. It covers the moments that are not the
steady state: the window before the CLI takes the tty at startup, and any point where it hands
the tty back. Two input paths are deliberately not covered and still reach the PTY: the mobile
keyboard accessory bar's one-shot Ctrl, and the CJK composition textarea when `cjkInputEnabled`
is on. Both are separate choke points to the PTY, and both are worth covering if this ever
turns out to matter in practice.
- 7767b16: fix(terminal): let Claude use truecolor so its themed backgrounds render
Claude draws the user's own messages as a block of background color, and it renders as an
approximation of the theme color at best. Claude's registry entry deleted `COLORTERM`, which
left it the only agent CLI here besides `opencode` not asking for 24-bit color, so every RGB
color its theme asks for was quantized down to whatever palette `TERM` alone implies. Claude
now exports `COLORTERM=truecolor` like codex, gemini, antigravity, pi, grok, deepseek and omp
already do, and the block renders in the color the theme actually names.
How bad the quantization was depends on `TERM`, which is why this looks different on different
machines. On tmux 3.2 and newer, whose `default-terminal` defaults to `tmux-256color`,
supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but
not the color the theme asked for. Where `TERM` resolves to a 16-color entry instead (tmux
older than 3.2, or a `~/.tmux.conf` setting `default-terminal screen`, which Codeman's tmux
server does read), every dark background collapses to `ESC[40m`, the terminal's own black, and
the block disappears entirely. That is the case this was reported from, and a custom Claude
theme could change the color there with nothing on screen moving.
Those seven CLIs also unset `NO_COLOR`; Claude does not, so a user who exports `NO_COLOR`
globally keeps the monochrome panes they asked for. `CLAUDECODE` stays unset, because Claude
reads it as a signal that it is running nested inside itself.
`buildClaudeEnv()`, the direct-PTY fallback used when tmux is unavailable, now reads the same
registry entry as the tmux pane and its attach client instead of deleting `COLORTERM` from a
hand-maintained list of its own. It applies that entry before assigning Codeman's own
variables, mirroring `buildEnvExports()`, so a `clis.json` override naming one of them cannot
strip it on this path while the tmux pane keeps it. A remote pane still exports nothing,
because `buildRemoteLaunchCommand()` never carried these declarations, so an SSH-remote Claude
session keeps the old rendering.
PR #3 introduced the `unset COLORTERM` in February, citing xterm.js#484 for the claim that
xterm.js mishandles truecolor, and aiming to fall back to 256-color mode. xterm.js closed that
issue in April 2019, Codeman now depends on `@xterm/xterm` 6, and `TmuxManager` sets
`terminal-overrides ",*:Tc"` on its own tmux server, so 24-bit color already reaches the
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
### Minor Changes
@@ -96,6 +206,15 @@
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.
### 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
### Minor Changes
@@ -189,6 +308,12 @@
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.
### 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
### Patch Changes
@@ -269,6 +394,13 @@
modules, handler counts, frontend module count and app.js size, install.sh size) and
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
### Patch Changes
@@ -368,6 +500,12 @@
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.
### 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
### Minor Changes
@@ -380,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.
### 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
### Minor Changes
+3 -1
View File
@@ -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.
**Version**: 1.27.0 (must match `package.json`)
**Version**: 1.28.1 (must match `package.json`)
## 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)
**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)
**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)
+3 -1
View File
@@ -18,9 +18,11 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
## Session launch modes
**Terminal colour env** — the stock registry decides each CLI's colour vars. Claude, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor`; `shell` and `opencode` unset it. All of those except Claude also unset `NO_COLOR`, so a user who exports `NO_COLOR` globally keeps monochrome Claude panes. The variable matters because a CLI inheriting no `COLORTERM` quantizes every RGB color it draws down to whatever palette `TERM` alone implies, and the pane's `TERM` is not a constant. ⚠️ Codeman sets no `default-terminal` and passes tmux no `-f`, so it is tmux's default (`tmux-256color` since 3.2) unless the user's own `~/.tmux.conf` says otherwise, which Codeman's server DOES read. At `tmux-256color` supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but not the color the theme named. Where `TERM` resolves to a 16-color entry instead (tmux older than 3.2, or a conf setting `default-terminal screen`) every dark background collapses to `ESC[40m`, the terminal's own black, and the block disappears entirely. That is why the same Claude theme looks different on two machines, and why a bug report here is worth pairing with the reporter's `tmux -V` and their pane's real `TERM`. ⚠️ These declarations reach the local tmux pane via `buildEnvExports()`, its attach client via `cliExportsTruecolor()`, and the direct-PTY fallback via `buildClaudeEnv()` — they do NOT reach a remote pane, which `buildRemoteLaunchCommand()` builds with no env exports at all. A Docker pane takes `COLORTERM=truecolor` from the hardcoded `envCreate`/`execEnv` in `tmux-manager.ts`, which apply to every mode including the two the registry says must unset it. A `~/.codeman/clis.json` override replaces these arrays wholesale (`deepMerge`), so a custom entry can drop either list. That is why both consumers apply the entry BEFORE Codeman's own variables rather than after: `buildEnvExports()` emits `...cliEnv` ahead of `export CODEMAN_MUX=1`, and `buildClaudeEnv()` assigns `PATH`/`TERM`/`CODEMAN_*` after its unset/export loop. Reversed, a config-supplied `unset` naming `CODEMAN_HOOK_SECRET_FILE` would strip it on one path and not the other.
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`.
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`.
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
+8 -4
View File
@@ -117,10 +117,14 @@ For choosing a path rather than typing one. It appears in two places:
- **Browse** in **Add Case → Link Existing**.
- The **📁 Path** key on the mobile keyboard bar.
It browses one directory at a time and can show hidden entries on request. The picker
inserts the path into your prompt **without** pressing Enter, so nothing is submitted by
accident. Its sibling **⌫ All** key clears the unsent prompt, and never sends the agent's
`/clear` command.
It browses one directory at a time and can show hidden entries on request. The current
folder is an editable field: type or paste a path and press Enter (or **Go**) to jump
straight there, and a full file path lands in its folder with that file selected. The
**Sort** control orders each listing by name or by modified time (newest first is the
quick way to the file an agent just wrote), with folders always ahead of files; the
choice is remembered per device. The picker inserts the path into your prompt
**without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All**
key clears the unsent prompt, and never sends the agent's `/clear` command.
This is a separate file-serving surface from the viewer, with its own rules: it allowlists
your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.27.0",
"version": "1.28.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.27.0",
"version": "1.28.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.27.0",
"version": "1.28.1",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+13 -2
View File
@@ -174,8 +174,19 @@ const CLAUDE: CliEntry = {
legacyConfigAliases: { resumeId: 'resumeSessionId' },
},
env: {
exports: [],
unset: ['CLAUDECODE', 'COLORTERM'],
// Claude asks for truecolor, like every CLI here except `shell` and `opencode`.
// tmux hands the pane TERM=screen, which supports-color reads as 16 colors, and
// Claude then quantizes every RGB color its theme asks for down to that palette.
// Each dark background lands on ESC[40m, the terminal's own black, so the block
// Claude draws behind the user's own messages renders invisible. PR #3 unset
// COLORTERM here against xterm.js#484, which xterm.js had already closed in 2019,
// and Codeman now ships @xterm/xterm 6 and sets `terminal-overrides *:Tc` itself.
// The other truecolor CLIs also unset NO_COLOR. Claude does not, so a user who
// exports NO_COLOR globally keeps the monochrome panes they asked for.
// CLAUDECODE stays unset, because Claude reads it as a signal that it is running
// nested inside itself.
exports: [{ name: 'COLORTERM', value: 'truecolor' }],
unset: ['CLAUDECODE'],
tmuxSetenvKeys: [],
dockerExecEnvNames: [],
allowedPrefixes: ['CLAUDE_CODE_'],
+32 -5
View File
@@ -13,6 +13,7 @@ import { isEffortLevel } from './types.js';
import { getAugmentedPath } from './utils/index.js';
import { compareVersions } from './utils/dependency-checker.js';
import { dataPath } from './config/instance.js';
import { getCli } from './config/cli-registry/registry.js';
/**
* Build Claude CLI permission flags based on the configured mode.
@@ -169,6 +170,36 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
...process.env,
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
};
// The colour and identity vars come from the registry entry, the same source
// buildEnvExports() and buildMuxAttachEnv() read, so this fallback cannot drift from
// the tmux pane the way a hand-maintained list here did.
// ⚠️ This block runs BEFORE Codeman's own keys are assigned, mirroring
// buildEnvExports(), where `...cliEnv` is emitted ahead of `export CODEMAN_MUX=1`.
// Applied afterwards it would outrank them: `unset` and `exports` are config
// (`~/.codeman/clis.json` overrides any entry), so an entry naming
// CODEMAN_HOOK_SECRET_FILE or PATH would strip or rewrite it on this path while the
// tmux pane, where Codeman's exports come last, kept its own value.
// COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined
// key as the literal string "KEY=undefined" (see buildMuxAttachEnv below).
const cliEnv = getCli('claude')?.env;
for (const name of cliEnv?.unset ?? []) delete env[name];
for (const item of cliEnv?.exports ?? []) {
// A direct PTY has no mux, so `muxName` has no value to resolve against. Claude
// declares literals only; an unresolvable engine value is skipped, never guessed.
const value =
typeof item.value === 'string'
? item.value
: item.value.engine === 'sessionId'
? sessionId
: item.value.engine === 'codemanPrefixedSessionId'
? `codeman_${sessionId}`
: undefined;
if (value !== undefined) env[item.name] = value;
}
Object.assign(env, {
PATH: getAugmentedPath(),
TERM: 'xterm-256color',
// Inform Claude it's running within Codeman (helps prevent self-termination)
@@ -180,11 +211,7 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
// as the literal "CODEMAN_API_URL=undefined" (COD-115).
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
};
// COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined
// key as the literal string "KEY=undefined" (see buildMuxAttachEnv below).
delete env.COLORTERM;
delete env.CLAUDECODE;
});
return env;
}
+2
View File
@@ -77,6 +77,8 @@ export interface FilesystemBrowseEntry {
path: string;
type: 'file' | 'directory';
size?: number;
/** Last-modified time (ms since epoch) of the entry's target; lets the picker sort by date. */
mtimeMs?: number;
symlink?: boolean;
previewKind?: FilesystemPreviewKind;
}
+50
View File
@@ -709,6 +709,54 @@ function resolveTerminalFontFamily(custom) {
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
// without a terminal, a clipboard, or a browser.
@@ -809,6 +857,8 @@ if (typeof window !== 'undefined') {
window.CodemanTerminalFont = {
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
resolve: resolveTerminalFontFamily,
WEIGHT_DEFAULTS: TERMINAL_FONT_WEIGHT_DEFAULTS,
resolveWeights: resolveTerminalFontWeights,
};
}
+6
View File
@@ -335,6 +335,12 @@
'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.':
'置于内置字体栈之前,回退字体(包括内置的 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': '本地回显',
'CJK Input': '中日韩输入',
'Extended Keyboard Bar': '扩展键盘栏',
+36
View File
@@ -1704,6 +1704,42 @@
</div>
<input type="text" id="appSettingsTerminalFont" class="set-input" placeholder='e.g. JetBrainsMono Nerd Font'>
</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>
+182 -13
View File
@@ -47,11 +47,24 @@
// the picker browses Home and every configured root, so wanting dotfiles in a
// project does not imply wanting them in ~.
const PATH_PICKER_SHOW_HIDDEN_KEY = 'codeman:pathPickerShowHidden';
// Per-device like the hidden toggle: how you scan a folder is a habit of the
// hand, not of the workspace.
const PATH_PICKER_SORT_KEY = 'codeman:pathPickerSort';
const PATH_PICKER_SORT_MODES = [
{ value: 'name-asc', label: 'Name A→Z' },
{ value: 'name-desc', label: 'Name Z→A' },
{ value: 'mtime-desc', label: 'Newest first' },
{ value: 'mtime-asc', label: 'Oldest first' },
];
const PATH_PICKER_DEFAULT_SORT = 'name-asc';
const PathPicker = {
overlay: null,
_options: null,
_selectedPath: '',
_currentPath: '',
_entries: [],
_truncated: false,
_previousFocus: null,
_keydownHandler: null,
_loadSequence: 0,
@@ -59,6 +72,7 @@ const PathPicker = {
_previewRequestSequence: 0,
_previewPreviousFocus: null,
_showHidden: false,
_sortMode: PATH_PICKER_DEFAULT_SORT,
/**
* Open the lazy filesystem browser.
@@ -69,7 +83,10 @@ const PathPicker = {
this.close(false);
this._options = options;
this._selectedPath = '';
this._currentPath = '';
this._entries = [];
this._showHidden = this._loadShowHidden();
this._sortMode = this._loadSortMode();
this._previousFocus = document.activeElement;
this._previousFocus?.blur?.();
@@ -90,11 +107,19 @@ const PathPicker = {
</div>
<div class="path-picker-nav">
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">&#x2191;</button>
<div class="path-picker-current" title="Current folder"></div>
<form class="path-picker-jump" title="Current folder — edit and press Enter to jump">
<input type="text" class="path-picker-current" aria-label="Current folder path" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go">
<button type="submit" class="path-picker-go" title="Go to this path" aria-label="Go to this path">Go</button>
</form>
<button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">&#x21BB;</button>
</div>
<div class="path-picker-status" aria-live="polite">Loading...</div>
<div class="path-picker-toolbar">
<div class="path-picker-status" aria-live="polite">Loading...</div>
<label class="path-picker-sort-label">Sort
<select class="path-picker-sort" aria-label="Sort entries"></select>
</label>
</div>
<div class="path-picker-list" role="listbox"></div>
<div class="path-picker-selection">
<span class="path-picker-selection-label">Selected</span>
@@ -114,12 +139,27 @@ const PathPicker = {
overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true));
overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm());
overlay.querySelector('.path-picker-current-select').addEventListener('click', () => {
const current = overlay.querySelector('.path-picker-current').textContent;
if (current) this.select(current);
if (this._currentPath) this.select(this._currentPath);
});
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load(this._currentPath));
overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden());
this._syncHiddenButton();
// Typing a path is the fast way there. The listing is loaded ONLY on Enter/Go,
// never on each keystroke: a half-typed path is a 404 the server has to
// answer for nothing, and jumping mid-edit would yank the field around.
overlay.querySelector('.path-picker-jump').addEventListener('submit', (event) => {
event.preventDefault();
this.jumpTo(overlay.querySelector('.path-picker-current').value);
});
const sortSelect = overlay.querySelector('.path-picker-sort');
for (const mode of PATH_PICKER_SORT_MODES) {
const option = document.createElement('option');
option.value = mode.value;
option.textContent = mode.label;
sortSelect.appendChild(option);
}
sortSelect.value = this._sortMode;
sortSelect.addEventListener('change', (event) => this.setSortMode(event.target.value));
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
if (parent) this.load(parent);
@@ -169,15 +209,97 @@ const PathPicker = {
// OFF inside a hidden folder makes the current path unbrowsable again; the
// server answers 403 and load()'s catch falls back to the default root,
// which is the only place left to stand.
this.load(this.overlay.querySelector('.path-picker-current').textContent || '');
this.load(this._currentPath || '');
},
async load(path) {
_loadSortMode() {
try {
const stored = localStorage.getItem(PATH_PICKER_SORT_KEY);
return PATH_PICKER_SORT_MODES.some((mode) => mode.value === stored) ? stored : PATH_PICKER_DEFAULT_SORT;
} catch {
return PATH_PICKER_DEFAULT_SORT;
}
},
setSortMode(mode) {
if (!PATH_PICKER_SORT_MODES.some((candidate) => candidate.value === mode)) return;
this._sortMode = mode;
try {
localStorage.setItem(PATH_PICKER_SORT_KEY, mode);
} catch {}
const select = this.overlay?.querySelector('.path-picker-sort');
if (select && select.value !== mode) select.value = mode;
// Re-order what is already on screen; no round trip, no lost selection.
if (this.overlay) this.renderEntries();
},
/**
* Order entries for display. Folders always come first, whatever the mode:
* a date sort is for finding the file you just made, and the folders are the
* way past it, not the thing being looked for. An entry without an mtime (an
* older server, the in-container source) sorts after every dated one and then
* by name, so a listing never degrades into an unstable order.
*
* ⚠️ This re-orders the listing the SERVER returned, and the server cuts at
* FILESYSTEM_PICKER_ENTRY_LIMIT (500) after sorting by name. So in a folder past
* that limit, "Newest first" is the newest of the first 500 BY NAME, not the newest
* in the folder, which is the one case this sort exists for. The status line says
* "(first 500 by name)" rather than "(first 500)" so the cut is legible; ordering
* before the cut would have to happen server-side, and would cost a stat on every
* entry in the directory rather than on the 500 that are returned.
*/
_sortEntries(entries) {
const [key, direction] = this._sortMode.split('-');
const sign = direction === 'desc' ? -1 : 1;
const byName = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
return entries.slice().sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
if (key === 'mtime') {
const aTime = typeof a.mtimeMs === 'number' ? a.mtimeMs : null;
const bTime = typeof b.mtimeMs === 'number' ? b.mtimeMs : null;
if (aTime !== null && bTime !== null && aTime !== bTime) return sign * (aTime - bTime);
if (aTime === null && bTime !== null) return 1;
if (aTime !== null && bTime === null) return -1;
return byName(a, b);
}
return sign * byName(a, b);
});
},
/** Compact modified-time label: time of day today, month-day this year, else the date. */
_formatModified(mtimeMs) {
if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return '';
const date = new Date(mtimeMs);
if (Number.isNaN(date.getTime())) return '';
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
if (date.toDateString() === now.toDateString()) return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
if (date.getFullYear() === now.getFullYear()) return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
},
/**
* Go to a path the user typed. A file path lands in its folder with the
* file selected, so pasting a full path from a log or a message is one Enter
* away from Select. A path that does not resolve lands in its parent folder
* when that exists (the closest place to stand) and otherwise keeps the
* current listing, and says so either way — unlike a stale initialPath, a
* typo is not a reason to throw the user back to the root.
*/
jumpTo(rawPath) {
const path = String(rawPath || '').trim();
if (!path) return;
this.load(path, { typed: true });
},
async load(path, options = {}) {
if (!this.overlay || !this._options) return;
const loadSequence = ++this._loadSequence;
const list = this.overlay.querySelector('.path-picker-list');
const status = this.overlay.querySelector('.path-picker-status');
list.replaceChildren();
const typed = !!options.typed;
if (!typed) list.replaceChildren();
status.classList.remove('error');
status.textContent = 'Loading...';
const params = new URLSearchParams();
@@ -194,13 +316,40 @@ const PathPicker = {
if (!result?.success) throw new Error(result?.error || 'Failed to browse this folder');
if (!this.overlay || loadSequence !== this._loadSequence) return;
this.render(result.data);
if (options.selectIfListed) {
// Landed in the typed path's folder: select the entry if it is there
// (a file path), otherwise say what the server said about the full
// path — the listing is still the closest place to stand.
if (this._entries.some((entry) => entry.path === options.selectIfListed)) {
this.select(options.selectIfListed);
} else {
status.textContent = options.failMessage || 'Path not found';
status.classList.add('error');
}
}
} catch (error) {
if (!this.overlay || loadSequence !== this._loadSequence) return;
const message = error.message || 'Failed to browse this folder';
if (typed) {
// The browse endpoint answers a FILE path with "not found" (it resolves
// folders only), so one retry lands in the parent folder and selects
// the entry from the listing. Only one level: a typo two segments up
// is an error, not a reason to climb to the root.
const slash = path.lastIndexOf('/');
if (slash > 0 && !options.selectIfListed) {
this.load(path.slice(0, slash), { typed: true, selectIfListed: path, failMessage: message });
return;
}
this.renderEntries();
status.textContent = options.failMessage || message;
status.classList.add('error');
return;
}
if (path) {
this.load('');
return;
}
status.textContent = error.message || 'Failed to browse this folder';
status.textContent = message;
status.classList.add('error');
}
},
@@ -216,19 +365,29 @@ const PathPicker = {
rootSelect.appendChild(option);
}
this.overlay.querySelector('.path-picker-current').textContent = data.path;
this._currentPath = data.path;
this.overlay.querySelector('.path-picker-current').value = data.path;
const up = this.overlay.querySelector('.path-picker-up');
up.dataset.parent = data.parent || '';
up.disabled = !data.parent;
this._entries = Array.isArray(data.entries) ? data.entries : [];
this._truncated = !!data.truncated;
this.renderEntries();
},
/** (Re)build the list from the last listing in the current sort order. */
renderEntries() {
if (!this.overlay) return;
const entries = this._sortEntries(this._entries);
const status = this.overlay.querySelector('.path-picker-status');
status.classList.remove('error');
status.textContent = data.entries.length === 0
status.textContent = entries.length === 0
? 'This folder is empty'
: `${data.entries.length} item${data.entries.length === 1 ? '' : 's'}${data.truncated ? ' (first 500)' : ''}`;
: `${entries.length} item${entries.length === 1 ? '' : 's'}${this._truncated ? ' (first 500 by name)' : ''}`;
const list = this.overlay.querySelector('.path-picker-list');
list.replaceChildren();
for (const entry of data.entries) {
for (const entry of entries) {
const row = document.createElement('div');
row.className = 'path-picker-item';
if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) {
@@ -248,6 +407,14 @@ const PathPicker = {
name.className = 'path-picker-item-name';
name.textContent = entry.name;
open.append(icon, name);
const modified = this._formatModified(entry.mtimeMs);
if (modified) {
const meta = document.createElement('span');
meta.className = 'path-picker-item-meta';
meta.textContent = modified;
meta.title = new Date(entry.mtimeMs).toLocaleString();
open.appendChild(meta);
}
if (entry.symlink) {
const link = document.createElement('span');
link.className = 'path-picker-item-link';
@@ -417,6 +584,8 @@ const PathPicker = {
this._previousFocus = null;
this._options = null;
this._selectedPath = '';
this._currentPath = '';
this._entries = [];
if (restoreFocus) previousFocus?.focus?.();
},
};
+26 -1
View File
@@ -2282,10 +2282,14 @@ Object.assign(CodemanApp.prototype, {
return;
}
const fontSettings = this.loadAppSettingsFromStorage?.() || {};
const terminal = new Terminal({
theme: { ...window.codemanCurrentXtermTheme() },
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,
lineHeight: 1.2,
cursorBlink: true,
@@ -2314,6 +2318,27 @@ Object.assign(CodemanApp.prototype, {
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(() => {
+42 -1
View File
@@ -335,6 +335,32 @@ Object.assign(CodemanApp.prototype, {
// 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() {
// Load current settings
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.
document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true;
document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || '';
this.populateTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight'), settings.terminalFontWeight);
this.populateTerminalFontWeight(
document.getElementById('appSettingsTerminalFontWeightBold'),
settings.terminalFontWeightBold
);
document.getElementById('appSettingsTerminalWheelLocal').checked =
settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false;
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
@@ -2091,6 +2122,10 @@ Object.assign(CodemanApp.prototype, {
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
terminalFontWeight: this.readTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight')),
terminalFontWeightBold: this.readTerminalFontWeight(
document.getElementById('appSettingsTerminalFontWeightBold')
),
terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked,
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
@@ -2145,6 +2180,7 @@ Object.assign(CodemanApp.prototype, {
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
this.applyTerminalFontFamily?.(settings.terminalFontFamily);
this.applyTerminalFontWeights?.(settings);
// 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
@@ -2302,6 +2338,11 @@ Object.assign(CodemanApp.prototype, {
// Per-device by nature (the font must exist on the device) and absent
// from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT.
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
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
showSessionButton: _ssb,
@@ -3076,7 +3117,7 @@ Object.assign(CodemanApp.prototype, {
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
'terminalWheelLocalScrollback',
'autoCopySelection',
+101 -18
View File
@@ -9,11 +9,20 @@
font-weight: 400 800;
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-family: 'JetBrains Mono';
font-style: normal;
font-display: swap;
font-weight: 400 700;
font-weight: 100 800;
src: url('fonts/jetbrains-mono-variable.woff2') format('woff2');
}
/* Icons-only per-glyph fallback for the terminal (Symbols Nerd Font Mono, MIT,
@@ -545,18 +554,21 @@ body {
contain: layout;
}
.session-tabs.tabs-two-rows {
flex-wrap: wrap;
overflow-x: hidden;
overflow-y: auto;
max-height: 120px;
}
/* A wrapped strip GROWS the header instead of scrolling inside it. The old
fixed caps (120px tall-tabs / 96px auto-wrap) were row counts in disguise:
a third row of tabs was clipped into a 4px scroller, so the tabs you were
looking for were off-screen in a container nothing tells you to scroll —
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 {
flex-wrap: wrap;
overflow-x: hidden;
overflow-y: auto;
max-height: 96px;
max-height: var(--tab-strip-max-height, 40vh);
}
.session-tabs::-webkit-scrollbar {
@@ -13617,24 +13629,95 @@ body.touch-device.cjk-input-visible .main {
cursor: default;
}
.path-picker-current {
/* The current path is an editable field: type or paste a path and press Enter
* (or Go) to jump there. The Go button is part of the same rounded control so
* the field keeps the width the read-only breadcrumb had. */
.path-picker-jump {
display: flex;
flex: 1;
min-width: 0;
padding: 9px 11px;
overflow-x: auto;
color: var(--accent);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
white-space: nowrap;
margin: 0;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
}
.path-picker-status {
padding: 0 14px 8px;
.path-picker-jump:focus-within {
border-color: var(--accent);
}
.path-picker-current {
flex: 1;
min-width: 0;
height: 36px;
padding: 0 10px;
color: var(--accent);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
white-space: nowrap;
background: transparent;
border: 0;
outline: none;
}
.path-picker-go {
flex: 0 0 auto;
padding: 0 10px;
color: var(--text-dim);
font-size: 0.7rem;
font-weight: 600;
background: transparent;
border: 0;
border-left: 1px solid var(--border);
cursor: pointer;
}
.path-picker-go:hover {
color: var(--accent);
}
.path-picker-toolbar {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 0 14px 8px;
}
.path-picker-status {
min-width: 0;
overflow: hidden;
color: var(--text-dim);
font-size: 0.7rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-picker-sort-label {
display: inline-flex;
flex: 0 0 auto;
gap: 6px;
align-items: center;
color: var(--text-dim);
font-size: 0.7rem;
}
.path-picker-sort {
padding: 3px 6px;
color: var(--text);
font-size: 0.7rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
}
.path-picker-item-meta {
flex: 0 0 auto;
margin-left: 8px;
color: var(--text-dim);
font-family: var(--font-mono, monospace);
font-size: 0.65rem;
white-space: nowrap;
}
.path-picker-status.error {
+79 -1
View File
@@ -248,9 +248,13 @@ Object.assign(CodemanApp.prototype, {
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;
this._destroyKeyCode229Recovery();
const fontSettings = this.loadAppSettingsFromStorage?.() || {};
this.terminal = new Terminal({
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)
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
lineHeight: 1.2,
@@ -383,6 +387,29 @@ Object.assign(CodemanApp.prototype, {
return false;
}
// Ctrl+Z (SIGTSTP/job-control suspend): in a plain shell session this is the
// user's own job-control tool (suspend a foreground command, `fg` it back) —
// leave it alone. In every other mode (claude/omp/pi/codex/... — Ink/TUI apps
// that normally run in raw mode with ISIG off, so ^Z is usually inert there
// already) suspending the CLI stops an unattended agent loop dead with no
// visible output — the same failure shape as an XOFF freeze. Swallow it
// before xterm can send \x1a into the PTY rather than relying on every CLI's
// raw-mode state holding at every instant (startup, raw-mode toggles, etc).
if (
ev.type === 'keydown' &&
ev.key.toLowerCase() === 'z' &&
ev.ctrlKey &&
!ev.altKey &&
!ev.metaKey &&
!ev.shiftKey
) {
const activeCtrlZSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
if (activeCtrlZSession && activeCtrlZSession.mode !== 'shell') {
ev.preventDefault();
return false;
}
}
// Shift+Enter / Ctrl+Enter: insert newline for multi-line input.
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
@@ -4882,6 +4909,57 @@ Object.assign(CodemanApp.prototype, {
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() {
const saved = localStorage.getItem('codeman-font-size');
if (saved) {
+10 -3
View File
@@ -876,6 +876,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
let type: FilesystemBrowseEntry['type'];
let size: number | undefined;
let mtimeMs: number | undefined;
const symlink = entry.isSymbolicLink();
if (entry.isDirectory()) {
type = 'directory';
@@ -886,6 +887,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const targetStat = await fs.stat(targetPath);
type = targetStat.isDirectory() ? 'directory' : 'file';
if (type === 'file') size = targetStat.size;
mtimeMs = targetStat.mtimeMs;
} catch {
continue;
}
@@ -894,11 +896,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue;
if (type === 'file' && size === undefined) {
if (mtimeMs === undefined) {
// One stat per entry: the modified time lets the picker sort by date, and
// a file's size rides along on the same call.
try {
size = (await fs.stat(targetPath)).size;
const targetStat = await fs.stat(targetPath);
mtimeMs = targetStat.mtimeMs;
if (type === 'file') size = targetStat.size;
} catch {
// The path is still selectable even when a size lookup races a change.
// The path is still selectable even when a stat races a change.
}
}
entries.push({
@@ -906,6 +912,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
path: visiblePath,
type,
size,
mtimeMs,
symlink: symlink || undefined,
previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined,
});
+294
View File
@@ -0,0 +1,294 @@
/**
* @fileoverview Path picker: sort order and the editable path field.
*
* Same jsdom harness as path-picker-hidden.test.ts: keyboard-accessory.js is
* evaluated against a jsdom window with a scripted fetch, so the assertions
* run against the real DOM the picker builds rather than string matches.
* Port: N/A
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const SORT_KEY = 'codeman:pathPickerSort';
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const jsdomDocument = jsdomWindow.document;
function loadPathPicker(fetchImpl: (url: string) => Promise<unknown>): any {
const MobileDetection = { isTouchDevice: () => false };
const factory = new Function(
'window',
'document',
'localStorage',
'fetch',
'MobileDetection',
`${accessoryJs}\nreturn PathPicker;`
);
return factory(jsdomWindow, jsdomDocument, jsdomWindow.localStorage, fetchImpl, MobileDetection);
}
type Entry = { name: string; type: 'file' | 'directory'; mtimeMs?: number };
function browseResponse(entries: Entry[], path = '/home/dev/project') {
return {
ok: true,
json: async () => ({
success: true,
data: {
path,
parent: path === '/home/dev' ? null : '/home/dev',
root: '/home/dev',
roots: [{ label: 'Home', path: '/home/dev' }],
entries: entries.map((e) => ({ ...e, path: `${path}/${e.name}` })),
truncated: false,
},
}),
};
}
function errorResponse(error: string) {
return { ok: false, json: async () => ({ success: false, error }) };
}
const DAY = 24 * 60 * 60 * 1000;
const NOW = Date.now();
const LISTING: Entry[] = [
{ name: 'zeta.txt', type: 'file', mtimeMs: NOW - 3 * DAY },
{ name: 'alpha.txt', type: 'file', mtimeMs: NOW - 1 * DAY },
{ name: 'mid.txt', type: 'file', mtimeMs: NOW - 2 * DAY },
{ name: 'old-dir', type: 'directory', mtimeMs: NOW - 30 * DAY },
{ name: 'new-dir', type: 'directory', mtimeMs: NOW - 1000 },
];
describe('PathPicker sort order', () => {
let PathPicker: any;
let urls: string[];
let respond: (url: string) => unknown;
beforeEach(() => {
jsdomWindow.localStorage.clear();
jsdomDocument.body.replaceChildren();
urls = [];
respond = () => browseResponse(LISTING);
PathPicker = loadPathPicker(async (url: string) => {
urls.push(url);
return respond(url);
});
});
afterEach(() => {
PathPicker?.close?.(false);
jsdomDocument.body.replaceChildren();
});
const open = async (options: Record<string, unknown> = {}) => {
PathPicker.open({ onSelect: () => {}, ...options });
await vi.waitFor(() => expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0));
};
const names = () => Array.from(jsdomDocument.querySelectorAll('.path-picker-item-name')).map((el) => el.textContent);
const sortSelect = () => jsdomDocument.querySelector('.path-picker-sort') as HTMLSelectElement;
const setSort = (value: string) => {
sortSelect().value = value;
sortSelect().dispatchEvent(new jsdomWindow.Event('change', { bubbles: true }));
};
it('sorts by name with folders first by default', async () => {
await open();
expect(sortSelect().value).toBe('name-asc');
expect(names()).toEqual(['new-dir', 'old-dir', 'alpha.txt', 'mid.txt', 'zeta.txt']);
});
it('re-orders the listing without another request, keeping folders first', async () => {
await open();
const requests = urls.length;
setSort('mtime-desc');
expect(names()).toEqual(['new-dir', 'old-dir', 'alpha.txt', 'mid.txt', 'zeta.txt']);
setSort('mtime-asc');
expect(names()).toEqual(['old-dir', 'new-dir', 'zeta.txt', 'mid.txt', 'alpha.txt']);
setSort('name-desc');
expect(names()).toEqual(['old-dir', 'new-dir', 'zeta.txt', 'mid.txt', 'alpha.txt']);
expect(urls.length).toBe(requests);
});
it('remembers the sort mode across reopenings', async () => {
await open();
setSort('mtime-desc');
expect(jsdomWindow.localStorage.getItem(SORT_KEY)).toBe('mtime-desc');
PathPicker.close(false);
await open();
expect(sortSelect().value).toBe('mtime-desc');
});
it('ignores a corrupt stored mode and a localStorage that throws', async () => {
jsdomWindow.localStorage.setItem(SORT_KEY, 'bogus');
await open();
expect(sortSelect().value).toBe('name-asc');
PathPicker.close(false);
const getItem = vi.spyOn(jsdomWindow.localStorage.__proto__, 'getItem').mockImplementation(() => {
throw new Error('private mode');
});
try {
await open();
expect(sortSelect().value).toBe('name-asc');
setSort('mtime-asc');
expect(names()[0]).toBe('old-dir');
} finally {
getItem.mockRestore();
}
});
it('places entries without a modified time after dated ones on a date sort', async () => {
respond = () =>
browseResponse([
{ name: 'undated.txt', type: 'file' },
{ name: 'dated.txt', type: 'file', mtimeMs: NOW - DAY },
]);
await open();
setSort('mtime-desc');
expect(names()).toEqual(['dated.txt', 'undated.txt']);
setSort('mtime-asc');
expect(names()).toEqual(['dated.txt', 'undated.txt']);
});
it('shows a compact modified time only when the server supplied one', async () => {
respond = () =>
browseResponse([
{ name: 'undated.txt', type: 'file' },
{ name: 'today.txt', type: 'file', mtimeMs: NOW },
]);
await open();
const rows = Array.from(jsdomDocument.querySelectorAll('.path-picker-item'));
const meta = (row: Element) => row.querySelector('.path-picker-item-meta')?.textContent ?? null;
expect(meta(rows[0])).toMatch(/^\d{2}:\d{2}$/);
expect(meta(rows[1])).toBeNull();
});
it('styles the sort control and the modified column', () => {
expect(stylesCss).toContain('.path-picker-sort {');
expect(stylesCss).toContain('.path-picker-item-meta {');
});
});
describe('PathPicker editable path', () => {
let PathPicker: any;
let urls: string[];
let respond: (url: string) => unknown;
beforeEach(() => {
jsdomWindow.localStorage.clear();
jsdomDocument.body.replaceChildren();
urls = [];
respond = () => browseResponse(LISTING);
PathPicker = loadPathPicker(async (url: string) => {
urls.push(url);
return respond(url);
});
});
afterEach(() => {
PathPicker?.close?.(false);
jsdomDocument.body.replaceChildren();
});
const open = async (options: Record<string, unknown> = {}) => {
PathPicker.open({ onSelect: () => {}, ...options });
await vi.waitFor(() => expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0));
};
const field = () => jsdomDocument.querySelector('.path-picker-current') as HTMLInputElement;
const submit = (value: string) => {
field().value = value;
(jsdomDocument.querySelector('.path-picker-jump') as HTMLFormElement).dispatchEvent(
new jsdomWindow.Event('submit', { bubbles: true, cancelable: true })
);
};
const pathParam = (url: string) => new URL(url, 'https://localhost').searchParams.get('path');
const status = () => jsdomDocument.querySelector('.path-picker-status') as HTMLElement;
it('shows the current folder in an editable field and jumps on Enter', async () => {
await open({ initialPath: '/home/dev/project' });
expect(field().value).toBe('/home/dev/project');
const before = urls.length;
// Typing alone never fetches.
field().value = '/home/dev/oth';
field().dispatchEvent(new jsdomWindow.Event('input', { bubbles: true }));
expect(urls.length).toBe(before);
respond = () => browseResponse([{ name: 'readme.md', type: 'file' }], '/home/dev/other');
submit(' /home/dev/other ');
await vi.waitFor(() => expect(field().value).toBe('/home/dev/other'));
expect(pathParam(urls[urls.length - 1])).toBe('/home/dev/other');
expect(jsdomDocument.querySelector('.path-picker-item-name')?.textContent).toBe('readme.md');
});
it('keeps the current listing and reports the error when neither a typed path nor its parent resolves', async () => {
await open({ initialPath: '/home/dev/project' });
respond = () => errorResponse('Path not found: /home/dev/nope/deeper');
submit('/home/dev/nope/deeper');
await vi.waitFor(() => expect(status().classList.contains('error')).toBe(true));
expect(status().textContent).toBe('Path not found: /home/dev/nope/deeper');
// One retry on the parent, then stop: never a climb to the root.
expect(urls.slice(-2).map(pathParam)).toEqual(['/home/dev/nope/deeper', '/home/dev/nope']);
expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBe(LISTING.length);
// The typed text stays in the field so the typo can be corrected in place.
expect(field().value).toBe('/home/dev/nope/deeper');
});
it('lands in the parent folder, unselected, when only the last segment is wrong', async () => {
await open({ initialPath: '/home/dev' });
respond = (url) =>
pathParam(url) === '/home/dev/project/typo.txt'
? errorResponse('Path not found: /home/dev/project/typo.txt')
: browseResponse(LISTING);
submit('/home/dev/project/typo.txt');
await vi.waitFor(() => expect(field().value).toBe('/home/dev/project'));
await vi.waitFor(() => expect(status().classList.contains('error')).toBe(true));
expect(status().textContent).toBe('Path not found: /home/dev/project/typo.txt');
expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe('None');
expect((jsdomDocument.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(true);
});
it('lands a typed file path in its folder with the file selected', async () => {
await open({ initialPath: '/home/dev/project' });
respond = (url) =>
pathParam(url) === '/home/dev/project/alpha.txt'
? errorResponse('Path not found: /home/dev/project/alpha.txt')
: browseResponse(LISTING);
submit('/home/dev/project/alpha.txt');
await vi.waitFor(() =>
expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe(
'/home/dev/project/alpha.txt'
)
);
expect(field().value).toBe('/home/dev/project');
expect(jsdomDocument.querySelector('.path-picker-item.selected .path-picker-item-name')?.textContent).toBe(
'alpha.txt'
);
expect((jsdomDocument.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(false);
});
it('selects the current folder from the field value and refreshes in place', async () => {
await open({ initialPath: '/home/dev/project' });
(jsdomDocument.querySelector('.path-picker-current-select') as HTMLButtonElement).click();
expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe('/home/dev/project');
const before = urls.length;
(jsdomDocument.querySelector('.path-picker-refresh') as HTMLButtonElement).click();
await vi.waitFor(() => expect(urls.length).toBe(before + 1));
expect(pathParam(urls[urls.length - 1])).toBe('/home/dev/project');
});
});
+44
View File
@@ -115,6 +115,50 @@ describe('file-routes', () => {
]);
});
it('stamps every entry with its modified time so the picker can sort by date', async () => {
mockedReaddir.mockResolvedValueOnce([
{ name: 'notes.txt', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
{ name: 'link', isDirectory: () => false, isFile: () => false, isSymbolicLink: () => true },
] as never);
mockedStat.mockImplementation(async (candidate) => {
const target = String(candidate);
if (target.endsWith('/notes.txt')) {
return { size: 42, mtimeMs: 1_700_000_000_000, isFile: () => true, isDirectory: () => false } as never;
}
if (target.endsWith('/src')) {
return { size: 4096, mtimeMs: 1_700_000_001_000, isFile: () => false, isDirectory: () => true } as never;
}
if (target.endsWith('/link')) {
return { size: 7, mtimeMs: 1_700_000_002_000, isFile: () => true, isDirectory: () => false } as never;
}
return { size: 0, mtimeMs: 0, isFile: () => false, isDirectory: () => true } as never;
});
const path = harness.ctx._session.workingDir;
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
});
expect(res.statusCode).toBe(200);
const entries = JSON.parse(res.body).data.entries as Array<{
name: string;
type: string;
size?: number;
mtimeMs?: number;
}>;
expect(entries.map((entry) => [entry.name, entry.type, entry.size, entry.mtimeMs])).toEqual([
['src', 'directory', undefined, 1_700_000_001_000],
['link', 'file', 7, 1_700_000_002_000],
['notes.txt', 'file', 42, 1_700_000_000_000],
]);
// One stat per entry: the date and the size ride on the same call.
const statsFor = (name: string) =>
mockedStat.mock.calls.filter(([candidate]) => String(candidate).endsWith(`/${name}`)).length;
expect([statsFor('notes.txt'), statsFor('src'), statsFor('link')]).toEqual([1, 1, 1]);
});
it('defaults to the Codeman Cases root, not Home, when linking a case with no path chosen yet', async () => {
// The "Link Existing" case picker opens with an empty path and no
// sessionId. `Home` and `Codeman Cases` are unrelated bind mounts under
+225
View File
@@ -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)');
});
});
+59
View File
@@ -12,6 +12,8 @@ function loadFontHelper() {
CodemanTerminalFont: {
DEFAULT_STACK: string;
resolve: (custom?: unknown) => string;
WEIGHT_DEFAULTS: { fontWeight: string; fontWeightBold: string };
resolveWeights: (settings?: unknown) => { fontWeight: string | number; fontWeightBold: string | number };
};
}
).CodemanTerminalFont;
@@ -56,3 +58,60 @@ describe('CodemanTerminalFont', () => {
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);
});
});
+7
View File
@@ -295,6 +295,13 @@ describe('TmuxManager (unit)', () => {
expect(callBuildEnvExports('opencode')).toContain('unset COLORTERM');
});
// Claude renders its themed backgrounds as RGB. Without this the pane inherits
// tmux's TERM=screen, supports-color reads 16 colors, and every dark background
// quantizes to ESC[40m — the terminal's own black — so the block goes invisible.
it('exports truecolor for Claude sessions', () => {
expect(callBuildEnvExports('claude')).toContain('export COLORTERM=truecolor');
});
it('exports the server-stamped CODEMAN_API_URL verbatim', () => {
const original = process.env.CODEMAN_API_URL;
process.env.CODEMAN_API_URL = 'https://127.0.0.1:3199';