mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edeaa15986 | ||
|
|
d2ff1814ed | ||
|
|
9e2091255b | ||
|
|
6030a520bd | ||
|
|
465b842e97 | ||
|
|
c4b74415ee | ||
|
|
d8a9e2f2bb | ||
|
|
708cb2cbf0 | ||
|
|
90ac13da1a | ||
|
|
48f30f3055 | ||
|
|
c211461500 | ||
|
|
37929cb671 | ||
|
|
e0ebbbdc91 | ||
|
|
8c237223b0 | ||
|
|
dae2ac580f | ||
|
|
7767b16d4f | ||
|
|
a017e9a8e0 | ||
|
|
65ddedd1d4 | ||
|
|
8b23f3e260 | ||
|
|
02b0e27898 | ||
|
|
9d664ffe01 | ||
|
|
a28b04c368 | ||
|
|
e35b68e253 | ||
|
|
77d9ad59f7 | ||
|
|
0cedf05d13 | ||
|
|
d9eeb039db | ||
|
|
bd61735393 | ||
|
|
58b4cb06d8 | ||
|
|
797f0d387c | ||
|
|
88243e9ffa | ||
|
|
0a5bc1ac2e | ||
|
|
e8a93ada1f | ||
|
|
82b090c74a |
+163
@@ -1,5 +1,136 @@
|
||||
# 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
|
||||
|
||||
- Session lists that answer "which of these wants me next?", loopback links that work from a phone, and a batch of input and remote-session fixes.
|
||||
|
||||
**The vertical tab rail sorts by activity and wears the home screen's cards.** A new per-device setting (App Settings → Appearance → Tabs → **Vertical Rail Order**, default _By activity_) orders rail rows with the same comparator both home screens use: whatever is blocked on you first, then whatever has been running longest, then the most recently quiet. Detailed rail rows become cards, with the state dot keeping its working ring and gaining the home rail's green halo. ⚠️ Existing vertical-rail users get sorting on upgrade, and a self-sorting list cannot also be drag-reorderable: choose _Manual_ to get your own order and drag-reordering back. The lineage bracket also moves 4px further from the rail's left edge, where its glow was being clipped by the window frame.
|
||||
|
||||
**The Claude Response Viewer's brief view shows the whole last turn.** It used to render one row, so the eye button often showed the "Done." tail of an answer whose substance was in the rows above it. A multi-row turn now also opens at its newest text instead of its first narration line.
|
||||
|
||||
**A `localhost` link in agent output opens as a proxied web tab.** An agent prints `http://localhost:5173/` and you tap it on a phone: that address only exists on the Codeman box, so the link was a guaranteed connection error from any other device. It now opens through the proxy, reusing a saved dashboard for the same dev server (one tab per server, not per host spelling) or saving one under its `host:port`. LAN and tailnet addresses still open directly, and on the box itself every link opens directly. `*.localhost` is deliberately not auto-routed: it is the only spelling that is a DNS name rather than an address literal, and these links come from agent output; add such a dashboard by hand instead. Trusted (non-sandboxed) dashboards are likewise never auto-reused by a tapped link.
|
||||
|
||||
**Remote omp and remote claude sessions continue their conversation across a respawn or reattach.** Remote claude now launches an idempotent `--session-id || --resume` pair and remote omp respawns with `--continue`, instead of starting a fresh conversation each time. An omp session id is never resolved from the local `~/.omp` for a remote session, which would have pinned an unrelated local conversation.
|
||||
|
||||
**Android and IME keyboards no longer drop committed characters.** Chrome on Android delivers a `composed: true` input event preceded by a keydown, which is exactly the shape xterm refuses to forward, so the character vanished. A recovery controller forwards it when, and only when, xterm produced nothing for that keystroke, so dictation and soft-keyboard input cannot be delivered twice either.
|
||||
|
||||
### Thanks
|
||||
- **@shenlvkang-collab** for the Response Viewer last-turn fix (#400) and for loopback links as web tabs (#401), both carefully measured, #400 against 285 real transcripts.
|
||||
- **@timkjr** for remote-omp resume/continue through respawn and reattach (#362), including dropping a half that had already landed and verifying the merge kept none of it.
|
||||
- **@aakhter** for the Android/IME input recovery (#388), and in particular for finding that an earlier version of their own browser test was passing vacuously, and saying so.
|
||||
|
||||
## 1.26.2
|
||||
|
||||
### Patch Changes
|
||||
@@ -75,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
|
||||
@@ -168,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
|
||||
@@ -248,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
|
||||
@@ -347,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
|
||||
@@ -359,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
|
||||
|
||||
@@ -26,6 +26,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/opencode-resize.test.ts',
|
||||
'test/webgl-fallback.test.ts',
|
||||
'test/terminal-copy-shortcut.test.ts',
|
||||
'test/terminal-keycode229-recovery.browser.test.ts',
|
||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||
];
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -52,7 +54,7 @@ Model is NOT a session field: it is a composition entry in the profile's config
|
||||
|
||||
### Remote SSH cases
|
||||
|
||||
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to `exec claude --dangerously-skip-permissions`; per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`.
|
||||
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`.
|
||||
|
||||
### Docker cases
|
||||
|
||||
@@ -361,7 +363,7 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
|
||||
|
||||
⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its *launch* conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a *stale* anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation.
|
||||
|
||||
⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per *row* was the original bug (#169) — but the fix overshot to one card per *human turn*, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.claude/projects` (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no `message.id` carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
|
||||
⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per *row* was the original bug (#169) — but the fix overshot to one card per *human turn*, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.claude/projects` (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no `message.id` carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. The BRIEF view therefore does not render `text`: it asks for `?context=turn`, which returns the assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn()` in `src/web/response-viewer-transcript.ts`: the highest `turn` that has an assistant row, so a prompt queued after the answer does not blank the view), and renders them with the same numeric-`turn` continuation gate as the full view. `text` alone was the final row of a median-3-row turn — a "Done." tail with the substance in the rows before it. Readers that emit no `turn` (Codex, the pane parser, an older server) return `text` only, and the brief view falls back to one card for them. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
|
||||
**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Header & Panels → Header buttons → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Header & Panels → Scheduling); cron jobs themselves are unaffected.
|
||||
|
||||
### Session list layout (header strip vs. left sidebar)
|
||||
|
||||
@@ -153,6 +153,14 @@ shared nor seeded.
|
||||
include `~/.local/bin`. Per-session config and `envOverrides` do not cross ssh and are
|
||||
rejected rather than silently ignored; use the per-host command override instead.
|
||||
|
||||
⚠️ A **respawn or reattach** of a remote omp session runs `omp --continue`, not a
|
||||
bare `omp`, so it lands back in the same conversation. It is deliberately
|
||||
`--continue` rather than the exact `--resume <id>` the local and docker paths
|
||||
pin: `omp-session-resolver.ts` only ever reads THIS host's `~/.omp/agent/sessions/`,
|
||||
and a remote conversation's session file lives on the remote host under the
|
||||
remote user's home, so resolving locally would pin a stranger's id. See
|
||||
[Respawn / reattach continuation](remote-sessions.md#respawn--reattach-continuation).
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **No idle/completion hook.** Idle detection falls back to output-stabilization
|
||||
|
||||
+50
-1
@@ -30,7 +30,7 @@ Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
|
||||
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
|
||||
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
|
||||
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
|
||||
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok'>` — the modes that can run remotely. |
|
||||
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok' \| 'deepseek' \| 'omp'>` — the modes that can run remotely. |
|
||||
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
|
||||
|
||||
Persistence is two flat JSON arrays in the instance data dir:
|
||||
@@ -116,6 +116,11 @@ Key points:
|
||||
the agent. The per-mode command comes from `remote.commands?.[mode]` or
|
||||
`defaultRemoteCommandForMode(mode)` (`exec claude` / `exec opencode` /
|
||||
`exec codex` / `exec gemini` / `exec agy` / `exec bash -l`).
|
||||
⚠️ **claude and omp no longer take that path**: both have their own arm in
|
||||
`buildRemoteLaunchCommand` so a respawn can continue the same conversation
|
||||
(see [Respawn / reattach continuation](#respawn--reattach-continuation)), and
|
||||
because the claude arm is an `a || b` pair under `-c`, its pane PID is the
|
||||
**login shell**, not the agent.
|
||||
- The **whole tmux invocation is a single shell-quoted ssh argument**, and the
|
||||
pane command is independently quoted, so a `remotePath` with spaces is safe.
|
||||
- Connection options come from the **same `buildSshConnectionArgs(remote)`** as
|
||||
@@ -202,6 +207,50 @@ The early return is a structural guarantee that **no code path can ever issue a
|
||||
remote `kill-session` for a session we don't own** — the only `kill-session` run is
|
||||
on the local socket, which never reaches the remote socket.
|
||||
|
||||
## Respawn / reattach continuation
|
||||
|
||||
A dropped connection or a dead pane must reconnect to the **same conversation**,
|
||||
not launch a fresh one — the whole point of a durable remote session.
|
||||
|
||||
- **Claude**: the launch command is idempotent — `claude --session-id <id> ||
|
||||
claude --resume <id>` (see `buildRemoteLaunchCommand`'s claude branch). The
|
||||
first run creates the conversation under the deterministic session id; every
|
||||
later reattach/respawn re-runs the same line, `--session-id` fails
|
||||
("already in use"), and the `||` fallback resumes it.
|
||||
- **OMP**: `omp` has no equivalent idempotent single-line form, so
|
||||
`Session._pinOmpRespawnId()` resolves and pins an explicit `--resume <id>`
|
||||
before a respawn (mirroring the local/docker builders, rendered through the
|
||||
same `buildSpawnCommandFromRegistry` engine — not a hand-rolled command and
|
||||
not `appendResumeFlag()`, which is docker-only and cannot work here: appending
|
||||
a flag after the quoted `-c 'omp'` hands the id to the login shell as `$0`
|
||||
instead of to `omp`). ⚠️ **The resolver only ever reads THIS host's local
|
||||
`~/.omp/agent/sessions/`**, which is meaningless for a remote session — the
|
||||
conversation and its session file live on the remote host, under the remote
|
||||
user's home. For a remote session, `_pinOmpRespawnId()` therefore skips local
|
||||
resolution entirely and falls back to `omp`'s own ambiguous `--continue`
|
||||
(`ompConfig.continueSession`), which the remote pane command already renders.
|
||||
This is a known, accepted degradation versus the local/docker paths' exact
|
||||
`--resume` pin — safe in practice because each remote respawn talks to
|
||||
exactly one remote pane's own omp history, so "most recent" is normally
|
||||
correct, but it can drift the same way `--continue` always could if two
|
||||
remote sessions ever share one remote directory.
|
||||
|
||||
## Auto-reconnect vs. a clean agent exit
|
||||
|
||||
`remoteAutoReconnect` (default ON) watches for a dropped SSH connection and
|
||||
reconnects with bounded backoff. It must **never** revive a session whose agent
|
||||
exited cleanly (Ctrl-C, Ctrl-D, `exit`) — that tears down the durable remote
|
||||
tmux session itself, and a transport-level `isPaneDead()` cannot tell that apart
|
||||
from a plain network drop. `remoteTmuxSessionAlive()` (#355) resolves this by
|
||||
probing the remote host directly: `tmux -L codeman-remote has-session -t
|
||||
codeman-ssh-<id8>` over the same `buildSshConnectionArgs` as launch, classified
|
||||
by **exit status alone** (`classifyRemoteAliveExit`: `0` = alive, ssh's `255` or
|
||||
a timeout = unknown, anything else = gone) — `has-session` prints nothing on
|
||||
success, so reading stdout would misclassify every live session as gone. An
|
||||
unreachable host answers "unknown", which also means do not revive. The answer
|
||||
is cached per session and cleared whenever the pane is next seen alive, so a
|
||||
stale `true` from one transport drop can never revive the NEXT clean exit.
|
||||
|
||||
## API
|
||||
|
||||
Routes are registered in `src/web/routes/case-routes.ts`:
|
||||
|
||||
@@ -52,6 +52,37 @@ sandbox, cookies, CORS, CSP, or any reverse proxy sitting in front of Codeman, s
|
||||
passing Test does not guarantee the embedded page will render (see the
|
||||
cookie-authenticated reverse proxy caveat below).
|
||||
|
||||
## Links to `localhost` from another device
|
||||
|
||||
An agent prints `http://localhost:5173/` (a dev server, a preview, a report it just
|
||||
served) and you tap it on your phone. That address only exists on the Codeman box, so
|
||||
the phone's browser can never load it — but the web-tab proxy fetches from the server,
|
||||
where it works.
|
||||
|
||||
So a **loopback** link (`localhost`, `127.0.0.0/8`, `0.0.0.0`, `::1`) clicked
|
||||
in the terminal or in the Response Viewer opens as a **proxied web tab** whenever the
|
||||
Codeman page itself is not on that box. A saved proxied dashboard on the same origin is
|
||||
reused (one tab per dev server, with the link's own path opened inside it, and one tab
|
||||
per dev server rather than per host spelling, so `localhost:5173` and `127.0.0.1:5173`
|
||||
share it); otherwise one is saved under its `host:port` so it is in the Run dropdown
|
||||
next time, and a toast tells you it was saved. Sandboxed by default, like any other web
|
||||
tab.
|
||||
|
||||
⚠️ **`*.localhost` is deliberately not auto-routed**, even though a browser treats it as
|
||||
loopback. Every other name in that list is an address literal that can only mean this
|
||||
box; a `*.localhost` DNS name is not one, and on a resolver with a search domain
|
||||
configured `evil.localhost` can be retried as `evil.localhost.<search domain>`, which
|
||||
someone else can control. Since the links come from agent output, one tap would then
|
||||
make Codeman fetch an agent-chosen origin server-side and save it. If you really run
|
||||
`api.localhost` dev hosts, add that dashboard by hand: doing so is an explicit action,
|
||||
which is the difference that matters here. A **trusted** (non-sandboxed) dashboard is
|
||||
likewise never auto-reused by a tapped link, for the same reason.
|
||||
|
||||
Only loopback is routed this way. A LAN or tailnet address (`192.168.…`, `100.…`,
|
||||
`box.ts.net`) may well be reachable from the device — a VPN, the same Wi-Fi — and a
|
||||
direct open is the cheaper, richer path, so those links still open in a new browser tab.
|
||||
On the box itself (a browser on `localhost`) every link opens directly.
|
||||
|
||||
## The sandbox, and when to turn it off
|
||||
|
||||
Because a proxied dashboard is served from Codeman's own address, it is
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.26.2",
|
||||
"version": "1.28.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.26.2",
|
||||
"version": "1.28.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.26.2",
|
||||
"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",
|
||||
|
||||
@@ -83,6 +83,7 @@ appendFileSync(
|
||||
|
||||
// 4. Minify frontend assets
|
||||
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
|
||||
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
|
||||
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
|
||||
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
|
||||
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
|
||||
@@ -110,6 +111,7 @@ console.log('\n[build] content-hash cache busting');
|
||||
'notification-manager.js',
|
||||
'keyboard-accessory.js',
|
||||
'input-cjk.js',
|
||||
'terminal-keycode229-recovery.js',
|
||||
'sanitize-html.js',
|
||||
'app.js',
|
||||
'tab-rail-resize.js',
|
||||
|
||||
@@ -284,6 +284,7 @@ than into an existing checkout.
|
||||
| send input | `POST /api/v1/sessions/:id/input` |
|
||||
| **read a worker's answer** (claude/codex/deepseek) | `GET /api/v1/sessions/:id/last-response` → `.data.{text,timestamp}`, clean transcript text, no TUI noise. ⚠️ **Poll it**, see [symptom 7](#7-last-response-returns-an-empty-string-right-after-stop) |
|
||||
| read the whole conversation | `GET /api/v1/sessions/:id/last-response?context=full` → `.data.messages[]`. ⚠️ **Only `{role,text}` is present for every mode.** `kind`/`label` come from claude (`prompt`/`response`), deepseek and the pane parser (which also emit `status`/`tool`) but NOT from codex; `timestamp` from claude and codex but not deepseek/pane; `turn` and `queued:true` (a prompt typed while the agent was working) from claude only. `.data.text` is unchanged by `context=full` — it stays the last assistant message, never `messages[-1]` |
|
||||
| read the last **answered turn** (claude only) | `GET /api/v1/sessions/:id/last-response?context=turn` → `.data.messages[]` holds every assistant message of the most recent turn that has one (the whole answer, not just its final row); `.data.text` is still the last assistant row. Other modes answer `text` only, with no `messages` |
|
||||
| read terminal (tail is in **BYTES**, raw ANSI) | `GET /api/v1/sessions/:id/terminal?tail=3000` → `.data.terminalBuffer`, for *diagnosis* (unsubmitted prompt?), not for reading answers |
|
||||
| full tmux scrollback (context bomb; post-mortems only) | `GET /api/v1/sessions/:id/terminal?full=1` |
|
||||
| background agents, one session | `GET /api/v1/sessions/:id/subagents` |
|
||||
|
||||
@@ -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_'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1775,6 +1775,19 @@ export class Session extends EventEmitter {
|
||||
// (reported live 2026-08-27, fixed in 13a19f79); this guard keeps that
|
||||
// fix intact now that resolution has moved out of the eager options build.
|
||||
if (!this._muxSession) return;
|
||||
// `resolveAndClaimOmpSessionId` scans THIS HOST's `~/.omp/agent/sessions/`, which is
|
||||
// meaningless for a remote session — the conversation and its session file live on the
|
||||
// remote host, under the REMOTE user's home. Worse than a no-op: `this.workingDir` for a
|
||||
// remote session is the remote path (e.g. `/home/user/dotfiles`), so if the local machine
|
||||
// happens to have its own omp history under a directory that mangles to the same name,
|
||||
// this would silently claim and pin a COMPLETELY UNRELATED local session's id onto a
|
||||
// remote respawn. Skip straight to the CLI's own `--continue` fallback, which the remote
|
||||
// pane command already renders (see buildRemoteLaunchCommand's omp branch) — safe there
|
||||
// because each remote respawn talks to exactly one remote pane's own omp history.
|
||||
if (this._remote) {
|
||||
this._ompConfig = { ...this._ompConfig, continueSession: true };
|
||||
return;
|
||||
}
|
||||
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
|
||||
if (resolvedId) {
|
||||
this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId };
|
||||
@@ -2568,6 +2581,11 @@ export class Session extends EventEmitter {
|
||||
*/
|
||||
private _maybeCaptureOmpSessionId(): void {
|
||||
if (getCli(this.mode)?.capabilities.transcript !== 'omp-jsonl' || this._claudeSessionId !== this.id) return;
|
||||
// Same host-local-filesystem trap as `_pinOmpRespawnId`: the omp session file for a
|
||||
// remote session lives on the remote host, not here, so scanning locally risks aliasing
|
||||
// this session onto an unrelated local omp conversation that happens to mangle to the
|
||||
// same directory name. Never resolvable from here — skip.
|
||||
if (this._remote) return;
|
||||
try {
|
||||
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
|
||||
if (resolvedId) {
|
||||
|
||||
+69
-8
@@ -827,8 +827,11 @@ export function buildRemoteLaunchCommand(options: {
|
||||
sessionId: string;
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
/** OMP only — resume/continue overrides for the remote omp relaunch (dead-pane respawn). */
|
||||
ompConfig?: OmpConfig;
|
||||
resumeSessionId?: string;
|
||||
}): string {
|
||||
const { mode, remote, sessionId, claudeMode, allowedTools } = options;
|
||||
const { mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId } = options;
|
||||
// §6.3: honor the session's EFFECTIVE claude permission mode on remote instead of
|
||||
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
|
||||
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
|
||||
@@ -837,11 +840,66 @@ export function buildRemoteLaunchCommand(options: {
|
||||
// `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH entry that
|
||||
// only an interactive login shell resolves (see that function's comment).
|
||||
const override = remote.commands?.[mode];
|
||||
const modeCommand = override
|
||||
? override
|
||||
: mode === 'claude'
|
||||
? remoteLoginShellCommand(`claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`)
|
||||
: defaultRemoteCommandForMode(mode);
|
||||
let modeCommand: string;
|
||||
if (override) {
|
||||
modeCommand = override;
|
||||
} else if (mode === 'claude') {
|
||||
// Deterministic conversation pinning for SSH-remote claude (mirrors the
|
||||
// docker-claude shape in claudeDockerPaneCommand, INCLUDING the distinct
|
||||
// resumeId branch it declares — this used to only mirror the same-id
|
||||
// fallback shape, silently dropping an explicit resumeSessionId that
|
||||
// differs from sessionId, e.g. a resume-from-history launch): the FIRST
|
||||
// run creates the conversation under --session-id <sessionId>; a respawn
|
||||
// / reattach re-runs the same idempotent command, --session-id exits
|
||||
// non-zero ("already in use"), and the `||` fallback RESUMES that same
|
||||
// conversation. Without a pinned id, every reattach relaunched a bare
|
||||
// `claude` and started a NEW conversation (found live 2026-08-29: remote
|
||||
// claude ctrl-d / ctrl-c relaunched a fresh session). A per-host
|
||||
// `commands.claude` override stays authoritative (admin's explicit
|
||||
// choice) and skips this entirely.
|
||||
const permFlags = buildClaudePermissionFlags(claudeMode, allowedTools);
|
||||
const cmd = `claude${permFlags}`;
|
||||
// Defense in depth, mirroring claudeDockerPaneCommand's own belt-and-braces check:
|
||||
// sessionId is server-minted and always safe in practice, but this command is built
|
||||
// as a single shellescaped string and then executed as shell code on the remote
|
||||
// host, so an unsafe value here is validated rather than trusted.
|
||||
if (!RESUME_ID_SAFE.test(sessionId)) {
|
||||
modeCommand = remoteLoginShellCommand(cmd);
|
||||
} else {
|
||||
const rid = resumeSessionId && RESUME_ID_SAFE.test(resumeSessionId) ? resumeSessionId : undefined;
|
||||
modeCommand = remoteLoginShellCommand(
|
||||
rid && rid !== sessionId
|
||||
? `${cmd} --resume ${rid} || ${cmd} --session-id ${sessionId}`
|
||||
: `${cmd} --session-id ${sessionId} || ${cmd} --resume ${sessionId}`
|
||||
);
|
||||
}
|
||||
} else if (mode === 'omp') {
|
||||
// Remote OMP respawn must RESUME the same conversation instead of
|
||||
// relaunching fresh (found live 2026-08-29: remote ctrl-c/ctrl-d relaunched
|
||||
// a brand-new omp session). The pinned id, when known, is passed as an
|
||||
// explicit --resume; otherwise fall back to omp's own "most recent"
|
||||
// --continue so a dead-pane respawn still lands back in the conversation.
|
||||
// Rendered through the CLI registry (buildSpawnCommandFromRegistry), the
|
||||
// SAME mode-agnostic path local/docker spawns use — not appendResumeFlag(),
|
||||
// which would hand the id to the login shell as $0 after the quoted `-c
|
||||
// 'omp'`, and not a raw buildOmpCommand() call, which the registry refactor
|
||||
// (#347) deleted. Gives every registry CLI with a resume form this
|
||||
// behaviour for free, and the flags can't drift from the local builder.
|
||||
const ompEntry = getCli('omp');
|
||||
const ompCmd = ompEntry
|
||||
? (buildSpawnCommandFromRegistry(ompEntry, {
|
||||
mode: 'omp',
|
||||
sessionId,
|
||||
ompConfig: {
|
||||
...ompConfig,
|
||||
resumeSessionId: resumeSessionId || ompConfig?.resumeSessionId,
|
||||
},
|
||||
}) ?? 'omp')
|
||||
: 'omp';
|
||||
modeCommand = remoteLoginShellCommand(ompCmd);
|
||||
} else {
|
||||
modeCommand = defaultRemoteCommandForMode(mode);
|
||||
}
|
||||
const remoteName = remoteTmuxSessionName(sessionId);
|
||||
|
||||
// Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by
|
||||
@@ -1309,6 +1367,9 @@ function buildRemoteSessionCommand(options: {
|
||||
sessionId: string;
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
/** OMP only — resume/continue overrides for a remote omp relaunch. */
|
||||
ompConfig?: OmpConfig;
|
||||
resumeSessionId?: string;
|
||||
}): string {
|
||||
const { remote, sessionId } = options;
|
||||
if (remote.owned === false) {
|
||||
@@ -1882,7 +1943,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const fullCmd = docker
|
||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||
: remote
|
||||
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
|
||||
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
|
||||
: localFullCmd;
|
||||
|
||||
// Create tmux session in three steps to handle cold-start (no server running)
|
||||
@@ -2133,7 +2194,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const fullCmd = docker
|
||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||
: remote
|
||||
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
|
||||
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
|
||||
: localFullCmd;
|
||||
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,23 @@ import { join, sep } from 'node:path';
|
||||
/** A real OMP session file is `<ISO-ish-timestamp>_<uuid>.jsonl`; only the uuid matters here. */
|
||||
const OMP_SESSION_FILE_PATTERN = /^.+_([a-zA-Z0-9-]+)\.jsonl$/;
|
||||
|
||||
/**
|
||||
* Strip a trailing `/` from a workingDir unless it is the root itself.
|
||||
*
|
||||
* Case paths routinely end in `/` — a remote case's `remotePath` is stored
|
||||
* verbatim (e.g. `/home/user/dotfiles/`) — but omp persists sessions under
|
||||
* the slash-less mangle (`-dotfiles`) with a header `cwd` of
|
||||
* `/home/user/dotfiles`. Without normalization, the trailing slash survives
|
||||
* the mangle (`-dotfiles-`), `readdirSync` returns null for a directory that
|
||||
* exists, and OMP respawn pinning silently degrades to the ambiguous
|
||||
* `--continue` (found live 2026-08-29: a remote OMP ctrl-c relaunched a fresh
|
||||
* conversation instead of resuming). Exported so the same normalization is
|
||||
* used for the header-`cwd` comparison in {@link resolveAndClaimOmpSessionId}.
|
||||
*/
|
||||
export function stripTrailingSlash(workingDir: string): string {
|
||||
return workingDir.length > 1 && workingDir.endsWith('/') ? workingDir.slice(0, -1) : workingDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors `omp`'s own directory mangling. Confirmed empirically against real
|
||||
* `~/.omp/agent/sessions/` directory names (2026-08-27): unlike Claude Code's
|
||||
@@ -45,8 +62,9 @@ export function mangleOmpWorkingDir(workingDir: string): string {
|
||||
// omp's actual behavior on a symlinked-home setup; guessing wrong here would
|
||||
// trade one silent mismatch for a different one.
|
||||
const home = homedir();
|
||||
const normalized = stripTrailingSlash(workingDir);
|
||||
const relative =
|
||||
workingDir === home || workingDir.startsWith(home + sep) ? workingDir.slice(home.length) : workingDir;
|
||||
normalized === home || normalized.startsWith(home + sep) ? normalized.slice(home.length) : normalized;
|
||||
return relative.replace(/\//g, '-');
|
||||
}
|
||||
|
||||
@@ -183,7 +201,9 @@ export function resolveAndClaimOmpSessionId(workingDir: string): string | null {
|
||||
}
|
||||
if (mtimeMs <= newestMtime) continue;
|
||||
const header = readOmpSessionHeader(filePath);
|
||||
if (!header || header.cwd !== workingDir || claimedOmpSessionIds.has(header.id)) continue;
|
||||
// Compare against the slash-normalized workingDir: the session's own
|
||||
// workingDir may carry a trailing slash while omp's header cwd never does.
|
||||
if (!header || header.cwd !== stripTrailingSlash(workingDir) || claimedOmpSessionIds.has(header.id)) continue;
|
||||
newestMtime = mtimeMs;
|
||||
newestId = header.id;
|
||||
}
|
||||
|
||||
+147
-5
@@ -2149,6 +2149,16 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// A `localhost` URL in the agent's answer: from another device that can
|
||||
// only load through the server, so hand it to a proxied web tab
|
||||
// (webview-tabs.js). Every other link keeps its new-tab default.
|
||||
const urlLink = ev.target.closest('a[href]');
|
||||
if (urlLink && this.openLinkThroughWebTabIfLoopback?.(urlLink.href)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// One-click copy: lift the raw source from the sibling <pre><code>.
|
||||
const copyBtn = ev.target.closest('.rv-copy-btn');
|
||||
if (copyBtn) {
|
||||
@@ -2325,10 +2335,19 @@ class CodemanApp {
|
||||
|
||||
if (!this.activeSessionId) return;
|
||||
try {
|
||||
// Source 1: Transcript JSONL (best quality — clean structured text from Claude)
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response`);
|
||||
// Source 1: Transcript JSONL (best quality — clean structured text from Claude).
|
||||
// `context=turn` asks for the last ANSWERED turn as messages: a Claude
|
||||
// answer is a median of 3 model messages (p90 11), and `text` alone is
|
||||
// only the final one — usually a "Done." tail with the substance in the
|
||||
// rows before it. Readers that know no `turn` context (Codex, the pane
|
||||
// parser, an older server) answer with `text` only, and that path is
|
||||
// unchanged below.
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response?context=turn`);
|
||||
const data = (await res.json())?.data ?? {};
|
||||
let lastResponse = data.text || '';
|
||||
const turnMessages = (Array.isArray(data.messages) ? data.messages : []).filter(
|
||||
(msg) => msg && msg.role === 'assistant' && typeof msg.text === 'string' && msg.text.trim()
|
||||
);
|
||||
|
||||
// Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome.
|
||||
// Claude + shell only: _cleanTerminalBuffer knows Claude CLI's output, and
|
||||
@@ -2345,7 +2364,20 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
const body = document.getElementById('responseViewerBody');
|
||||
if (lastResponse) {
|
||||
if (turnMessages.length > 0) {
|
||||
// The whole last turn, rendered exactly as the full view renders that
|
||||
// turn: one badge, then badge-less continuation segments. The same
|
||||
// numeric-`turn` gate as loadFullContext, never same-role adjacency.
|
||||
const agentLabel = this._getResponseViewerAgentLabel();
|
||||
body.innerHTML = '';
|
||||
let previous = null;
|
||||
for (const msg of turnMessages) {
|
||||
const continuation = !!previous && typeof msg.turn === 'number' && previous.turn === msg.turn;
|
||||
body.appendChild(this._buildResponseViewerMessage(msg.text, 'assistant', agentLabel, { ...msg, continuation }));
|
||||
previous = msg;
|
||||
}
|
||||
this._bindResponseViewerInteractions(body);
|
||||
} else if (lastResponse) {
|
||||
// Keep the brief view inside the same message wrapper as the full
|
||||
// conversation view. The wrapper supplies the card, role badge and
|
||||
// descendant markdown styles that direct body children do not get.
|
||||
@@ -2366,7 +2398,12 @@ class CodemanApp {
|
||||
|
||||
viewer.classList.add('visible');
|
||||
backdrop.classList.add('visible');
|
||||
body.scrollTop = 0;
|
||||
// A multi-row turn opens at its NEWEST text, matching loadFullContext's
|
||||
// "scroll to bottom (latest message)". `scrollTop = 0` was right when the
|
||||
// brief view was a single card holding the last row; with the whole turn
|
||||
// rendered, the top is the turn's first narration line and the answer the
|
||||
// eye button exists to show can be several screens down.
|
||||
body.scrollTop = turnMessages.length > 1 ? body.scrollHeight : 0;
|
||||
} catch (err) {
|
||||
console.error('Failed to load response:', err);
|
||||
}
|
||||
@@ -3990,6 +4027,71 @@ class CodemanApp {
|
||||
return this.isSessionSidebarRich() || this.isTabRailRich();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the VERTICAL TAB RAIL orders its cards the way both home screens
|
||||
* do — blocked on you first, then running longest-first, then quiet
|
||||
* most-recently-quiet first (`CodemanSessionOrder`, constants.js) — instead of
|
||||
* leaving them in the user's tab order.
|
||||
*
|
||||
* Read off <html> like the other two rail gates, because the render loop asks
|
||||
* it once per pass and getSessionListLayout() re-parses localStorage.
|
||||
* `tabRailSort: 'manual'` is the opt-out, and it is what a user who reorders
|
||||
* by hand wants: a self-sorting list cannot also be drag-reorderable, so
|
||||
* setupTabDragHandlers() drops the drag affordance while this is on rather
|
||||
* than letting a card snap back to where the sort puts it.
|
||||
*
|
||||
* Deliberately NOT gated on `isTabRailRich()`: a simple rail lists the same
|
||||
* sessions and answers the same question, it just says less about each one.
|
||||
*/
|
||||
isTabRailSorted() {
|
||||
const root = document.documentElement;
|
||||
return root.getAttribute('data-tab-orientation') === 'vertical' && root.dataset.tabRailSort === 'activity';
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual position per session id for the sorted rail, or null when the rail is
|
||||
* not sorting.
|
||||
*
|
||||
* The sort is applied as the flex `order` property, NOT by reordering the DOM.
|
||||
* That is the whole design: `#sessionTabs` stays in `sessionOrder`, so
|
||||
* drag-and-drop, the Alt+N badges, the arrow-key walk, the sidebar filter and
|
||||
* `_scrollActiveTabIntoView()` all keep reading the list they have always
|
||||
* read, and a session changing state moves one inline style instead of
|
||||
* forcing the full rebuild that would restart every card's animation.
|
||||
*
|
||||
* Rows are classified by `_mobileOverviewState()` and compared by
|
||||
* `CodemanSessionOrder` — the same two helpers both home screens use, so the
|
||||
* rail cannot disagree with them about what "working" means or what sorts
|
||||
* first. `orderIndex` is the tab-strip position, which the comparator uses as
|
||||
* its deterministic final tiebreak.
|
||||
*
|
||||
* Guarded like every other cross-file consumer: a stale cached constants.js or
|
||||
* mobile-overview.js degrades to tab order rather than taking the strip down.
|
||||
*
|
||||
* @param {Array<string>} ids live session ids, in tab order
|
||||
* @returns {Map<string, number>|null}
|
||||
*/
|
||||
_tabRailSortOrder(ids) {
|
||||
if (!this.isTabRailSorted()) return null;
|
||||
if (!window.CodemanSessionOrder || typeof this._mobileOverviewState !== 'function') return null;
|
||||
const rows = [];
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const session = this.sessions.get(ids[i]);
|
||||
if (!session) continue;
|
||||
rows.push({
|
||||
id: ids[i],
|
||||
state: this._mobileOverviewState(session, this.pendingHooks?.get(ids[i])),
|
||||
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||
orderIndex: i,
|
||||
});
|
||||
}
|
||||
const sorted = window.CodemanSessionOrder.sort(rows);
|
||||
const out = new Map();
|
||||
for (let i = 0; i < sorted.length; i++) out.set(sorted[i].id, i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* True where the sidebar is a MODAL off-canvas drawer over the terminal
|
||||
* instead of a docked column.
|
||||
@@ -4611,11 +4713,21 @@ class CodemanApp {
|
||||
// Read once for the whole pass, like the full-rebuild path: this touches
|
||||
// the DOM and the loop below runs for every session on every SSE tick.
|
||||
const richRows = this.isRichTabRows();
|
||||
// Sorted vertical rail: a state change moves a card, and this is the path
|
||||
// that sees one — a session going working→idle never adds or removes a
|
||||
// tab, so the full rebuild below is not reached. Recomputed per pass for
|
||||
// the same reason the rich meta line is: the order IS the state.
|
||||
const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid)));
|
||||
// Incremental update - only modify changed properties
|
||||
for (const [id, session] of this.sessions) {
|
||||
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||
if (!tab) continue;
|
||||
|
||||
// An empty string clears the property, which is also what un-sorts the
|
||||
// rail when the setting (or the layout) flips without a full rebuild.
|
||||
const railOrder = railSortOrder?.has(id) ? String(railSortOrder.get(id)) : '';
|
||||
if (tab.style.order !== railOrder) tab.style.order = railOrder;
|
||||
|
||||
// A web tab owns the active state while one is open. activeSessionId stays
|
||||
// set (the terminal keeps streaming underneath, and switching back is
|
||||
// instant): only the highlight moves. Without this the debounced render
|
||||
@@ -4922,10 +5034,17 @@ class CodemanApp {
|
||||
// Read once, not per session: isRichTabRows() touches the DOM and
|
||||
// this loop runs for every tab on every full rebuild.
|
||||
const richRows = this.isRichTabRows();
|
||||
// The sorted vertical rail (tabRailSort) moves cards with the flex `order`
|
||||
// property and leaves this loop iterating tab order, so the Alt+N badge
|
||||
// below still counts the strip, not the sorted list. Null in every other
|
||||
// layout, and the tabs then carry no inline order at all — the header
|
||||
// strip's markup is byte-identical to before.
|
||||
const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));
|
||||
let _tabIdx = 0;
|
||||
for (const id of tabOrder) {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) continue; // Skip if session was removed
|
||||
const railOrderStyle = railSortOrder?.has(id) ? ` style="order:${railSortOrder.get(id)}"` : '';
|
||||
|
||||
// See the note in the incremental path: a web tab owns the active highlight
|
||||
// while one is open, even though activeSessionId stays set.
|
||||
@@ -4979,7 +5098,7 @@ class CodemanApp {
|
||||
const inlineSessionActions = this.shouldInlineSessionActions();
|
||||
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`;
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
@@ -5064,6 +5183,18 @@ class CodemanApp {
|
||||
|
||||
// Rows hidden by the sidebar filter must not be steppable.
|
||||
const tabs = [...container.querySelectorAll('.session-tab:not(.tab-filtered-out)')];
|
||||
// ⚠️ A sorted rail paints its rows with the flex `order` property while the
|
||||
// DOM stays in `sessionOrder` (that is what keeps the Alt+N badge and the
|
||||
// drag model honest), so a DOM-order walk steps around the screen instead of
|
||||
// down it: ArrowDown from the top card lands wherever that session happens to
|
||||
// sit in the tab order. Walk what the eye sees. Read the COMPUTED order, not
|
||||
// the inline one, or web tabs (pinned past the cards by a CSS `order: 9999`
|
||||
// rather than an inline style) read as 0 and the walk starts on them. Array
|
||||
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
|
||||
if (this.isTabRailSorted()) {
|
||||
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
|
||||
tabs.sort((a, b) => orderOf(a) - orderOf(b));
|
||||
}
|
||||
const currentIndex = tabs.indexOf(document.activeElement);
|
||||
|
||||
// Enter or Space activates the tab
|
||||
@@ -5190,6 +5321,17 @@ class CodemanApp {
|
||||
const container = this.$('sessionTabs');
|
||||
const tabs = container.querySelectorAll('.session-tab[data-id]');
|
||||
|
||||
// A self-sorting list cannot also be hand-ordered: the drop below rewrites
|
||||
// sessionOrder correctly, the sort then puts the card straight back where it
|
||||
// was, and the user is left dragging a row that refuses to move. Drop the
|
||||
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
|
||||
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
|
||||
// order this list is no longer showing.
|
||||
if (this.isTabRailSorted()) {
|
||||
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
|
||||
return;
|
||||
}
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.setAttribute('draggable', 'true');
|
||||
|
||||
|
||||
@@ -342,7 +342,13 @@ const LINEAGE_DIP_MAX_PX = 64;
|
||||
// apart bled into one thick band instead of reading as three separate lines.
|
||||
const LINEAGE_SIBLING_STEP_PX = 8;
|
||||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||||
const LINEAGE_VERTICAL_TRACK_INSET_PX = 6;
|
||||
// How far the vertical bracket sits in from the rail's left edge. It has to
|
||||
// clear the VIEWPORT edge, not just the tabs: the line carries an 11px outer
|
||||
// glow, so a track at 6px had half of that glow clipped away and the arc read
|
||||
// as a thin thread pinned to the window frame. The rail reserves the channel
|
||||
// itself (`--lineage-vertical-gutter` on the rail's .session-tabs), and
|
||||
// computeLineagePath still clamps the track to stay left of both tabs.
|
||||
const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
|
||||
const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
|
||||
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
|
||||
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
|
||||
@@ -703,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.
|
||||
@@ -803,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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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': '扩展键盘栏',
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||
here and boot will contradict this value, animating the drawer open by
|
||||
itself on every load between 768 and 1023px. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';}</script>
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -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>
|
||||
|
||||
@@ -1944,6 +1980,16 @@
|
||||
<option value="simple">Simple (name only)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="tab rail sort order activity manual drag reorder">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Vertical Rail Order</span>
|
||||
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. Alt+1..9 always follows the tab order either way.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabRailSort" class="set-select">
|
||||
<option value="activity">By activity (home screen order)</option>
|
||||
<option value="manual">Manual (drag to reorder)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="tab rail width resize compact wide maximum">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Vertical Rail Width</span>
|
||||
@@ -3459,6 +3505,8 @@
|
||||
<script defer src="notification-manager.js"></script>
|
||||
<script defer src="keyboard-accessory.js"></script>
|
||||
<script defer src="input-cjk.js"></script>
|
||||
<!-- Forwards committed input events that xterm drops on Android/GBoard soft keyboards. Must precede terminal-ui.js. -->
|
||||
<script defer src="terminal-keycode229-recovery.js"></script>
|
||||
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
|
||||
<script defer src="sanitize-html.js"></script>
|
||||
<script defer src="app.js"></script>
|
||||
|
||||
@@ -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">↑</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">↻</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?.();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
@@ -431,6 +462,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.syncTabRailWidthSetting?.(tabRailWidth);
|
||||
document.getElementById('appSettingsTabRailDetail').value =
|
||||
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
|
||||
document.getElementById('appSettingsTabRailSort').value =
|
||||
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
|
||||
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
||||
document.getElementById('appSettingsSessionListLayout').value =
|
||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
@@ -2089,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,
|
||||
@@ -2097,6 +2134,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
|
||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||
tabRailSort: document.getElementById('appSettingsTabRailSort').value,
|
||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
||||
@@ -2142,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
|
||||
@@ -2299,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,
|
||||
@@ -2500,6 +2544,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabOrientation: 'horizontal',
|
||||
tabRailWidth: 256,
|
||||
tabRailDetail: 'rich',
|
||||
tabRailSort: 'activity',
|
||||
sessionListLayout: 'header',
|
||||
sessionSidebarFontSize: 12,
|
||||
cjkInputEnabled: false,
|
||||
@@ -2766,6 +2811,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const detail = (settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich') === 'simple' ? 'simple' : 'rich';
|
||||
root.dataset.tabRailDetail = detail;
|
||||
|
||||
// Row ORDER rides on a third attribute, for the same reason detail rides on
|
||||
// its own: a sort flip leaves orientation on 'vertical' both times, and the
|
||||
// order is applied as an inline `order` the render paths emit, not by CSS
|
||||
// that could just re-match. `isTabRailSorted()` (app.js) reads this.
|
||||
const previousSort = root.dataset.tabRailSort || 'activity';
|
||||
const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity';
|
||||
root.dataset.tabRailSort = sort;
|
||||
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const rail = document.getElementById('tabRail');
|
||||
const headerHost = document.getElementById('sessionTabsHost');
|
||||
@@ -2789,7 +2842,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the row template, not toggled by CSS — same reasoning as the sidebar's
|
||||
// detail half in applySessionListLayout(). Taller rows also move every
|
||||
// connector anchored to a tab rect.
|
||||
const changed = orientationChanged || previousDetail !== detail;
|
||||
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;
|
||||
if (orientationChanged) {
|
||||
this.updateTabOverflowMode?.();
|
||||
if (!settleRailWidth) this.fitAddon?.fit();
|
||||
@@ -3062,9 +3115,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalFontFamily',
|
||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'autoCopySelection',
|
||||
|
||||
+267
-18
@@ -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 {
|
||||
@@ -17754,6 +17837,172 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
/* --- Detailed rail rows wear the HOME SCREEN's card language -------------- */
|
||||
/* The desktop home rail (.home-sessions, home-sessions.js) and this rail list
|
||||
the same sessions with the same three lines — name, project, "created 3d ago
|
||||
· working 12m" + pill — so they now LOOK the same too: a bordered card per
|
||||
session instead of a flat strip row, a 9px state dot (pulsing and ringed
|
||||
while the session works), the name at full weight, and the stamps line
|
||||
wrapped onto its own full-width row with the pill parked at its right end.
|
||||
|
||||
⚠ RAIL ONLY, never the shared comma-grouped selectors above. The detailed
|
||||
SIDEBAR draws the same meta line and is deliberately left flat: it is a
|
||||
permanently-docked navigation column that sits next to the terminal all day,
|
||||
and 20 stacked cards there read as a wall. Widening one of those grouped
|
||||
rules instead of adding this block is what would silently restyle it.
|
||||
|
||||
⚠ The paint rules here are (0,5,1)-(0,6,1), so they outrank the plain
|
||||
`.session-tab .tab-status.idle` class rules (0,2,0) — which is the point —
|
||||
but they must NOT outrank the alert rules that turn a dot red or yellow when
|
||||
a session is blocked on a human. Those are (0,3,0), so every dot rule below
|
||||
excludes the two alert classes by hand rather than relying on the cascade.
|
||||
State and alert normally agree (_mobileOverviewState reads the same pending
|
||||
hooks the alert does), so this is a guard against them drifting, not a fix
|
||||
for a known disagreement. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tabs {
|
||||
gap: 0.35rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab {
|
||||
/* The stamps line rides a wrapped full-width row, exactly as on the home rail:
|
||||
that hands the whole width of the card to the session name, which is what
|
||||
stops a `w34-claudeman: mindreading` ellipsizing into `w34-claudeman: …`. */
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* The name is what tells one card from another, so it carries the weight the
|
||||
home rail gives it rather than the strip's dim 0.75rem label. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-name {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
/* One step up from the shared vertical-list size, which is 12px because that
|
||||
is what the SIDEBAR has always been; a card row has the height for the
|
||||
~13px the home rail renders at. Still driven by the same setting (App
|
||||
Settings → Session Name Font Size), so it moves with it. */
|
||||
font-size: calc(var(--session-sidebar-name-font-size, 12px) + 1px);
|
||||
/* Three lines rather than two: the card is wrapped, so a long name costs
|
||||
height instead of pushing the pill or the stamps off the row. */
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-folder {
|
||||
font-size: 0.66rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* The state line gets the full width of the card (`flex: 0 0 100%` wraps it),
|
||||
so the stamps stop competing with the gear/close column for the sliver of
|
||||
room left beside the name. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta {
|
||||
flex: 0 0 100%;
|
||||
margin-top: 0;
|
||||
font-size: 0.64rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Bigger than the strip's 6px pip, because a card row has the room and the dot
|
||||
is the one thing on it readable at a glance. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-status {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* Idle is green, but a MUTED green — same mix the home rail uses, for the same
|
||||
reason: a glance down the rail must separate "running right now" from
|
||||
"sitting there" without reading a word. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
|
||||
.tab-rail
|
||||
.session-tab.tab-state-idle:not(.tab-alert-action):not(.tab-alert-idle)
|
||||
.tab-status {
|
||||
background: color-mix(in srgb, var(--green) 42%, var(--text-muted));
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
|
||||
.tab-rail
|
||||
.session-tab.tab-state-done:not(.tab-alert-action):not(.tab-alert-idle)
|
||||
.tab-status {
|
||||
background: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* The working halo. The orbiting ring is the shared `.tab-status.busy::after`
|
||||
already declared above — only its inset moves, to clear the wider dot. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
|
||||
.tab-rail
|
||||
.session-tab.tab-state-working:not(.tab-alert-action):not(.tab-alert-idle)
|
||||
.tab-status {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
|
||||
.tab-rail
|
||||
.session-tab
|
||||
.tab-status.busy::after {
|
||||
inset: -4px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
/* Card accents: the same three colours as every other session surface, and the
|
||||
same two blinks the home rail runs. `tab-alert-*` draws its own ::before ring
|
||||
on top of this for the sessions that are genuinely blocked on a human; these
|
||||
border accents are the calmer, always-on half. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-needs,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-error {
|
||||
border-color: color-mix(in srgb, var(--red) 50%, transparent);
|
||||
animation: home-sessions-blink-red 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-waiting {
|
||||
border-color: color-mix(in srgb, var(--yellow) 50%, transparent);
|
||||
animation: home-sessions-blink-yellow 3.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-working {
|
||||
border-color: color-mix(in srgb, var(--green) 35%, transparent);
|
||||
}
|
||||
|
||||
/* No `.active` rule here on purpose: `.session-tab.active` (and its per-skin
|
||||
twin) already paints background, border and box-shadow with !important, so a
|
||||
card rule would be dead weight that reads as if it were doing something. The
|
||||
selected card keeps the accent its skin gives every selected tab, which is
|
||||
what makes it legible against the card background the state accents above
|
||||
paint on. */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Activity-sorted rail (tabRailSort) ---------------------------------- */
|
||||
/* Visual order only: the DOM stays in the user's tab order, so drag-reorder,
|
||||
Alt+N badges, the arrow-key walk and the sidebar filter all keep reading the
|
||||
list they always read. `order` is set inline per tab by the render paths
|
||||
(app.js `_tabRailSortOrder`).
|
||||
|
||||
Web tabs carry no session state to sort by and keep their place at the end of
|
||||
the list, so they are pinned past every session card rather than being given
|
||||
an inline order of their own — `renderWebviewTabs()` emits the same markup for
|
||||
every layout and must stay that way. */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-sort='activity'] .tab-rail .session-tab[data-webview-id] {
|
||||
order: 9999;
|
||||
}
|
||||
|
||||
/* --- Collapsed rail ---------------------------------------------------- */
|
||||
/* Collapsed is a 44px icon rail, not "hidden": the ambient signal (status dot,
|
||||
task/subagent/ultracode badges) is the whole point of mission control and
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @fileoverview Orphaned-input forwarder for xterm's helper textarea.
|
||||
*
|
||||
* xterm's `CoreBrowserTerminal._inputEvent` only forwards an `insertText`
|
||||
* input event while `(!ev.composed || !this._keyDownSeen)` holds. A soft
|
||||
* keyboard that delivers a `composed: true` input event after a keydown fails
|
||||
* that guard, so xterm returns without emitting and the committed character is
|
||||
* silently dropped.
|
||||
*
|
||||
* ⚠ The gap is NARROWER than "keyCode 229", and assuming otherwise produces a
|
||||
* controller that looks useful while doing nothing. For a keydown that really
|
||||
* does report `keyCode: 229`, xterm ALREADY self-rescues: `CompositionHelper
|
||||
* .keydown()` calls `_handleAnyTextareaChanges()`, which snapshots
|
||||
* `textarea.value` and diffs it on a 0 ms timer, emitting the difference
|
||||
* itself. Measured in headless chromium against a real terminal: for a 229
|
||||
* keydown xterm emits and this controller correctly stands down. What is left
|
||||
* unrescued is a refused `insertText` where NO 229 diff was scheduled — that is
|
||||
* the case this module exists for, and the case its browser test asserts by
|
||||
* checking WHO delivered the byte rather than merely that one arrived.
|
||||
*
|
||||
* The recovery never guesses the character: the `input` event already carries
|
||||
* the real committed text in `ev.data`, which is exactly what xterm itself
|
||||
* would have forwarded. We only decide WHETHER to forward it, by asking
|
||||
* whether xterm produced any canonical data since the keydown that started the
|
||||
* keystroke. That snapshot must be taken at KEYDOWN, not at the input event:
|
||||
* xterm's `_keyPress` emits and sets `_keyPressHandled` before `input` fires,
|
||||
* so a snapshot read at input time would already contain that emission and the
|
||||
* character would be delivered twice.
|
||||
*
|
||||
* Listener registration is load-bearing, in BOTH phase and order. xterm
|
||||
* registers its own `input` listener in `terminal.open()` with `capture:
|
||||
* true`, and ours is added afterwards, so at-target it runs second. It must
|
||||
* also be a CAPTURE listener; see the measured table at the addEventListener
|
||||
* call below.
|
||||
*
|
||||
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
|
||||
* @loadorder 5.55 (before app.js/terminal-ui.js, which create the controller)
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
function create(options) {
|
||||
const textarea = options?.textarea;
|
||||
const emitRecovered = options?.emitRecovered;
|
||||
if (!textarea?.addEventListener || !textarea?.removeEventListener || typeof emitRecovered !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isScreenReaderMode = options.isScreenReaderMode;
|
||||
const setTimer = options.setTimer || global.setTimeout.bind(global);
|
||||
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
|
||||
|
||||
let destroyed = false;
|
||||
// Number of canonical data events xterm has emitted, bumped by the caller's
|
||||
// onData hook. Only its ORDER relative to a keydown matters.
|
||||
let canonicalCount = 0;
|
||||
// ⚠️ 0, never null. With `null` the `?? canonicalCount` fallback at the input
|
||||
// event reads a count xterm has ALREADY bumped: on a fresh page load with no
|
||||
// keydown yet (dictation, Android voice typing, any `insertText` with no key
|
||||
// held) xterm's own capture listener runs first, forwards the text itself and
|
||||
// bumps the counter, then this snapshot equals it, `count > snapshot` is false,
|
||||
// and the text is emitted a SECOND time. A baseline of 0 makes that comparison
|
||||
// true and stands the recovery down, which restores this file's invariant: a
|
||||
// missed recovery is acceptable, a duplicated keystroke is not.
|
||||
let keydownSnapshot = 0;
|
||||
let composing = false;
|
||||
const pending = [];
|
||||
|
||||
function cancelPending() {
|
||||
for (const candidate of pending.splice(0)) {
|
||||
candidate.active = false;
|
||||
if (candidate.timer !== null) {
|
||||
try {
|
||||
clearTimer(candidate.timer);
|
||||
} catch {
|
||||
// A broken timer host must not break input handling.
|
||||
}
|
||||
candidate.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCandidate(candidate) {
|
||||
const index = pending.indexOf(candidate);
|
||||
if (index !== -1) pending.splice(index, 1);
|
||||
candidate.timer = null;
|
||||
if (!candidate.active || destroyed) return;
|
||||
candidate.active = false;
|
||||
// xterm (or its keypress path) spoke for this keystroke — it is already
|
||||
// on its way to the PTY, so there is nothing to recover.
|
||||
if (canonicalCount > candidate.snapshot) return;
|
||||
try {
|
||||
emitRecovered(candidate.data);
|
||||
} catch {
|
||||
// Recovery is best effort; a failed delivery must never throw into the
|
||||
// browser's input handling.
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from xterm's onData hook: xterm produced canonical data. */
|
||||
function notifyCanonicalData() {
|
||||
canonicalCount += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the canonical counter at every keydown. This deliberately reads
|
||||
* NOTHING else off the event — not `key`, not `keyCode`. Gating it on
|
||||
* keyCode 229 would make the recovery inert on exactly the devices it
|
||||
* exists for, whose keydowns report `key: 'Unidentified'`. It is a single
|
||||
* assignment, so running it for every keydown costs nothing.
|
||||
*/
|
||||
function handleKeyEvent(event) {
|
||||
if (destroyed || event?.type !== 'keydown') return;
|
||||
keydownSnapshot = canonicalCount;
|
||||
}
|
||||
|
||||
function onInput(event) {
|
||||
if (destroyed || composing || event?.isComposing) return;
|
||||
if (event.inputType !== 'insertText') return;
|
||||
const data = event.data;
|
||||
if (typeof data !== 'string' || data === '') return;
|
||||
try {
|
||||
if (isScreenReaderMode?.()) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = {
|
||||
data,
|
||||
snapshot: keydownSnapshot ?? canonicalCount,
|
||||
active: true,
|
||||
timer: null,
|
||||
};
|
||||
pending.push(candidate);
|
||||
try {
|
||||
candidate.timer = setTimer(() => resolveCandidate(candidate), 0);
|
||||
} catch {
|
||||
cancelPending();
|
||||
}
|
||||
}
|
||||
|
||||
function onCompositionStart() {
|
||||
if (destroyed) return;
|
||||
composing = true;
|
||||
cancelPending();
|
||||
}
|
||||
|
||||
function onCompositionEnd() {
|
||||
if (destroyed) return;
|
||||
composing = false;
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
cancelPending();
|
||||
try {
|
||||
textarea.removeEventListener('input', onInput, true);
|
||||
textarea.removeEventListener('compositionstart', onCompositionStart, true);
|
||||
textarea.removeEventListener('compositionend', onCompositionEnd, true);
|
||||
} catch {
|
||||
// Teardown is best effort; the terminal is being replaced anyway.
|
||||
}
|
||||
}
|
||||
|
||||
// capture: true, not bubble. The target (the textarea) is visited TWICE in
|
||||
// the event path, so a capture-phase listener on it calling
|
||||
// stopPropagation() still stops later BUBBLE-phase listeners on that same
|
||||
// target. xterm's `_inputEvent` calls `this.cancel(ev)` (preventDefault +
|
||||
// stopPropagation) exactly in the branch where it HANDLED the input, so on
|
||||
// bubble we would never see handled events — and whether we saw them at
|
||||
// all would hang off xterm's `options.cancelEvents`, which Codeman does not
|
||||
// set. Measured (jsdom and headless chromium agree):
|
||||
//
|
||||
// capture-then-BUBBLE, no stop: xterm -> ours
|
||||
// capture-then-BUBBLE, stopPropagation: xterm (ours never fires)
|
||||
// capture-then-CAPTURE, no stop: xterm -> ours
|
||||
// capture-then-CAPTURE, stopPropagation: xterm -> ours (still fires)
|
||||
//
|
||||
// On capture we therefore observe EVERY input event uniformly, and the
|
||||
// canonicalCount snapshot alone decides whether to forward.
|
||||
try {
|
||||
textarea.addEventListener('input', onInput, true);
|
||||
textarea.addEventListener('compositionstart', onCompositionStart, true);
|
||||
textarea.addEventListener('compositionend', onCompositionEnd, true);
|
||||
} catch {
|
||||
destroy();
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy });
|
||||
}
|
||||
|
||||
global.CodemanKeyCode229Recovery = Object.freeze({ create });
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -232,15 +232,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Terminal Setup — xterm.js config and input handling
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_destroyKeyCode229Recovery() {
|
||||
try {
|
||||
this._keyCode229Recovery?.destroy?.();
|
||||
} catch {
|
||||
// Recovery is optional; terminal replacement must continue.
|
||||
}
|
||||
this._keyCode229Recovery = null;
|
||||
},
|
||||
|
||||
initTerminal() {
|
||||
// Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor
|
||||
// so users who picked up the previous (smaller) default get the new minimum on upgrade.
|
||||
const stored = parseInt(localStorage.getItem('codeman-scrollback'));
|
||||
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,
|
||||
@@ -292,6 +306,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
// punctuation; returning false here would stop xterm before it can diff
|
||||
// the helper textarea and emit the committed Unicode text.
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
try {
|
||||
// Deliberately runs for EVERY keydown, not just keyCode 229: the
|
||||
// controller snapshots a counter and reads nothing off the event, and
|
||||
// the devices this exists for report `key: 'Unidentified'` with no
|
||||
// reliable identity to gate on. Gating it would make recovery inert
|
||||
// exactly where it is needed. Cost is one assignment.
|
||||
this._keyCode229Recovery?.handleKeyEvent?.(ev);
|
||||
} catch {
|
||||
// The fallback must never interfere with xterm's canonical handler.
|
||||
}
|
||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
||||
|
||||
// Let the app's Alt/Option session-nav and Command Palette shortcuts reach the document keydown handler
|
||||
@@ -363,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)
|
||||
@@ -1034,7 +1081,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// mobile connections. The overlay + localStorage persistence ensure input
|
||||
// survives tab switches and reconnects.
|
||||
|
||||
this.terminal.onData((data) => {
|
||||
const handleTerminalData = (data) => {
|
||||
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
|
||||
// the PTY even while the CJK input field owns focus. Without this exception
|
||||
// tapping to move the cursor silently does nothing whenever Chinese input
|
||||
@@ -1356,6 +1403,49 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Chrome on Android delivers a `composed: true` input event preceded by a
|
||||
// keydown, which is exactly the shape xterm's _inputEvent refuses to
|
||||
// forward, so the committed character is silently dropped. The controller
|
||||
// forwards the input event's own `data` when xterm produced nothing for
|
||||
// that keystroke. Created AFTER terminal.open() on purpose: for an event
|
||||
// targeting the textarea, at-target listeners run in registration order,
|
||||
// so xterm's listener (added in open()) still runs first. The controller
|
||||
// registers its own listener with `capture: true`; on bubble xterm's
|
||||
// `cancel()` (stopPropagation) would swallow exactly the handled events —
|
||||
// see the measured table in terminal-keycode229-recovery.js.
|
||||
try {
|
||||
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
|
||||
textarea: this.terminal.textarea,
|
||||
emitRecovered: (data) => handleTerminalData(data),
|
||||
isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
|
||||
});
|
||||
} catch {
|
||||
this._keyCode229Recovery = null;
|
||||
}
|
||||
this.terminal.onData((data) => {
|
||||
// Canonical xterm data. Telling the controller is what lets it know a
|
||||
// keystroke was already delivered and needs no recovery.
|
||||
//
|
||||
// ⚠️ onData ALSO fires for output xterm produces on its own initiative:
|
||||
// the DA/DSR/CPR/OSC replies it answers during Ink redraws, and the SGR
|
||||
// mouse and focus reports (see the two predicates above, used for exactly
|
||||
// this question at the send sites). Any one of those landing between the
|
||||
// keydown and the candidate's zero-delay resolution would be read as
|
||||
// "xterm spoke for this keystroke", standing the recovery down and
|
||||
// leaving the character dropped, worst on a busy agent pane, which is
|
||||
// the case this exists for. Narrowing the counter cannot cause a
|
||||
// duplicate: it only ever makes the controller less sure it can stand down.
|
||||
try {
|
||||
const input = window.CodemanTerminalInput;
|
||||
if (!input?.shouldSuppressTerminalQueryResponse(data) && !input?.isTerminalFocusOrMouseReport(data)) {
|
||||
this._keyCode229Recovery?.notifyCanonicalData?.();
|
||||
}
|
||||
} catch {
|
||||
// Bookkeeping must never block real input.
|
||||
}
|
||||
handleTerminalData(data);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1448,6 +1538,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
range: { start, end },
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate(_event, text) {
|
||||
// A `localhost` link tapped from another device can only work
|
||||
// through the server: route it into a proxied web tab
|
||||
// (webview-tabs.js). Anything else opens as before.
|
||||
if (self.openLinkThroughWebTabIfLoopback?.(text)) return;
|
||||
window.open(text, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
hover() {
|
||||
@@ -4815,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) {
|
||||
|
||||
@@ -19,7 +19,170 @@
|
||||
* @loadorder 12.5 of 16, after session-ui.js (needs the tab strip), before api-client.js
|
||||
*/
|
||||
|
||||
// ── Loopback links ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// An agent prints `http://localhost:5173/` and the user taps it on a phone.
|
||||
// That link can only ever resolve on the Codeman box itself, so opening it in
|
||||
// the browser is a guaranteed connection error from anywhere else — while the
|
||||
// proxied web tab fetches from the server, where it works. Only loopback is
|
||||
// routed this way: a LAN or tailnet address may well be reachable from the
|
||||
// device (a VPN, the same Wi-Fi), and a direct open is the cheaper, richer path.
|
||||
|
||||
const LOOPBACK_HOSTNAMES = new Set(['localhost', '0.0.0.0', '::1', '[::1]', '::', '[::]']);
|
||||
|
||||
function normalizeHostname(hostname) {
|
||||
return String(hostname || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\.$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* `localhost`, 127.0.0.0/8, 0.0.0.0 and the IPv6 loopback forms: names that can
|
||||
* only ever mean this box.
|
||||
*
|
||||
* ⚠️ `*.localhost` is deliberately NOT here. The link source is agent-written
|
||||
* terminal output and response-viewer markdown, i.e. prompt-injectable, and
|
||||
* this set is the whole confinement on a tap that makes Codeman fetch a URL
|
||||
* server-side and persist it. Every other member is an address literal; a
|
||||
* `*.localhost` DNS name is not one: on a resolver that does not synthesise it
|
||||
* locally and has a search domain configured, `evil.localhost` NXDOMAINs as
|
||||
* absolute and is retried as `evil.localhost.<search domain>`, which an
|
||||
* attacker can control. A user who really runs `api.localhost` dev hosts can
|
||||
* still save that dashboard by hand, which is an explicit action.
|
||||
*/
|
||||
function isLoopbackHostname(hostname) {
|
||||
const host = normalizeHostname(hostname);
|
||||
if (!host) return false;
|
||||
if (LOOPBACK_HOSTNAMES.has(host)) return true;
|
||||
const ipv4 = /^(\d{1,3})\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(host);
|
||||
return !!ipv4 && Number(ipv4[1]) === 127;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the PAGE is being viewed on the box itself. Broader than the
|
||||
* auto-route set on purpose, and safe in the opposite direction: a false
|
||||
* positive here only ever DECLINES to proxy, leaving the caller's direct open.
|
||||
*/
|
||||
function isOnBoxHostname(hostname) {
|
||||
const host = normalizeHostname(hostname);
|
||||
return isLoopbackHostname(host) || host.endsWith('.localhost');
|
||||
}
|
||||
|
||||
/**
|
||||
* One key per dev server, so `localhost:5173` and `127.0.0.1:5173` reuse a
|
||||
* single saved dashboard and a single tab instead of one per host spelling.
|
||||
*/
|
||||
function webTabOriginKey(url) {
|
||||
return isLoopbackHostname(url.hostname) ? `${url.protocol}//loopback:${url.port}` : url.origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a link should open through a proxied web tab rather than directly:
|
||||
* an http(s) URL on a loopback host, viewed from a page that is NOT itself on
|
||||
* that host (on the box, the browser can reach localhost and the direct open
|
||||
* keeps devtools, extensions and the real origin).
|
||||
*/
|
||||
function linkNeedsWebTabProxy(rawUrl, pageHostname) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(rawUrl || ''));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||||
if (!isLoopbackHostname(url.hostname)) return false;
|
||||
return !isOnBoxHostname(pageHostname);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanWebviewLinks = { isLoopbackHostname, isOnBoxHostname, webTabOriginKey, linkNeedsWebTabProxy };
|
||||
}
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ── Loopback links ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Take a link the device cannot reach and open it through a proxied web tab.
|
||||
* Returns true when it took the link; false leaves the caller's own opening
|
||||
* path (window.open, an anchor's default) untouched.
|
||||
*/
|
||||
openLinkThroughWebTabIfLoopback(rawUrl) {
|
||||
if (!linkNeedsWebTabProxy(rawUrl, window.location?.hostname)) return false;
|
||||
void this.openUrlInWebTab(rawUrl);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Open an arbitrary URL as a proxied web tab, deep path included. A saved
|
||||
* proxied dashboard on the same origin is reused (one tab per dev server,
|
||||
* not one per link); otherwise one is saved under the host:port name so it
|
||||
* is there in the Run dropdown next time.
|
||||
*/
|
||||
async openUrlInWebTab(rawUrl) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(rawUrl || ''));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// ⚠️ "is `this.webviews` set" does NOT answer "is it loaded": initWebviews()
|
||||
// assigns a truthy EMPTY map synchronously and only then awaits the list, so
|
||||
// a tap during page load used to find nothing to reuse and POST a duplicate
|
||||
// record for an origin that already exists server-side. Join an in-flight
|
||||
// load; start one only when none has ever run.
|
||||
if (this._webviewsRefresh) await this._webviewsRefresh;
|
||||
else if (!this._webviewsLoaded) await this.refreshWebviews();
|
||||
if (!this.webviews) {
|
||||
this.showToast?.('Could not open URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const wantedKey = webTabOriginKey(url);
|
||||
let existing = null;
|
||||
for (const webview of this.webviews.values()) {
|
||||
// ⚠️ `trusted` is excluded alongside `managed` and direct-mode records: a
|
||||
// trusted frame runs with `allow-same-origin`, i.e. on Codeman's origin with
|
||||
// the user's cookie, and the link being followed came from agent output. An
|
||||
// agent that can write into the dev server's tree (it IS the workspace) could
|
||||
// otherwise print a path that one tap navigates that privileged frame to.
|
||||
// Opening such a dashboard from the Run dropdown is still an explicit action.
|
||||
if (webview.managed || webview.trusted || (webview.embedMode ?? 'proxy') !== 'proxy') continue;
|
||||
try {
|
||||
if (webTabOriginKey(new URL(webview.url)) === wantedKey) {
|
||||
existing = webview;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* a saved URL that no longer parses is not a match */
|
||||
}
|
||||
}
|
||||
|
||||
let id = existing?.id;
|
||||
if (!id) {
|
||||
const created = await this._apiJson('/api/webviews', {
|
||||
method: 'POST',
|
||||
body: { name: url.host.slice(0, 60), url: `${url.origin}/`, embedMode: 'proxy', trusted: false },
|
||||
});
|
||||
if (!created?.id) {
|
||||
this.showToast?.('Could not open URL', 'error');
|
||||
return;
|
||||
}
|
||||
await this.refreshWebviews();
|
||||
id = created.id;
|
||||
// The create is a persisted record: it writes webviews.json, broadcasts
|
||||
// over SSE, adds a Run-dropdown row on every device this owner is signed
|
||||
// in on and counts toward MAX_WEBVIEWS. Adding one by hand goes through a
|
||||
// modal; a tap should not do all that with a new tab as its only signal.
|
||||
this.showToast?.(`Saved ${url.host} as a web tab`, 'success');
|
||||
}
|
||||
// `/` is passed through rather than flattened to '': openWebview reads an
|
||||
// empty path as "no deep link" and leaves an already-open frame on whatever
|
||||
// page it was showing, so a link to the origin root did nothing visible.
|
||||
const path = `${url.pathname}${url.search}${url.hash}`;
|
||||
await this.openWebview(id, { path: path || '/' });
|
||||
},
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Load the saved list and restore which tabs were open. */
|
||||
@@ -45,11 +208,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async refreshWebviews() {
|
||||
const data = await this._apiJson('/api/webviews');
|
||||
if (!data) return;
|
||||
this.webviews = new Map((data.webviews || []).map((w) => [w.id, w]));
|
||||
if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames;
|
||||
this.renderWebviewMenuItems();
|
||||
const inFlight = this._apiJson('/api/webviews').then((data) => {
|
||||
if (!data) return;
|
||||
this.webviews = new Map((data.webviews || []).map((w) => [w.id, w]));
|
||||
if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames;
|
||||
this._webviewsLoaded = true;
|
||||
this.renderWebviewMenuItems();
|
||||
});
|
||||
this._webviewsRefresh = inFlight;
|
||||
try {
|
||||
await inFlight;
|
||||
} finally {
|
||||
if (this._webviewsRefresh === inFlight) this._webviewsRefresh = null;
|
||||
}
|
||||
},
|
||||
|
||||
/** SSE: the saved list changed (possibly on another device). */
|
||||
@@ -133,7 +304,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
* memory-only and expire, so a tab reopened after a server restart must not reuse
|
||||
* the dead URL from the previous run.
|
||||
*/
|
||||
async openWebview(id) {
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {{path?: string}} [options] `path` (pathname+search+hash) opens a
|
||||
* deep link inside the dashboard: appended to the proxy prefix, or resolved
|
||||
* against the real URL in direct mode. A mounted frame is navigated there
|
||||
* rather than left on whatever page it was showing.
|
||||
*/
|
||||
async openWebview(id, options = {}) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) return;
|
||||
|
||||
@@ -149,8 +327,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
if (data.webview) this.webviews.set(id, data.webview);
|
||||
|
||||
const src = data.embedUrl || data.webview?.url || webview.url;
|
||||
this._mountWebviewFrame(id, src, data.webview || webview);
|
||||
let src = data.embedUrl || data.webview?.url || webview.url;
|
||||
const path = typeof options.path === 'string' ? options.path : '';
|
||||
if (path) {
|
||||
// The proxy prefix is `/webview/<cap>/`; a wildcard rides after it. In
|
||||
// direct mode the deep link resolves against the dashboard's own origin.
|
||||
src = data.embedUrl ? `${data.embedUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}` : new URL(path, src).href;
|
||||
}
|
||||
this._mountWebviewFrame(id, src, data.webview || webview, { navigate: !!path });
|
||||
this.activeWebviewId = id;
|
||||
this.hideWelcome?.();
|
||||
document.querySelector('.main')?.classList.add('webview-active');
|
||||
@@ -162,11 +346,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/** Create the frame if absent, then reveal it and hide its siblings. */
|
||||
_mountWebviewFrame(id, src, webview) {
|
||||
_mountWebviewFrame(id, src, webview, { navigate = false } = {}) {
|
||||
const layer = document.getElementById('webviewLayer');
|
||||
if (!layer) return;
|
||||
|
||||
let wrap = layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`);
|
||||
if (wrap && navigate) {
|
||||
// A deep link into an already-mounted dashboard: navigate the live frame
|
||||
// instead of tearing it down, so its login and state survive.
|
||||
const frame = wrap.querySelector('iframe');
|
||||
if (frame) frame.src = CodemanBase.url(src);
|
||||
}
|
||||
if (!wrap) {
|
||||
wrap = document.createElement('div');
|
||||
wrap.className = 'webview-frame';
|
||||
|
||||
@@ -359,3 +359,30 @@ export function getLastTranscriptResponse(blocks: ResponseViewerTranscriptBlock[
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The messages of the most recent turn that has an answer: every assistant
|
||||
* message whose `turn` matches the highest turn any assistant message carries.
|
||||
*
|
||||
* This is what the viewer's brief ("Last Response") view renders for Claude.
|
||||
* The brief `text` is one row — the last assistant row — and a Claude turn is
|
||||
* a median of 3 rows (p90 11), so that row alone was usually the tail of the
|
||||
* answer ("Done.") with the substance in the rows before it. Reading the whole
|
||||
* turn gives the same cards the full view shows for it, and no more.
|
||||
*
|
||||
* Deliberately NOT "everything after the last user message": a prompt queued
|
||||
* while the agent works opens a new, still-unanswered turn, and the honest
|
||||
* brief view is then the previous, answered one — exactly the row `text`
|
||||
* already points at. Messages without a numeric `turn` (Codex, the pane
|
||||
* parser, an older reader) yield an empty list so callers fall back to `text`.
|
||||
*/
|
||||
export function selectLastAnsweredTurn<T extends { role: string; turn?: number }>(messages: T[]): T[] {
|
||||
let latest = -1;
|
||||
for (const message of messages) {
|
||||
if (message.role === 'assistant' && typeof message.turn === 'number' && message.turn > latest) {
|
||||
latest = message.turn;
|
||||
}
|
||||
}
|
||||
if (latest < 0) return [];
|
||||
return messages.filter((message) => message.role === 'assistant' && message.turn === latest);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -153,6 +153,7 @@ import {
|
||||
getLastTranscriptResponse,
|
||||
isExternalCliTranscriptMode,
|
||||
parseExternalCliTranscript,
|
||||
selectLastAnsweredTurn,
|
||||
} from '../response-viewer-transcript.js';
|
||||
import { readDeepSeekLastResponse } from '../../deepseek-transcript.js';
|
||||
|
||||
@@ -2214,10 +2215,14 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
const query = req.query as { context?: string };
|
||||
// `turn` is the brief view's context: the last ANSWERED turn's assistant
|
||||
// messages, so a multi-row answer is not reduced to its final row. `text`
|
||||
// stays the last assistant row in every mode (agent pollers hash it).
|
||||
const wantsMessages = query.context === 'full' || query.context === 'turn';
|
||||
const claudeSessionId = session.claudeSessionId || session.id;
|
||||
const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
|
||||
if (!transcript) {
|
||||
return query.context === 'full' ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
return wantsMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
}
|
||||
|
||||
if (transcript.sessionId !== session.claudeSessionId && transcript.sessionId !== session.id) {
|
||||
@@ -2233,9 +2238,17 @@ export function registerSessionRoutes(
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(transcript.path, 'utf8');
|
||||
return parseClaudeResponseTranscript(content, query.context === 'full');
|
||||
const parsed = parseClaudeResponseTranscript(content, wantsMessages);
|
||||
if (query.context === 'turn') {
|
||||
return {
|
||||
text: parsed.text,
|
||||
timestamp: parsed.timestamp,
|
||||
messages: selectLastAnsweredTurn(parsed.messages ?? []),
|
||||
};
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return query.context === 'full' ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
return wantsMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1255,6 +1255,15 @@ export const SettingsUpdateSchema = z
|
||||
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
|
||||
tabRailWidth: z.number().int().min(208).max(360).optional(),
|
||||
tabRailDetail: z.enum(['simple', 'rich']).optional(),
|
||||
/**
|
||||
* Vertical rail row order. Display key (per-device).
|
||||
* 'activity' = the home screens' order (CodemanSessionOrder): blocked on a
|
||||
* human first, then running longest-first, then quiet
|
||||
* most-recently-quiet first.
|
||||
* 'manual' = the user's tab order, and the only value that leaves the
|
||||
* rail drag-reorderable.
|
||||
*/
|
||||
tabRailSort: z.enum(['activity', 'manual']).optional(),
|
||||
/**
|
||||
* Session list layout. Display key (per-device).
|
||||
* 'header' = horizontal tab strip
|
||||
|
||||
@@ -93,6 +93,11 @@ const ALLOWED_BRANCHES: Record<string, string> = {
|
||||
"tmux-manager.ts::mode === 'claude'":
|
||||
"claude's remote pane command carries per-session permission flags, and its docker form is " +
|
||||
'`--session-id … || resume`; neither fits a static overlays.command string',
|
||||
"tmux-manager.ts::mode === 'omp'":
|
||||
'remote omp respawn needs the pinned/continue --resume override threaded through ' +
|
||||
'(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' +
|
||||
'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' +
|
||||
'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode',
|
||||
|
||||
// --- Per-CLI prose and launch handling not yet generalised ---
|
||||
"web/session-wait-registry.ts::mode === 'deepseek'":
|
||||
|
||||
@@ -25,7 +25,7 @@ import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { MuxSession } from '../src/types.js';
|
||||
import type { MuxSession, SessionRemote } from '../src/types.js';
|
||||
|
||||
describe('OMP: fresh session vs. reattach must not share resumeSessionId resolution', () => {
|
||||
const workingDir = join(homedir(), 'codeman-cases', 'resume-test');
|
||||
@@ -126,4 +126,91 @@ describe('OMP: fresh session vs. reattach must not share resumeSessionId resolut
|
||||
expect(session.toState().ompConfig?.resumeSessionId).toBe('real-omp-uuid');
|
||||
expect(session.claudeSessionId).toBe('real-omp-uuid');
|
||||
});
|
||||
|
||||
it("a remote session never resolves --resume from this host's local ~/.omp, even when a same-named local session file exists", () => {
|
||||
// Seed a LOCAL session file whose directory mangle happens to match this
|
||||
// remote session's remotePath. If _pinOmpRespawnId() ever fell through to
|
||||
// resolveAndClaimOmpSessionId() for a remote session, it would wrongly
|
||||
// claim/pin this unrelated local conversation's id onto the remote respawn.
|
||||
seedOmpSessionFile('wrong-local-conversation-id');
|
||||
|
||||
const remote: SessionRemote = {
|
||||
hostId: 'remote-box',
|
||||
label: 'remote-box',
|
||||
host: 'remote-box',
|
||||
username: 'someone',
|
||||
remotePath: workingDir,
|
||||
owned: true,
|
||||
};
|
||||
|
||||
const muxSession: MuxSession = {
|
||||
sessionId: 'placeholder',
|
||||
muxName: 'codeman-deadbeef',
|
||||
pid: 1,
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
mode: 'omp',
|
||||
attached: false,
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
mode: 'omp',
|
||||
mux: new TmuxManager(),
|
||||
useMux: true,
|
||||
muxSession,
|
||||
remote,
|
||||
});
|
||||
sessions.push(session);
|
||||
|
||||
(session as unknown as { _pinOmpRespawnId(): void })._pinOmpRespawnId();
|
||||
|
||||
const state = session.toState();
|
||||
expect(state.ompConfig?.resumeSessionId).toBeUndefined();
|
||||
expect(state.ompConfig?.continueSession).toBe(true);
|
||||
expect(session.claudeSessionId).toBe(session.id);
|
||||
});
|
||||
|
||||
it('_maybeCaptureOmpSessionId() is subject to the same remote guard, so a first idle turn cannot alias a remote session onto a local conversation', () => {
|
||||
// The sibling guard in _pinOmpRespawnId has the test above; this one runs on
|
||||
// the FIRST turn going idle, before any respawn, and reads the same local
|
||||
// ~/.omp tree. Without the `this._remote` early return it would claim this
|
||||
// unrelated local conversation's uuid as the remote session's identity, and
|
||||
// every later respawn would then inherit the wrong pin.
|
||||
seedOmpSessionFile('wrong-local-conversation-id');
|
||||
|
||||
const remote: SessionRemote = {
|
||||
hostId: 'remote-box',
|
||||
label: 'remote-box',
|
||||
host: 'remote-box',
|
||||
username: 'someone',
|
||||
remotePath: workingDir,
|
||||
owned: true,
|
||||
};
|
||||
|
||||
const muxSession: MuxSession = {
|
||||
sessionId: 'placeholder',
|
||||
muxName: 'codeman-deadbeef',
|
||||
pid: 1,
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
mode: 'omp',
|
||||
attached: false,
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
mode: 'omp',
|
||||
mux: new TmuxManager(),
|
||||
useMux: true,
|
||||
muxSession,
|
||||
remote,
|
||||
});
|
||||
sessions.push(session);
|
||||
|
||||
(session as unknown as { _maybeCaptureOmpSessionId(): void })._maybeCaptureOmpSessionId();
|
||||
|
||||
expect(session.claudeSessionId).toBe(session.id);
|
||||
expect(session.toState().ompConfig?.resumeSessionId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,11 @@ import { mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { findLatestOmpSessionId, mangleOmpWorkingDir } from '../src/utils/omp-session-resolver.js';
|
||||
import {
|
||||
findLatestOmpSessionId,
|
||||
mangleOmpWorkingDir,
|
||||
resolveAndClaimOmpSessionId,
|
||||
} from '../src/utils/omp-session-resolver.js';
|
||||
import { resolveOmpConfigForCreate } from '../src/web/routes/session-routes.js';
|
||||
|
||||
describe('mangleOmpWorkingDir', () => {
|
||||
@@ -41,6 +45,15 @@ describe('mangleOmpWorkingDir', () => {
|
||||
const sibling = `${homedir()}-other/dev/foo`;
|
||||
expect(mangleOmpWorkingDir(sibling)).toBe(sibling.replace(/\//g, '-'));
|
||||
});
|
||||
|
||||
it('normalizes a trailing slash so a remote case path resolves to the same dir', () => {
|
||||
// Regression (2026-08-29): remote case paths are stored verbatim with a
|
||||
// trailing slash (e.g. `/home/user/dotfiles/`), but omp persists sessions
|
||||
// under the slash-less mangle (`-dotfiles`). Before the fix this produced
|
||||
// `-dotfiles-`, readdirSync returned null for an existing dir, and OMP
|
||||
// respawn pinning silently degraded to the ambiguous `--continue`.
|
||||
expect(mangleOmpWorkingDir(join(homedir(), 'dotfiles') + '/')).toBe('-dotfiles');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findLatestOmpSessionId', () => {
|
||||
@@ -69,6 +82,33 @@ describe('findLatestOmpSessionId', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAndClaimOmpSessionId: header-cwd trailing-slash normalization', () => {
|
||||
// Sibling of the directory-mangle trailing-slash regression above, but for
|
||||
// the OTHER half of the same fix: resolveAndClaimOmpSessionId additionally
|
||||
// verifies each candidate file's own header `cwd` against workingDir (the
|
||||
// mangle is lossy, so the filename-derived id alone isn't enough — see the
|
||||
// function's doc comment). A remote case's workingDir carries a trailing
|
||||
// slash (e.g. `/home/user/dotfiles/`) but omp's header `cwd` never does;
|
||||
// without stripTrailingSlash() on BOTH sides of that comparison, a real
|
||||
// on-disk session would be found by directory but rejected by the cwd
|
||||
// check, silently degrading pinning to the ambiguous `--continue`.
|
||||
const workingDirNoSlash = join(homedir(), 'dotfiles');
|
||||
const workingDirWithSlash = `${workingDirNoSlash}/`;
|
||||
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-dotfiles');
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('matches a header cwd with no trailing slash against a workingDir that has one', () => {
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
const header = `${JSON.stringify({ type: 'session', id: 'remote-dotfiles-uuid', cwd: workingDirNoSlash })}\n`;
|
||||
writeFileSync(join(sessionDir, '2026-08-29T00-00-00-000Z_remote-dotfiles-uuid.jsonl'), header);
|
||||
|
||||
expect(resolveAndClaimOmpSessionId(workingDirWithSlash)).toBe('remote-dotfiles-uuid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOmpConfigForCreate', () => {
|
||||
// The exact pipeline "resume this OMP row from the history list" drives:
|
||||
// POST /api/sessions with mode:'omp' + ompConfig:{continueSession:true}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,31 @@ describe('COD-106 shared remote sessions', () => {
|
||||
expect(cmd).toContain('new-session -A -s codeman-ssh-cod106aa');
|
||||
});
|
||||
|
||||
it('remote omp relaunch resumes the pinned conversation instead of starting fresh (2026-08-29)', () => {
|
||||
const cmd = buildRemoteLaunchCommand({
|
||||
mode: 'omp',
|
||||
remote,
|
||||
sessionId: 'cod106aaa',
|
||||
ompConfig: { model: 'llm-proxy/crof/glm-5.3-flash' },
|
||||
resumeSessionId: '01a04eb1-d883-75f0-bdfa-74cc315b09ce',
|
||||
});
|
||||
// The remote pane command must carry the pinned omp session id so a
|
||||
// dead-pane respawn lands back in the same conversation.
|
||||
expect(cmd).toContain('omp --model llm-proxy/crof/glm-5.3-flash --resume 01a04eb1-d883-75f0-bdfa-74cc315b09ce');
|
||||
// still a durable, idempotent remote tmux session
|
||||
expect(cmd).toContain('new-session -A -s codeman-ssh-cod106aa');
|
||||
});
|
||||
|
||||
it('remote omp relaunch falls back to --continue when no id is pinned', () => {
|
||||
const cmd = buildRemoteLaunchCommand({
|
||||
mode: 'omp',
|
||||
remote,
|
||||
sessionId: 'cod106aaa',
|
||||
ompConfig: { continueSession: true },
|
||||
});
|
||||
expect(cmd).toContain('omp --continue');
|
||||
});
|
||||
|
||||
it('parses session_attached as a CLIENT COUNT (>1 = shared)', () => {
|
||||
const rows = parseRemoteSessionList(
|
||||
['codeman-solo\\t1\\t100\\t1', 'codeman-shared\\t2\\t200\\t3', 'codeman-idle\\t0\\t300\\t1'].join('\n')
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* @fileoverview The brief ("Last Response") view renders the last ANSWERED turn.
|
||||
*
|
||||
* `data.text` is one row — the last assistant row — and a Claude turn is a
|
||||
* median of 3 rows, so the eye button used to show the tail of an answer
|
||||
* ("Done.") while the More view showed the whole thing. The brief view now asks
|
||||
* for `context=turn` and renders those rows the way the full view does: one
|
||||
* badge, then continuation segments. Pinned here:
|
||||
*
|
||||
* 1. `selectLastAnsweredTurn` picks the highest turn that HAS an assistant
|
||||
* message, so a prompt queued after the answer (a new, unanswered turn)
|
||||
* does not blank the view; and it yields nothing without numeric turns.
|
||||
* 2. The brief view falls back to `text` when the server sends no messages
|
||||
* (Codex, the pane parser, an older server), so those keep their one card.
|
||||
* 3. The continuation gate is the numeric `turn`, as in loadFullContext.
|
||||
*
|
||||
* app.js is loaded via `vm` with a jsdom document, as in
|
||||
* response-viewer-turn-segments.test.ts.
|
||||
* Port: N/A
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { selectLastAnsweredTurn } from '../src/web/response-viewer-transcript.js';
|
||||
|
||||
describe('selectLastAnsweredTurn', () => {
|
||||
const user = (text: string, turn: number) => ({ role: 'user', text, turn });
|
||||
const assistant = (text: string, turn: number) => ({ role: 'assistant', text, turn });
|
||||
|
||||
it('returns every assistant message of the highest answered turn', () => {
|
||||
const messages = [
|
||||
user('a', 1),
|
||||
assistant('a1', 1),
|
||||
user('b', 2),
|
||||
assistant('b1', 2),
|
||||
assistant('b2', 2),
|
||||
user('c', 3),
|
||||
];
|
||||
expect(selectLastAnsweredTurn(messages).map((m) => m.text)).toEqual(['b1', 'b2']);
|
||||
});
|
||||
|
||||
it('yields nothing for messages without numeric turns, so callers fall back to text', () => {
|
||||
const messages = [
|
||||
{ role: 'user', text: 'a' },
|
||||
{ role: 'assistant', text: 'a1' },
|
||||
{ role: 'assistant', text: 'a2' },
|
||||
];
|
||||
expect(selectLastAnsweredTurn(messages)).toEqual([]);
|
||||
expect(selectLastAnsweredTurn([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps turn-0 output emitted before the first prompt', () => {
|
||||
expect(selectLastAnsweredTurn([assistant('hello', 0)]).map((m) => m.text)).toEqual(['hello']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('response viewer brief view (last answered turn)', () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
||||
const { document, NodeFilter } = dom.window;
|
||||
|
||||
function loadCodemanAppClass() {
|
||||
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
fetch: vi.fn(),
|
||||
document,
|
||||
NodeFilter,
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: {},
|
||||
});
|
||||
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
return { CodemanApp: (context as { __CodemanApp: { prototype: object } }).__CodemanApp, context };
|
||||
}
|
||||
|
||||
const { CodemanApp, context: appContext } = loadCodemanAppClass();
|
||||
|
||||
interface ViewerApp {
|
||||
toggleResponseViewer(): Promise<void>;
|
||||
activeSessionId?: string;
|
||||
sessions?: Map<string, { mode: string }>;
|
||||
}
|
||||
|
||||
function mountViewer() {
|
||||
const viewer = document.createElement('div');
|
||||
viewer.id = 'responseViewer';
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.id = 'responseViewerBackdrop';
|
||||
const body = document.createElement('div');
|
||||
body.id = 'responseViewerBody';
|
||||
const title = document.createElement('div');
|
||||
title.id = 'responseViewerTitle';
|
||||
const more = document.createElement('button');
|
||||
more.id = 'responseViewerMore';
|
||||
document.body.append(viewer, backdrop, body, title, more);
|
||||
return { viewer, body, title, more };
|
||||
}
|
||||
|
||||
function makeApp(payload: unknown): { app: ViewerApp; fetchMock: ReturnType<typeof vi.fn> } {
|
||||
const app = Object.create(CodemanApp.prototype) as ViewerApp;
|
||||
app.activeSessionId = 's1';
|
||||
app.sessions = new Map([['s1', { mode: 'claude' }]]);
|
||||
const fetchMock = vi.fn(async () => ({ json: async () => ({ data: payload }) }));
|
||||
(appContext as { fetch: unknown }).fetch = fetchMock;
|
||||
return { app, fetchMock };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('asks for context=turn and renders the whole turn as one badged card with segments', async () => {
|
||||
const { body, viewer } = mountViewer();
|
||||
const { app, fetchMock } = makeApp({
|
||||
text: 'Done.',
|
||||
timestamp: 't',
|
||||
messages: [
|
||||
{ role: 'assistant', text: 'Looking at the file.', turn: 2 },
|
||||
{ role: 'assistant', text: 'The bug is on line 3.', turn: 2 },
|
||||
{ role: 'assistant', text: 'Done.', turn: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
await app.toggleResponseViewer();
|
||||
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe('/api/sessions/s1/last-response?context=turn');
|
||||
const cards = body.querySelectorAll('.rv-message');
|
||||
expect(cards).toHaveLength(3);
|
||||
expect(body.querySelectorAll('.rv-role')).toHaveLength(1);
|
||||
expect(cards[0].classList.contains('rv-msg-cont')).toBe(false);
|
||||
expect(cards[1].classList.contains('rv-msg-cont')).toBe(true);
|
||||
expect(cards[2].classList.contains('rv-msg-cont')).toBe(true);
|
||||
expect(body.textContent).toContain('The bug is on line 3.');
|
||||
expect(viewer.classList.contains('visible')).toBe(true);
|
||||
});
|
||||
|
||||
it('opens a multi-row turn at its NEWEST text, and a single card at the top', async () => {
|
||||
// `body.scrollTop = 0` was right when the brief view was one card holding
|
||||
// the last row. With the whole turn rendered, the top of the scroller is
|
||||
// the turn's FIRST narration line and the answer the eye button exists to
|
||||
// show can be several screens below it; loadFullContext already scrolls to
|
||||
// the bottom for the same turn, so the two views disagreed.
|
||||
// jsdom does no layout, so scrollHeight is stubbed and the write recorded.
|
||||
const spyScroll = (body: HTMLElement) => {
|
||||
const writes: number[] = [];
|
||||
Object.defineProperty(body, 'scrollHeight', { configurable: true, get: () => 4200 });
|
||||
Object.defineProperty(body, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => writes[writes.length - 1] ?? 0,
|
||||
set: (v: number) => void writes.push(v),
|
||||
});
|
||||
return writes;
|
||||
};
|
||||
|
||||
const many = mountViewer();
|
||||
const manyWrites = spyScroll(many.body);
|
||||
await makeApp({
|
||||
text: 'Done.',
|
||||
timestamp: 't',
|
||||
messages: [
|
||||
{ role: 'assistant', text: 'Looking at the file.', turn: 2 },
|
||||
{ role: 'assistant', text: 'Done.', turn: 2 },
|
||||
],
|
||||
}).app.toggleResponseViewer();
|
||||
expect(manyWrites.at(-1)).toBe(4200);
|
||||
|
||||
document.body.innerHTML = '';
|
||||
const one = mountViewer();
|
||||
const oneWrites = spyScroll(one.body);
|
||||
await makeApp({
|
||||
text: 'Done.',
|
||||
timestamp: 't',
|
||||
messages: [{ role: 'assistant', text: 'Done.', turn: 2 }],
|
||||
}).app.toggleResponseViewer();
|
||||
expect(oneWrites.at(-1)).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to text when the server sends no messages, keeping one badged card', async () => {
|
||||
const { body } = mountViewer();
|
||||
const { app } = makeApp({ text: 'Only the last row.', timestamp: 't' });
|
||||
|
||||
await app.toggleResponseViewer();
|
||||
|
||||
expect(body.querySelectorAll('.rv-message')).toHaveLength(1);
|
||||
expect(body.querySelectorAll('.rv-role')).toHaveLength(1);
|
||||
expect(body.textContent).toContain('Only the last row.');
|
||||
});
|
||||
|
||||
it('ignores user rows and blank rows in a turn payload', async () => {
|
||||
const { body } = mountViewer();
|
||||
const { app } = makeApp({
|
||||
text: 'answer',
|
||||
timestamp: 't',
|
||||
messages: [
|
||||
{ role: 'user', text: 'prompt', turn: 1 },
|
||||
{ role: 'assistant', text: ' ', turn: 1 },
|
||||
{ role: 'assistant', text: 'answer', turn: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
await app.toggleResponseViewer();
|
||||
|
||||
expect(body.querySelectorAll('.rv-message')).toHaveLength(1);
|
||||
expect(body.textContent).not.toContain('prompt');
|
||||
expect(body.textContent).toContain('answer');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -336,6 +336,69 @@ describe('GET /api/sessions/:id/last-response (claude)', () => {
|
||||
expect(body.data.text).toBe('Let me look.');
|
||||
});
|
||||
|
||||
it('answers context=turn with the last answered turn only, text still on the last row', async () => {
|
||||
const sessionId = harness.ctx._session.id;
|
||||
const session = harness.ctx._session as typeof harness.ctx._session & {
|
||||
claudeSessionId: string;
|
||||
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
session.claudeSessionId = sessionId;
|
||||
session.adoptClaudeSessionId = vi.fn();
|
||||
writeTranscript(sessionId, [
|
||||
userEntry('first'),
|
||||
assistantEntry('Old answer.', '2026-07-21T00:00:01Z'),
|
||||
userEntry('second'),
|
||||
assistantEntry('Looking at the file.', '2026-07-21T00:00:02Z'),
|
||||
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'x' }] } },
|
||||
{ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'x' }] } },
|
||||
assistantEntry('The bug is on line 3.', '2026-07-21T00:00:03Z'),
|
||||
assistantEntry('Done.', '2026-07-21T00:00:04Z'),
|
||||
// A prompt queued after the answer opens a new, unanswered turn.
|
||||
queuedEntry('third', '2026-07-21T00:00:05Z'),
|
||||
]);
|
||||
|
||||
const response = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/last-response?context=turn`,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = JSON.parse(response.body).data as {
|
||||
text: string;
|
||||
timestamp: string;
|
||||
messages: Array<{ role: string; text: string; turn: number }>;
|
||||
};
|
||||
// The frozen brief contract holds: still the last assistant row.
|
||||
expect(data.text).toBe('Done.');
|
||||
expect(data.timestamp).toBe('2026-07-21T00:00:04Z');
|
||||
// The turn view is that row's whole turn, assistant rows only, and not the
|
||||
// queued prompt that has no answer yet.
|
||||
expect(data.messages.map((m) => [m.role, m.text, m.turn])).toEqual([
|
||||
['assistant', 'Looking at the file.', 2],
|
||||
['assistant', 'The bug is on line 3.', 2],
|
||||
['assistant', 'Done.', 2],
|
||||
]);
|
||||
|
||||
const brief = await getLastResponse(sessionId);
|
||||
expect(brief.body.data).toEqual({ text: 'Done.', timestamp: '2026-07-21T00:00:04Z' });
|
||||
});
|
||||
|
||||
it('answers context=turn with an empty list when nothing has been answered yet', async () => {
|
||||
const sessionId = harness.ctx._session.id;
|
||||
const session = harness.ctx._session as typeof harness.ctx._session & {
|
||||
claudeSessionId: string;
|
||||
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
session.claudeSessionId = sessionId;
|
||||
session.adoptClaudeSessionId = vi.fn();
|
||||
writeTranscript(sessionId, [userEntry('go')]);
|
||||
|
||||
const response = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/last-response?context=turn`,
|
||||
});
|
||||
expect(JSON.parse(response.body).data).toEqual({ text: '', timestamp: '', messages: [] });
|
||||
});
|
||||
|
||||
/**
|
||||
* A multi-line paste absorbed mid-turn arrives as N queued rows within a few
|
||||
* hundred milliseconds (observed: 5 rows inside ~360ms). They are one turn, so
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* @fileoverview The vertical tab rail's row ORDER and its card styling.
|
||||
*
|
||||
* The rail lists exactly the sessions both home screens list (the phone
|
||||
* overview and the desktop home rail), so it now answers their question the
|
||||
* same way: `CodemanSessionOrder` (constants.js) puts whatever is blocked on a
|
||||
* human first, then whatever has been running longest, then the most recently
|
||||
* quiet. Three things about that can go wrong silently and are pinned here.
|
||||
*
|
||||
* 1. THE ROW MODEL. The comparator reads `state` plus two raw stamps, and a
|
||||
* WORKING row is ranked by `lastSubmitAt` rather than `lastActivityAt`
|
||||
* (a working pane repaints about once a second, so its last-activity stamp
|
||||
* is always "now"). Dropping `lastSubmitAt` from the row would not throw and
|
||||
* would not fail a rendering test — every running turn would just quietly
|
||||
* rank as freshly started.
|
||||
*
|
||||
* 2. THE ALT+N BADGE. The sort is applied as the flex `order` property while
|
||||
* the DOM stays in `sessionOrder`, which is what keeps the number badge
|
||||
* honest: it names the Alt+N key, not the row's position, so it deliberately
|
||||
* does NOT run 1,2,3 down a sorted rail.
|
||||
*
|
||||
* 3. THE OPT-OUT. A self-sorting list cannot also be drag-reorderable, so the
|
||||
* drag affordance is dropped while sorting is on and `tabRailSort: 'manual'`
|
||||
* is the way back. That decision lives in one place and must stay wired to
|
||||
* the attribute the render paths read.
|
||||
*
|
||||
* Port: none (vm-loaded app.js + static source/markup assertions).
|
||||
*/
|
||||
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';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
const settingsUi = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8');
|
||||
const styles = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||
|
||||
/** The rail's rich-row scope, spelled exactly as styles.css must spell it. */
|
||||
const RAIL_RICH = "html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail";
|
||||
|
||||
/** The <html> element the vm's app.js reads. Mutable, so the REAL gate runs. */
|
||||
type Root = { dataset: Record<string, string>; getAttribute: (name: string) => string | null };
|
||||
|
||||
/**
|
||||
* Load app.js + mobile-overview.js in one vm context, so `_tabRailSortOrder()`
|
||||
* runs against the REAL `_mobileOverviewState()` rather than a copy of it that
|
||||
* could drift. Same stubbing technique as session-close-fallback.test.ts, plus a
|
||||
* writable `document.documentElement`: `isTabRailSorted()` reads the layout off
|
||||
* <html>, and stubbing THAT out on the instance would leave the shipped gate
|
||||
* untested while these assertions kept passing.
|
||||
*/
|
||||
function loadCodemanAppClass(): { CodemanApp: new () => unknown; root: Root } {
|
||||
const attrs: Record<string, string> = {};
|
||||
const root: Root = { dataset: {}, getAttribute: (name: string) => attrs[name] ?? null };
|
||||
Object.defineProperty(root, '__attrs', { value: attrs });
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: { OPEN: 1 },
|
||||
fetch: vi.fn(),
|
||||
document: {
|
||||
addEventListener: vi.fn(),
|
||||
getElementById: () => null,
|
||||
querySelector: () => null,
|
||||
documentElement: root,
|
||||
},
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' },
|
||||
});
|
||||
const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
|
||||
vm.runInContext(
|
||||
`${read('constants.js')}\n${read('app.js')}\n${read('mobile-overview.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
|
||||
context
|
||||
);
|
||||
return { CodemanApp: (context as { __CodemanApp: new () => unknown }).__CodemanApp, root };
|
||||
}
|
||||
|
||||
const { CodemanApp, root: railRoot } = loadCodemanAppClass();
|
||||
|
||||
/** Point the shared <html> stub at one rail configuration. */
|
||||
function setLayout(attrs: Record<string, string>): void {
|
||||
const raw = (railRoot as unknown as { __attrs: Record<string, string> }).__attrs;
|
||||
for (const key of Object.keys(raw)) delete raw[key];
|
||||
for (const key of Object.keys(railRoot.dataset)) delete railRoot.dataset[key];
|
||||
raw['data-tab-orientation'] = attrs['data-tab-orientation'] ?? 'vertical';
|
||||
railRoot.dataset.tabRailSort = attrs.tabRailSort ?? 'activity';
|
||||
railRoot.dataset.tabRailDetail = attrs.tabRailDetail ?? 'rich';
|
||||
}
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
status?: string;
|
||||
lastActivityAt?: number;
|
||||
lastSubmitAt?: number;
|
||||
hooks?: string[];
|
||||
};
|
||||
|
||||
type RailApp = {
|
||||
isTabRailSorted: () => boolean;
|
||||
_tabRailSortOrder: (ids: string[]) => Map<string, number> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Instance with the two inputs the order needs real (`sessions`,
|
||||
* `pendingHooks`); the rail gate answers from the shared <html> stub, so the
|
||||
* shipped `isTabRailSorted()` is what decides.
|
||||
*/
|
||||
function makeApp(rows: Row[], attrs: Record<string, string> = {}): RailApp {
|
||||
setLayout(attrs);
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as RailApp & Record<string, unknown>;
|
||||
app.sessions = new Map(rows.map((r) => [r.id, { ...r, mode: 'claude' }]));
|
||||
app.pendingHooks = new Map(rows.filter((r) => r.hooks).map((r) => [r.id, new Set(r.hooks)]));
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Visual positions keyed by id, for readable assertions. */
|
||||
function positions(app: RailApp, ids: string[]): Record<string, number> | null {
|
||||
const map = app._tabRailSortOrder(ids);
|
||||
if (!map) return null;
|
||||
return Object.fromEntries(map);
|
||||
}
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
const minsAgo = (m: number) => NOW - m * 60_000;
|
||||
|
||||
describe('vertical tab rail row order', () => {
|
||||
it('puts a blocked session first, longest-blocked ahead of the newest block', () => {
|
||||
const ids = ['fresh-block', 'old-block', 'quiet'];
|
||||
const app = makeApp([
|
||||
{ id: 'fresh-block', status: 'idle', lastActivityAt: minsAgo(1), hooks: ['permission_prompt'] },
|
||||
{ id: 'old-block', status: 'idle', lastActivityAt: minsAgo(30), hooks: ['permission_prompt'] },
|
||||
{ id: 'quiet', status: 'idle', lastActivityAt: minsAgo(2) },
|
||||
]);
|
||||
expect(positions(app, ids)).toEqual({ 'old-block': 0, 'fresh-block': 1, quiet: 2 });
|
||||
});
|
||||
|
||||
it('ranks a running session by its last Enter, not by the repaint it just did', () => {
|
||||
// Both panes printed a byte a moment ago, which is what a working pane does
|
||||
// about once a second. Only lastSubmitAt says which turn actually started
|
||||
// first, so reading lastActivityAt here would call this a tie and fall
|
||||
// through to tab order — silently, and in the wrong direction.
|
||||
const ids = ['short-turn', 'long-turn'];
|
||||
const app = makeApp([
|
||||
{ id: 'short-turn', status: 'busy', lastActivityAt: minsAgo(0), lastSubmitAt: minsAgo(2) },
|
||||
{ id: 'long-turn', status: 'busy', lastActivityAt: minsAgo(0), lastSubmitAt: minsAgo(40) },
|
||||
]);
|
||||
expect(positions(app, ids)).toEqual({ 'long-turn': 0, 'short-turn': 1 });
|
||||
});
|
||||
|
||||
it('flips the tiebreak for quiet sessions: most recently quiet first', () => {
|
||||
const ids = ['yesterday', 'just-finished'];
|
||||
const app = makeApp([
|
||||
{ id: 'yesterday', status: 'idle', lastActivityAt: minsAgo(1440) },
|
||||
{ id: 'just-finished', status: 'idle', lastActivityAt: minsAgo(1) },
|
||||
]);
|
||||
expect(positions(app, ids)).toEqual({ 'just-finished': 0, yesterday: 1 });
|
||||
});
|
||||
|
||||
it('orders the whole fleet blocked → running → quiet', () => {
|
||||
const ids = ['idle-a', 'working', 'needs', 'waiting', 'idle-b'];
|
||||
const app = makeApp([
|
||||
{ id: 'idle-a', status: 'idle', lastActivityAt: minsAgo(2) },
|
||||
{ id: 'working', status: 'busy', lastActivityAt: minsAgo(0), lastSubmitAt: minsAgo(12) },
|
||||
{ id: 'needs', status: 'idle', lastActivityAt: minsAgo(5), hooks: ['permission_prompt'] },
|
||||
{ id: 'waiting', status: 'idle', lastActivityAt: minsAgo(3), hooks: ['idle_prompt'] },
|
||||
{ id: 'idle-b', status: 'idle', lastActivityAt: minsAgo(90) },
|
||||
]);
|
||||
expect(positions(app, ids)).toEqual({ needs: 0, waiting: 1, working: 2, 'idle-a': 3, 'idle-b': 4 });
|
||||
});
|
||||
|
||||
it('returns no order at all when the rail is horizontal or set to manual', () => {
|
||||
const rows: Row[] = [{ id: 'a', status: 'idle', lastActivityAt: minsAgo(1) }];
|
||||
const horizontal = makeApp(rows, { 'data-tab-orientation': 'horizontal' });
|
||||
expect(horizontal.isTabRailSorted()).toBe(false);
|
||||
expect(horizontal._tabRailSortOrder(['a'])).toBeNull();
|
||||
const manual = makeApp(rows, { tabRailSort: 'manual' });
|
||||
expect(manual.isTabRailSorted()).toBe(false);
|
||||
expect(manual._tabRailSortOrder(['a'])).toBeNull();
|
||||
expect(makeApp(rows).isTabRailSorted()).toBe(true);
|
||||
});
|
||||
|
||||
it('sorts a SIMPLE rail too — it lists the same sessions, it just says less', () => {
|
||||
const ids = ['quiet', 'blocked'];
|
||||
const app = makeApp(
|
||||
[
|
||||
{ id: 'quiet', status: 'idle', lastActivityAt: minsAgo(1) },
|
||||
{ id: 'blocked', status: 'idle', lastActivityAt: minsAgo(9), hooks: ['permission_prompt'] },
|
||||
],
|
||||
{ tabRailDetail: 'simple' }
|
||||
);
|
||||
expect(positions(app, ids)).toEqual({ blocked: 0, quiet: 1 });
|
||||
});
|
||||
|
||||
it('keeps the Alt+N badge on the tab index while the cards are sorted', () => {
|
||||
// `_tabIdx` counts the loop over sessionOrder, and only `style="order:…"`
|
||||
// moves the card — so the badge names a shortcut, not a row position.
|
||||
expect(appJs).toContain(
|
||||
'const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));'
|
||||
);
|
||||
expect(appJs).toContain('` style="order:${railSortOrder.get(id)}"`');
|
||||
expect(appJs).toMatch(/_tabIdx < 9 \? '<span class="tab-number">' \+ \(_tabIdx \+ 1\)/);
|
||||
// The loop itself still walks the user's order, which is what makes the
|
||||
// badge, drag-and-drop and the arrow-key walk agree with each other.
|
||||
expect(appJs).toContain('const tabOrder = this.sessionOrder;');
|
||||
});
|
||||
|
||||
it('re-applies the order from the incremental path, since a state change adds no tab', () => {
|
||||
// A session going working→idle never adds or removes a tab, so the full
|
||||
// rebuild is not reached — and a rebuild here would restart every card's
|
||||
// animation on every SSE tick anyway.
|
||||
expect(appJs).toContain(
|
||||
'const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid)));'
|
||||
);
|
||||
expect(appJs).toContain('if (tab.style.order !== railOrder) tab.style.order = railOrder;');
|
||||
// An empty string is what clears the property when the rail stops sorting.
|
||||
expect(appJs).toContain("const railOrder = railSortOrder?.has(id) ? String(railSortOrder.get(id)) : '';");
|
||||
});
|
||||
|
||||
it('drops the drag affordance while sorting, so a card cannot snap back', () => {
|
||||
const drag = appJs.slice(appJs.indexOf('setupTabDragHandlers() {'));
|
||||
expect(drag.indexOf('if (this.isTabRailSorted()) {')).toBeLessThan(
|
||||
drag.indexOf("tab.setAttribute('draggable', 'true')")
|
||||
);
|
||||
expect(drag).toContain("tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));");
|
||||
});
|
||||
|
||||
it('classifies and compares through the shared helpers, never a local copy', () => {
|
||||
const fn = appJs.slice(appJs.indexOf('_tabRailSortOrder(ids) {'), appJs.indexOf('_tabRailSortOrder(ids) {') + 1200);
|
||||
expect(fn).toContain('window.CodemanSessionOrder.sort(rows)');
|
||||
expect(fn).toContain('this._mobileOverviewState(session, this.pendingHooks?.get(ids[i]))');
|
||||
// Both raw stamps, or the comparator silently ranks every running turn as
|
||||
// freshly started (see the file header).
|
||||
expect(fn).toContain('lastActivityAt:');
|
||||
expect(fn).toContain('lastSubmitAt:');
|
||||
expect(fn).toContain('orderIndex: i');
|
||||
// Degrades to tab order rather than throwing when a cached constants.js or
|
||||
// mobile-overview.js is stale.
|
||||
expect(fn).toContain("!window.CodemanSessionOrder || typeof this._mobileOverviewState !== 'function'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('vertical tab rail sort setting', () => {
|
||||
it('accepts only the two documented values', () => {
|
||||
for (const v of ['activity', 'manual']) {
|
||||
expect(SettingsUpdateSchema.safeParse({ tabRailSort: v }).success).toBe(true);
|
||||
}
|
||||
for (const v of ['', 'auto', 'Activity', 1, true]) {
|
||||
expect(SettingsUpdateSchema.safeParse({ tabRailSort: v }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('is a per-device display key with a control the load/save path can find', () => {
|
||||
expect(settingsUi).toContain("'tabRailSort'");
|
||||
expect(settingsUi).toContain("tabRailSort: 'activity',");
|
||||
expect(settingsUi).toContain("document.getElementById('appSettingsTabRailSort').value");
|
||||
expect(settingsUi).toContain("tabRailSort: document.getElementById('appSettingsTabRailSort').value,");
|
||||
expect(html).toContain('id="appSettingsTabRailSort"');
|
||||
expect(html).toContain('<option value="activity">');
|
||||
expect(html).toContain('<option value="manual">');
|
||||
});
|
||||
|
||||
it('stamps the attribute before first paint and on every settings apply', () => {
|
||||
// Without the pre-paint stamp the rail renders unsorted for a frame and
|
||||
// then reshuffles, which is exactly the flash the other two rail attributes
|
||||
// are stamped there to avoid.
|
||||
expect(html).toContain(
|
||||
"document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';"
|
||||
);
|
||||
expect(html).toContain("document.documentElement.dataset.tabRailSort='activity';");
|
||||
expect(settingsUi).toContain('root.dataset.tabRailSort = sort;');
|
||||
// A sort flip leaves orientation on 'vertical' both times, so it has to
|
||||
// count as a change of its own or nothing re-renders.
|
||||
expect(settingsUi).toContain(
|
||||
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vertical tab rail card styling', () => {
|
||||
it('gives detailed rail rows the home screen card, and only the rail', () => {
|
||||
const block = styles.slice(styles.indexOf(`${RAIL_RICH} .session-tabs {`));
|
||||
expect(block).toContain(`${RAIL_RICH} .session-tab {`);
|
||||
for (const decl of ['border-radius: 10px;', 'background: var(--bg-card);', 'flex-wrap: wrap;']) {
|
||||
expect(block.slice(0, 2000)).toContain(decl);
|
||||
}
|
||||
// The detailed SIDEBAR shares the meta line and must stay flat: every rule
|
||||
// in the card block is rail-scoped, never added to the comma-grouped
|
||||
// selectors that carry both surfaces.
|
||||
const cardBlock = block.slice(0, block.indexOf('/* --- Collapsed rail'));
|
||||
expect(cardBlock).not.toContain('.session-sidebar');
|
||||
});
|
||||
|
||||
it('states read in the same three colours as both home screens', () => {
|
||||
const block = styles.slice(styles.indexOf(`${RAIL_RICH} .session-tabs {`));
|
||||
const cardBlock = block.slice(0, block.indexOf('/* --- Collapsed rail'));
|
||||
expect(cardBlock).toContain('.session-tab.tab-state-needs');
|
||||
expect(cardBlock).toContain('.session-tab.tab-state-waiting');
|
||||
expect(cardBlock).toContain('.session-tab.tab-state-working');
|
||||
// Reuses the home rail's keyframes rather than declaring a second pair that
|
||||
// could drift out of step with it.
|
||||
expect(cardBlock).toContain('home-sessions-blink-red');
|
||||
expect(cardBlock).toContain('home-sessions-blink-yellow');
|
||||
});
|
||||
|
||||
it('never lets a state dot outrank the red/yellow alert dot', () => {
|
||||
// The dot rules are (0,5,1)+; the alert rules that mark a session blocked on
|
||||
// a human are only (0,3,0), so each state rule excludes them by hand.
|
||||
const block = styles.slice(styles.indexOf(`${RAIL_RICH} .session-tabs {`));
|
||||
const cardBlock = block.slice(0, block.indexOf('/* --- Collapsed rail'));
|
||||
const dotRules = cardBlock.match(/\.session-tab\.tab-state-\w+[^{]*\.tab-status\b/g) ?? [];
|
||||
expect(dotRules.length).toBeGreaterThanOrEqual(3);
|
||||
for (const rule of dotRules) {
|
||||
expect(rule).toContain(':not(.tab-alert-action):not(.tab-alert-idle)');
|
||||
}
|
||||
});
|
||||
|
||||
it('pins web tabs past the sorted cards instead of interleaving them at order 0', () => {
|
||||
expect(styles).toContain(
|
||||
"html[data-tab-orientation='vertical'][data-tab-rail-sort='activity'] .tab-rail .session-tab[data-webview-id] {"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* @fileoverview Terminal font weight: live apply, and the plumbing around it.
|
||||
*
|
||||
* Bold text on the theme's default foreground carries exactly ONE cue, the
|
||||
* weight step. Claude Code marks its markdown bold with a bare `ESC[1m` and no
|
||||
* colour change, and xterm substitutes a bright colour for bold only when the
|
||||
* foreground is a palette index below 8, so nothing else distinguishes it. A
|
||||
* family that ships only a regular and a bold face keeps that step small, and
|
||||
* 400 stays 400 whatever family is picked — which is why the NORMAL slot is
|
||||
* settable at all.
|
||||
*
|
||||
* Three things are pinned here because each fails silently:
|
||||
*
|
||||
* - A live save reaches the echo overlays and the Agent Teams panes. Both
|
||||
* cache the weight (the overlays paint it into their spans, the panes read
|
||||
* their options at construction), so without the propagation the characters
|
||||
* being typed, or a pane left open across the save, keep the old weight
|
||||
* beside a repainted terminal.
|
||||
* - An unchanged save is a no-op, so opening and closing App Settings does not
|
||||
* churn the terminal.
|
||||
* - The bundled face is declared over its full axis. The `@font-face`
|
||||
* descriptor, not the file, is what the browser synthesizes from: at
|
||||
* `400 700` every weight below 400 renders identically to 400, so the
|
||||
* setting would be inert for anyone without Fira Code or Cascadia Code
|
||||
* installed.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
|
||||
* box; see connection-indicator.test.ts), matching terminal-font-settle.test.ts.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
|
||||
function loadTerminalMixin(): Record<string, unknown> {
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
CodemanApp: FakeCodemanApp,
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
document: undefined,
|
||||
});
|
||||
const constants = readFileSync(resolve(publicDir, 'constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(publicDir, 'terminal-ui.js'), 'utf8');
|
||||
vm.runInContext(`${constants}\n${source}`, context);
|
||||
// constants.js publishes CodemanTerminalFont onto the context's window, which
|
||||
// is the one the mixin closes over.
|
||||
return FakeCodemanApp.prototype;
|
||||
}
|
||||
|
||||
const mixin = loadTerminalMixin();
|
||||
|
||||
function fakeTerminal(options: Record<string, unknown> = {}) {
|
||||
return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } };
|
||||
}
|
||||
|
||||
function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTerminal> | null } = {}) {
|
||||
const fit = vi.fn();
|
||||
const teammateFits: ReturnType<typeof vi.fn>[] = [];
|
||||
const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>();
|
||||
for (let i = 0; i < (opts.teammates ?? 0); i++) {
|
||||
const teammateFit = vi.fn();
|
||||
teammateFits.push(teammateFit);
|
||||
teammateTerminals.set(`agent-${i}`, { terminal: fakeTerminal(), fitAddon: { fit: teammateFit } });
|
||||
}
|
||||
const app = {
|
||||
applyTerminalFontWeights: mixin.applyTerminalFontWeights,
|
||||
_awaitTerminalFont: vi.fn(() => Promise.resolve()),
|
||||
terminal: opts.terminal === undefined ? fakeTerminal() : opts.terminal,
|
||||
fitAddon: { fit },
|
||||
teammateTerminals,
|
||||
_localEchoOverlay: { refreshFont: vi.fn() },
|
||||
_predictiveEcho: { refreshFont: vi.fn() },
|
||||
_terminalFontReady: null as unknown,
|
||||
};
|
||||
return { app, fit, teammateFits, teammateTerminals };
|
||||
}
|
||||
|
||||
describe('applyTerminalFontWeights', () => {
|
||||
it('writes both slots to the live terminal', () => {
|
||||
const { app, fit } = makeApp();
|
||||
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||
terminalFontWeight: '300',
|
||||
terminalFontWeightBold: '800',
|
||||
});
|
||||
|
||||
expect(app.terminal?.options.fontWeight).toBe(300);
|
||||
expect(app.terminal?.options.fontWeightBold).toBe(800);
|
||||
expect(fit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes the echo overlays, which cache the weight and paint it', () => {
|
||||
// Without this the characters being typed keep the old weight while the
|
||||
// rest of the screen changes — most visible on a phone, where local echo
|
||||
// is on by default.
|
||||
const { app } = makeApp();
|
||||
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||
terminalFontWeight: '300',
|
||||
});
|
||||
|
||||
expect(app._localEchoOverlay.refreshFont).toHaveBeenCalledTimes(1);
|
||||
expect(app._predictiveEcho.refreshFont).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reaches Agent Teams panes, which read their options at construction', () => {
|
||||
const { app, teammateTerminals, teammateFits } = makeApp({ teammates: 2 });
|
||||
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||
terminalFontWeight: '300',
|
||||
terminalFontWeightBold: '800',
|
||||
});
|
||||
|
||||
for (const [, entry] of teammateTerminals) {
|
||||
expect(entry.terminal.options.fontWeight).toBe(300);
|
||||
expect(entry.terminal.options.fontWeightBold).toBe(800);
|
||||
}
|
||||
for (const teammateFit of teammateFits) expect(teammateFit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores xterm’s own defaults when the setting is cleared', () => {
|
||||
const { app } = makeApp({ terminal: fakeTerminal({ fontWeight: 300, fontWeightBold: 800 }) });
|
||||
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({});
|
||||
|
||||
expect(app.terminal?.options.fontWeight).toBe('normal');
|
||||
expect(app.terminal?.options.fontWeightBold).toBe('bold');
|
||||
});
|
||||
|
||||
it('does nothing when neither slot changed', () => {
|
||||
// saveAppSettings runs on every close of the modal.
|
||||
const { app, fit } = makeApp({ terminal: fakeTerminal({ fontWeight: 300, fontWeightBold: 'bold' }) });
|
||||
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||
terminalFontWeight: 300,
|
||||
});
|
||||
|
||||
expect(fit).not.toHaveBeenCalled();
|
||||
expect(app._localEchoOverlay.refreshFont).not.toHaveBeenCalled();
|
||||
expect(app._awaitTerminalFont).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-arms the font wait, so a fit lands once the face is rasterized', () => {
|
||||
const { app } = makeApp();
|
||||
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||
terminalFontWeight: '300',
|
||||
});
|
||||
|
||||
expect(app._awaitTerminalFont).toHaveBeenCalledTimes(1);
|
||||
expect(app._terminalFontReady).toBeInstanceOf(Promise);
|
||||
});
|
||||
|
||||
it('survives a terminal that does not exist yet', () => {
|
||||
const { app } = makeApp({ terminal: null });
|
||||
|
||||
expect(() =>
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||
terminalFontWeight: '300',
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bundled terminal face', () => {
|
||||
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
||||
|
||||
it('is declared over its full weight axis, not xterm’s default span', () => {
|
||||
// The woff2 carries a `wght` axis of 100 to 800. A narrower @font-face
|
||||
// descriptor CLAMPS it: at `400 700`, 100/200/300 all render identically to
|
||||
// 400 and 800 identically to 700, so the settings above would be a no-op
|
||||
// for every install without Fira Code or Cascadia Code.
|
||||
const face = styles.slice(styles.indexOf("font-family: 'JetBrains Mono'"));
|
||||
const declared = /font-weight:\s*(\d+)\s+(\d+)/.exec(face.slice(0, face.indexOf('}')));
|
||||
expect(declared, 'the bundled mono face must declare a weight RANGE').not.toBeNull();
|
||||
expect(Number(declared![1])).toBeLessThanOrEqual(100);
|
||||
expect(Number(declared![2])).toBeGreaterThanOrEqual(800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal font weight settings plumbing', () => {
|
||||
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
|
||||
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
|
||||
const keys = ['terminalFontWeight', 'terminalFontWeightBold'] as const;
|
||||
|
||||
it('offers both selects with a Default entry and the 100-900 steps', () => {
|
||||
for (const id of ['appSettingsTerminalFontWeight', 'appSettingsTerminalFontWeightBold']) {
|
||||
const start = html.indexOf(`<select id="${id}"`);
|
||||
expect(start, `${id} missing from index.html`).toBeGreaterThan(-1);
|
||||
const select = html.slice(start, html.indexOf('</select>', start));
|
||||
expect(select).toContain('<option value="">');
|
||||
for (let w = 100; w <= 900; w += 100) expect(select).toContain(`<option value="${w}">`);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats both as per-device, which is TWO separate decisions', () => {
|
||||
// Membership in displayKeys keeps one device from overwriting another's
|
||||
// value; the strip before the PUT is what stops the .strict() schema from
|
||||
// 400-ing the whole settings save.
|
||||
const displayKeys = settingsUi.slice(settingsUi.indexOf('const displayKeys = new Set(['));
|
||||
const listed = displayKeys.slice(0, displayKeys.indexOf(']);'));
|
||||
const stripped = settingsUi.slice(settingsUi.indexOf('const {', settingsUi.indexOf('async saveAppSettings()')));
|
||||
for (const key of keys) {
|
||||
expect(listed, `${key} must be a display key`).toContain(`'${key}'`);
|
||||
expect(stripped.slice(0, stripped.indexOf('} = settings;')), `${key} must be stripped from the PUT`).toContain(
|
||||
`${key}: _`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('applies the save to the live terminal', () => {
|
||||
const save = settingsUi.slice(settingsUi.indexOf('async saveAppSettings()'));
|
||||
expect(save.slice(0, save.indexOf('\n },'))).toContain('this.applyTerminalFontWeights?.(settings)');
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ function loadFontHelper() {
|
||||
CodemanTerminalFont: {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Wiring for the orphaned-input recovery controller, in a real browser.
|
||||
*
|
||||
* The controller's decision logic is unit-tested in
|
||||
* test/terminal-keycode229-recovery.test.ts. What can only be proven with a
|
||||
* real xterm instance is the wiring:
|
||||
*
|
||||
* - our `input` listener is registered AFTER xterm's, so xterm's `cancel()`
|
||||
* (stopPropagation, not stopImmediatePropagation) does not silence it;
|
||||
* - a `composed: true` insertText preceded by a keydown — the shape Chrome on
|
||||
* Android delivers — is dropped by xterm and recovered by us, exactly once;
|
||||
* - a keystroke xterm DOES handle is delivered exactly once, not twice.
|
||||
*
|
||||
* Browser-driven, so it is excluded from `npm run test:ci` like the other
|
||||
* Playwright suites. Run locally:
|
||||
* npm run test:browser -- test/terminal-keycode229-recovery.browser.test.ts
|
||||
*
|
||||
* Port: 3186 (per CLAUDE.md, ports 3150+ for tests)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3186;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('orphaned terminal input recovery wiring', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
await page.waitForFunction(() => (window as any).app?._keyCode229Recovery, null, { timeout: 30000 });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
/**
|
||||
* Drive one keystroke through the real textarea and report what reached the
|
||||
* PTY send path. `dispatchInput` mirrors GBoard: a keydown with no usable key
|
||||
* identity, then a `composed: true` insertText that xterm refuses to forward.
|
||||
*/
|
||||
async function keystroke(options: { data: string; dispatchInput: boolean; keyCode: number }) {
|
||||
return page.evaluate(async ({ data, dispatchInput, keyCode }) => {
|
||||
const app = (window as any).app;
|
||||
const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement;
|
||||
const originalSessionId = app.activeSessionId;
|
||||
const originalLocalEcho = app._localEchoEnabled;
|
||||
const originalSendInput = app._sendInputAsync;
|
||||
const originalPendingInput = app._pendingInput;
|
||||
const originalLastKeystrokeTime = app._lastKeystrokeTime;
|
||||
const sent: string[] = [];
|
||||
let xtermEmitted = 0;
|
||||
const rec = app._keyCode229Recovery;
|
||||
|
||||
try {
|
||||
app.activeSessionId = 'cod388-browser-regression';
|
||||
app._localEchoEnabled = false;
|
||||
app._pendingInput = '';
|
||||
app._lastKeystrokeTime = 0;
|
||||
app._sendInputAsync = (_sessionId: string, chunk: string) => sent.push(chunk);
|
||||
// The controller object is Object.freeze()d, so count xterm's own
|
||||
// canonical emissions by swapping the (writable) property on app.
|
||||
app._keyCode229Recovery = {
|
||||
handleKeyEvent: (e: any) => rec.handleKeyEvent(e),
|
||||
notifyCanonicalData: () => {
|
||||
xtermEmitted += 1;
|
||||
return rec.notifyCanonicalData();
|
||||
},
|
||||
destroy: () => rec.destroy(),
|
||||
};
|
||||
textarea.focus();
|
||||
|
||||
const down = new KeyboardEvent('keydown', {
|
||||
key: 'Unidentified',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
Object.defineProperties(down, { keyCode: { value: keyCode }, which: { value: keyCode } });
|
||||
textarea.dispatchEvent(down);
|
||||
|
||||
if (dispatchInput) {
|
||||
textarea.value = data;
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', { data, inputType: 'insertText', bubbles: true, composed: true })
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
return { sent, xtermEmitted };
|
||||
} finally {
|
||||
app.activeSessionId = originalSessionId;
|
||||
app._localEchoEnabled = originalLocalEcho;
|
||||
app._sendInputAsync = originalSendInput;
|
||||
app._pendingInput = originalPendingInput;
|
||||
app._lastKeystrokeTime = originalLastKeystrokeTime;
|
||||
app._keyCode229Recovery = rec;
|
||||
textarea.value = '';
|
||||
}
|
||||
}, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠ The gap this controller actually fills is NARROWER than "keyCode 229",
|
||||
* and that matters for what these tests can prove.
|
||||
*
|
||||
* xterm already self-recovers keyCode 229: `CompositionHelper.keydown()`
|
||||
* calls `_handleAnyTextareaChanges()`, which snapshots `textarea.value` and
|
||||
* diffs it on a 0 ms timer, emitting the difference itself. So for a 229
|
||||
* keydown there is nothing orphaned to recover, and a test asserting "we
|
||||
* recovered it" would pass while xterm did all the work — measured: xterm
|
||||
* emits, our controller correctly stands down.
|
||||
*
|
||||
* The real gap is an `insertText` input event that xterm's `_inputEvent`
|
||||
* refuses (`composed: true` with a keydown seen) where NO 229 diff was
|
||||
* scheduled to rescue it. These tests therefore assert WHO delivered the
|
||||
* byte, via `xtermEmitted`, not merely that a byte arrived.
|
||||
*/
|
||||
it('recovers a composed insertText that xterm dropped and did not self-rescue', async () => {
|
||||
const { sent, xtermEmitted } = await keystroke({ data: 'x', dispatchInput: true, keyCode: 65 });
|
||||
expect(xtermEmitted).toBe(0); // xterm delivered nothing: genuinely orphaned
|
||||
expect(sent.join('')).toBe('x'); // ...so this byte is ours
|
||||
});
|
||||
|
||||
it('does not duplicate a keystroke xterm self-rescued via its own 0 ms diff', async () => {
|
||||
const { sent, xtermEmitted } = await keystroke({ data: 'y', dispatchInput: true, keyCode: 229 });
|
||||
expect(xtermEmitted).toBe(1); // xterm's 229 textarea diff spoke
|
||||
expect(sent.join('')).toBe('y'); // exactly once — we must not add a second copy
|
||||
});
|
||||
|
||||
it('recovers the same character twice when both keystrokes are orphaned', async () => {
|
||||
const first = await keystroke({ data: 'z', dispatchInput: true, keyCode: 65 });
|
||||
const second = await keystroke({ data: 'z', dispatchInput: true, keyCode: 65 });
|
||||
expect(first.sent.join('')).toBe('z');
|
||||
expect(second.sent.join('')).toBe('z');
|
||||
});
|
||||
|
||||
it('sends nothing for a keydown that produces no input event', async () => {
|
||||
const { sent } = await keystroke({ data: 'q', dispatchInput: false, keyCode: 65 });
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Orphaned-input recovery for xterm's helper textarea (PR #388 / COD-27).
|
||||
*
|
||||
* xterm's CoreBrowserTerminal._inputEvent only forwards an `insertText` input
|
||||
* event when `(!ev.composed || !this._keyDownSeen)`. Chrome-on-Android's soft
|
||||
* keyboard produces `composed: true` input events preceded by a keydown, so
|
||||
* that guard is false and the committed character is silently dropped.
|
||||
*
|
||||
* The controller under test forwards the event's own `data` when — and only
|
||||
* when — xterm produced no canonical data for that keystroke. These tests
|
||||
* drive it with synthetic events and an injected timer; no browser is needed.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
type Listener = (event: Record<string, unknown>) => void;
|
||||
|
||||
function makeTextarea() {
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
const registrations: Array<{ type: string; capture: unknown }> = [];
|
||||
return {
|
||||
addEventListener(type: string, listener: Listener, capture?: unknown) {
|
||||
const bucket = listeners.get(type) ?? new Set<Listener>();
|
||||
bucket.add(listener);
|
||||
listeners.set(type, bucket);
|
||||
registrations.push({ type, capture });
|
||||
},
|
||||
removeEventListener(type: string, listener: Listener) {
|
||||
listeners.get(type)?.delete(listener);
|
||||
},
|
||||
fire(type: string, event: Record<string, unknown> = {}) {
|
||||
for (const listener of [...(listeners.get(type) ?? [])]) listener({ type, ...event });
|
||||
},
|
||||
listenerCount() {
|
||||
return [...listeners.values()].reduce((total, bucket) => total + bucket.size, 0);
|
||||
},
|
||||
registrations() {
|
||||
return [...registrations];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A committed-text `input` event of the shape Chrome-on-Android delivers. */
|
||||
function inputEvent(data: string, overrides: Record<string, unknown> = {}) {
|
||||
return { data, inputType: 'insertText', isComposing: false, ...overrides };
|
||||
}
|
||||
|
||||
function harness({ screenReader = false } = {}) {
|
||||
const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8');
|
||||
const exposed: Record<string, any> = {};
|
||||
vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' });
|
||||
|
||||
const textarea = makeTextarea();
|
||||
const emitted: string[] = [];
|
||||
const timers = new Map<number, () => void>();
|
||||
let timerId = 0;
|
||||
|
||||
const controller = exposed.CodemanKeyCode229Recovery.create({
|
||||
textarea,
|
||||
emitRecovered: (data: string) => emitted.push(data),
|
||||
isScreenReaderMode: () => screenReader,
|
||||
setTimer: (callback: () => void) => {
|
||||
const id = ++timerId;
|
||||
timers.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id: number) => timers.delete(id),
|
||||
});
|
||||
|
||||
return {
|
||||
controller,
|
||||
emitted,
|
||||
textarea,
|
||||
/** A keydown that carries NO usable key identity, exactly like GBoard's. */
|
||||
keydown(overrides: Record<string, unknown> = {}) {
|
||||
controller.handleKeyEvent({ type: 'keydown', key: 'Unidentified', keyCode: 229, ...overrides });
|
||||
},
|
||||
input(data: string, overrides: Record<string, unknown> = {}) {
|
||||
textarea.fire('input', inputEvent(data, overrides));
|
||||
},
|
||||
flushTimers() {
|
||||
for (const [id, callback] of [...timers]) {
|
||||
timers.delete(id);
|
||||
callback();
|
||||
}
|
||||
},
|
||||
pendingTimers: () => timers.size,
|
||||
};
|
||||
}
|
||||
|
||||
/** terminal-ui.js's exported predicates, loaded the same way as in test/mobile-shell-keyboard.test.ts. */
|
||||
function loadTerminalInput() {
|
||||
const source = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf8');
|
||||
const win: Record<string, any> = {
|
||||
addEventListener() {},
|
||||
matchMedia: () => ({ matches: false, addEventListener() {} }),
|
||||
};
|
||||
const sandbox: Record<string, any> = {
|
||||
window: win,
|
||||
globalThis: win,
|
||||
document: { addEventListener() {} },
|
||||
CodemanApp: class {},
|
||||
};
|
||||
win.CodemanApp = sandbox.CodemanApp;
|
||||
vm.runInNewContext(source, sandbox, { filename: 'terminal-ui.js' });
|
||||
return win.CodemanTerminalInput as {
|
||||
shouldSuppressTerminalQueryResponse(data: string): boolean;
|
||||
isTerminalFocusOrMouseReport(data: string): boolean;
|
||||
};
|
||||
}
|
||||
|
||||
describe('orphaned terminal input recovery', () => {
|
||||
it('forwards the committed text when xterm stayed silent', () => {
|
||||
const h = harness();
|
||||
h.keydown();
|
||||
h.input('x');
|
||||
expect(h.emitted).toEqual([]);
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual(['x']);
|
||||
});
|
||||
|
||||
it('forwards nothing when xterm emitted canonical data after the keydown', () => {
|
||||
// The "xterm handled it" case is decided by the COUNTER, never by assuming
|
||||
// the input event does not reach us. On capture it always does (xterm's
|
||||
// cancel() only stops later BUBBLE listeners), so this test dispatches the
|
||||
// real input event AND has xterm emit canonical data for that keystroke.
|
||||
const h = harness();
|
||||
h.keydown();
|
||||
h.input('x');
|
||||
h.controller.notifyCanonicalData();
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual([]);
|
||||
});
|
||||
|
||||
it('registers the input listener in the CAPTURE phase', () => {
|
||||
// Measured in jsdom and headless chromium: a capture-phase listener on the
|
||||
// TARGET calling stopPropagation() (which is what xterm's cancel() does in
|
||||
// the branch where it handled the input) stops later BUBBLE listeners on
|
||||
// that same target, because the target is visited twice in the event path.
|
||||
//
|
||||
// capture-then-BUBBLE, stopPropagation: ours NEVER fires
|
||||
// capture-then-CAPTURE, stopPropagation: ours still fires
|
||||
//
|
||||
// So this must not be "tidied" to bubble: on bubble we would silently stop
|
||||
// seeing exactly the events xterm handled, and whether we saw them at all
|
||||
// would depend on xterm's `options.cancelEvents`, which Codeman never sets.
|
||||
const h = harness();
|
||||
const input = h.textarea.registrations().filter((entry) => entry.type === 'input');
|
||||
expect(input).toHaveLength(1);
|
||||
expect(input[0].capture).toBe(true);
|
||||
for (const entry of h.textarea.registrations()) expect(entry.capture).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards nothing on the keypress path, where canonical data precedes the input event', () => {
|
||||
// xterm's _keyPress calls triggerDataEvent() and sets _keyPressHandled
|
||||
// BEFORE the input event fires. The "did xterm speak?" snapshot therefore
|
||||
// has to be taken at keydown; taken at input time it would already include
|
||||
// this emission and the character would be delivered twice.
|
||||
const h = harness();
|
||||
h.keydown();
|
||||
h.controller.notifyCanonicalData();
|
||||
h.input('x');
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not let a stale candidate swallow a later identical keystroke (defect 1)', () => {
|
||||
const h = harness();
|
||||
|
||||
// First keystroke: orphaned, recovered.
|
||||
h.keydown();
|
||||
h.input('x');
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual(['x']);
|
||||
|
||||
// Second identical keystroke, handled by xterm itself.
|
||||
h.keydown();
|
||||
h.input('x');
|
||||
h.controller.notifyCanonicalData();
|
||||
h.flushTimers();
|
||||
|
||||
// Exactly one recovery total, and the second keystroke's canonical byte was
|
||||
// never claimed or suppressed by the first one.
|
||||
expect(h.emitted).toEqual(['x']);
|
||||
});
|
||||
|
||||
it('forwards committed text that no keydown key could describe, exactly once (defect 2)', () => {
|
||||
const h = harness();
|
||||
h.keydown({ key: 'Enter' });
|
||||
h.input('a longer commit');
|
||||
h.flushTimers();
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual(['a longer commit']);
|
||||
});
|
||||
|
||||
it('recovers a GBoard keydown and never reads key or keyCode (defect 3)', () => {
|
||||
const h = harness();
|
||||
const reads: string[] = [];
|
||||
h.controller.handleKeyEvent({
|
||||
type: 'keydown',
|
||||
get key() {
|
||||
reads.push('key');
|
||||
return 'Unidentified';
|
||||
},
|
||||
get keyCode() {
|
||||
reads.push('keyCode');
|
||||
return 229;
|
||||
},
|
||||
get which() {
|
||||
reads.push('which');
|
||||
return 229;
|
||||
},
|
||||
});
|
||||
h.input('x');
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual(['x']);
|
||||
expect(reads).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores input events that are not committed text', () => {
|
||||
const h = harness();
|
||||
for (const inputType of ['insertCompositionText', 'deleteContentBackward', 'insertLineBreak', 'insertFromPaste']) {
|
||||
h.keydown();
|
||||
h.input('x', { inputType });
|
||||
}
|
||||
h.keydown();
|
||||
h.input('');
|
||||
h.keydown();
|
||||
h.textarea.fire('input', { data: null, inputType: 'insertText' });
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores composition and cancels pending candidates on compositionstart', () => {
|
||||
const composing = harness();
|
||||
composing.keydown();
|
||||
composing.input('x', { isComposing: true });
|
||||
composing.flushTimers();
|
||||
expect(composing.emitted).toEqual([]);
|
||||
|
||||
const lifecycle = harness();
|
||||
lifecycle.textarea.fire('compositionstart');
|
||||
lifecycle.keydown();
|
||||
lifecycle.input('中');
|
||||
lifecycle.flushTimers();
|
||||
expect(lifecycle.emitted).toEqual([]);
|
||||
|
||||
// compositionstart arriving after a candidate is queued must cancel it.
|
||||
const cancelled = harness();
|
||||
cancelled.keydown();
|
||||
cancelled.input('x');
|
||||
cancelled.textarea.fire('compositionstart');
|
||||
cancelled.flushTimers();
|
||||
expect(cancelled.emitted).toEqual([]);
|
||||
|
||||
// compositionend releases the gate again.
|
||||
cancelled.textarea.fire('compositionend');
|
||||
cancelled.keydown();
|
||||
cancelled.input('y');
|
||||
cancelled.flushTimers();
|
||||
expect(cancelled.emitted).toEqual(['y']);
|
||||
});
|
||||
|
||||
it('stays out of the way in screen reader mode', () => {
|
||||
const h = harness({ screenReader: true });
|
||||
h.keydown();
|
||||
h.input('x');
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual([]);
|
||||
});
|
||||
|
||||
it('recovers an input event that arrives with no preceding keydown', () => {
|
||||
const h = harness();
|
||||
h.input('x');
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual(['x']);
|
||||
});
|
||||
|
||||
it('clears timers and listeners on destroy', () => {
|
||||
const h = harness();
|
||||
expect(h.textarea.listenerCount()).toBeGreaterThan(0);
|
||||
h.keydown();
|
||||
h.input('x');
|
||||
expect(h.pendingTimers()).toBe(1);
|
||||
|
||||
h.controller.destroy();
|
||||
expect(h.pendingTimers()).toBe(0);
|
||||
expect(h.textarea.listenerCount()).toBe(0);
|
||||
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual([]);
|
||||
|
||||
// Nothing fires after destroy, even if a stray event is delivered.
|
||||
h.textarea.fire('input', inputEvent('y'));
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the first input event of a page load, with no keydown before it', () => {
|
||||
it('stands down when xterm already delivered it, instead of duplicating the text', () => {
|
||||
// Dictation (Android voice typing, desktop dictation, any `insertText` with
|
||||
// no key held) reaches xterm with `_keyDownSeen` false, so xterm's OWN capture
|
||||
// listener forwards it and bumps the canonical counter before this controller's
|
||||
// listener runs. The snapshot baseline has to predate that bump, or the
|
||||
// candidate reads "xterm stayed silent" and emits the text a second time.
|
||||
const h = harness();
|
||||
h.controller.notifyCanonicalData(); // xterm delivered it first
|
||||
h.input('hello');
|
||||
h.flushTimers();
|
||||
expect(h.emitted, 'xterm already delivered this text').toEqual([]);
|
||||
});
|
||||
|
||||
it('still recovers one that xterm genuinely dropped', () => {
|
||||
const h = harness();
|
||||
h.input('hello'); // nothing from xterm for it
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual(['hello']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal-ui wiring: what counts as "xterm spoke for this keystroke"', () => {
|
||||
const terminalSource = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf8');
|
||||
|
||||
it('gates notifyCanonicalData on the two predicates this file already owns', () => {
|
||||
// onData does NOT only carry keystrokes: xterm answers DA/DSR/CPR/OSC
|
||||
// queries through it during Ink redraws, and emits SGR mouse and focus
|
||||
// reports on its own initiative. Counting one of those as canonical data
|
||||
// for the pending keystroke stands the recovery down and leaves the
|
||||
// character dropped, worst on a busy agent pane, which is the case this
|
||||
// exists for. Same gate, same two predicates, as the one-shot Ctrl
|
||||
// modifier uses for the same question (test/mobile-shell-keyboard.test.ts).
|
||||
const notify = terminalSource.indexOf('_keyCode229Recovery?.notifyCanonicalData?.()');
|
||||
expect(notify).toBeGreaterThan(0);
|
||||
|
||||
const gate = terminalSource.lastIndexOf(
|
||||
'!input?.shouldSuppressTerminalQueryResponse(data) && !input?.isTerminalFocusOrMouseReport(data)',
|
||||
notify
|
||||
);
|
||||
expect(gate).toBeGreaterThan(0);
|
||||
expect(gate).toBeLessThan(notify);
|
||||
|
||||
// ⚠️ The predicates live inside the module IIFE that ends long before this
|
||||
// call site, so they are reachable ONLY through the global. Bare references
|
||||
// would throw a ReferenceError straight into the surrounding try/catch,
|
||||
// which swallows it, and notifyCanonicalData would then NEVER run: the
|
||||
// recovery would re-emit a character xterm already delivered.
|
||||
expect(terminalSource.slice(gate - 120, notify)).toContain('window.CodemanTerminalInput');
|
||||
});
|
||||
|
||||
it('stands down for a real keystroke, but not for a mouse report or a query reply', () => {
|
||||
// The gate as terminal-ui.js writes it. The wiring test above pins the real
|
||||
// source; this proves the behaviour it buys.
|
||||
const input = loadTerminalInput();
|
||||
const onData = (h: ReturnType<typeof harness>, data: string) => {
|
||||
if (!input.shouldSuppressTerminalQueryResponse(data) && !input.isTerminalFocusOrMouseReport(data)) {
|
||||
h.controller.notifyCanonicalData();
|
||||
}
|
||||
};
|
||||
|
||||
for (const noise of ['\x1b[<0;10;5M', '\x1b[I', '\x1b[?1;2c']) {
|
||||
const h = harness();
|
||||
h.keydown();
|
||||
h.input('x');
|
||||
onData(h, noise);
|
||||
h.flushTimers();
|
||||
expect(h.emitted, `${JSON.stringify(noise)} must not stand the recovery down`).toEqual(['x']);
|
||||
}
|
||||
|
||||
const typed = harness();
|
||||
typed.keydown();
|
||||
typed.input('x');
|
||||
onData(typed, 'x');
|
||||
typed.flushTimers();
|
||||
expect(typed.emitted, 'xterm really did deliver this one').toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -172,6 +172,43 @@ describe('TmuxManager (unit)', () => {
|
||||
expect(command).toContain('exec "${SHELL:-/bin/sh}" -i -l -c');
|
||||
expect(command).toContain('claude --dangerously-skip-permissions');
|
||||
});
|
||||
|
||||
it('pins SSH-remote claude to the Codeman session id so a respawn resumes the same conversation', () => {
|
||||
// Regression (2026-08-29): remote claude was launched as a bare `claude …`,
|
||||
// so every reattach/respawn after a pane death (user ctrl-d or ctrl-c exit)
|
||||
// started a NEW conversation. The launch now mirrors the docker-claude shape:
|
||||
// `--session-id <id>` to create, with a `|| --resume <id>` fallback so the
|
||||
// idempotent re-run resumes instead of erroring ("already in use").
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'claude',
|
||||
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
expect(command).toContain('claude --dangerously-skip-permissions --session-id abc123def456');
|
||||
expect(command).toContain('claude --dangerously-skip-permissions --resume abc123def456');
|
||||
});
|
||||
|
||||
it('resumes an explicit resumeSessionId distinct from sessionId (mirrors claudeDockerPaneCommand)', () => {
|
||||
// The docker-claude builder (claudeDockerPaneCommand) has always handled a
|
||||
// resumeId that differs from sessionId — e.g. a resume-from-history launch —
|
||||
// by leading with `--resume <rid> || --session-id <sessionId>`. The remote
|
||||
// claude branch used to only mirror the SAME-id fallback shape and silently
|
||||
// dropped a distinct resumeSessionId, so a remote resume-from-history claude
|
||||
// launch created a brand-new conversation instead of resuming the named one.
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'claude',
|
||||
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||
sessionId: 'abc123def456',
|
||||
resumeSessionId: 'old-conversation-uuid',
|
||||
});
|
||||
expect(command).toContain('claude --dangerously-skip-permissions --resume old-conversation-uuid');
|
||||
expect(command).toContain('claude --dangerously-skip-permissions --session-id abc123def456');
|
||||
// The resume attempt must lead — session-id is the fallback here, reversed
|
||||
// from the same-id case.
|
||||
const resumeIdx = command.indexOf('--resume old-conversation-uuid');
|
||||
const sessionIdIdx = command.indexOf('--session-id abc123def456');
|
||||
expect(resumeIdx).toBeLessThan(sessionIdIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote kill command builder', () => {
|
||||
@@ -258,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';
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* @fileoverview Loopback links open through a proxied web tab (webview-tabs.js).
|
||||
*
|
||||
* An agent prints `http://localhost:5173/` and the user taps it on a phone. The
|
||||
* browser there can never reach the Codeman box's loopback, so the link was a
|
||||
* guaranteed connection error — while the web-tab proxy fetches from the server,
|
||||
* where it works. Pinned here:
|
||||
*
|
||||
* 1. The decision: only http(s) on a loopback host, and only when the page
|
||||
* itself is not on that host. A LAN/tailnet address stays a direct open.
|
||||
* 2. A saved proxied dashboard on the same origin is reused, with the deep
|
||||
* path appended to the minted proxy prefix; a mounted frame is navigated,
|
||||
* not torn down.
|
||||
* 3. An unknown origin is saved under its host:port and opened.
|
||||
* 4. The terminal link provider and the response viewer consult the hook
|
||||
* before their own opening path.
|
||||
*
|
||||
* Same in-test JSDOM boot as webview-menu-rows.test.ts (no per-file env).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const CONSTANTS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
|
||||
const WEBVIEW_TABS = readFileSync(new URL('../src/web/public/webview-tabs.js', import.meta.url), 'utf-8');
|
||||
const TERMINAL_UI = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf-8');
|
||||
const APP_JS = readFileSync(new URL('../src/web/public/app.js', import.meta.url), 'utf-8');
|
||||
|
||||
type Webview = { id: string; name: string; url: string; embedMode?: string; managed?: string };
|
||||
|
||||
interface AppLike {
|
||||
webviews: Map<string, Webview>;
|
||||
webviewOrder: string[];
|
||||
activeWebviewId: string | null;
|
||||
renderSessionTabs(): void;
|
||||
refreshWebviews(): Promise<void>;
|
||||
openLinkThroughWebTabIfLoopback(url: string): boolean;
|
||||
openUrlInWebTab(url: string): Promise<void>;
|
||||
openWebview(id: string, options?: { path?: string }): Promise<void>;
|
||||
_apiJson(path: string, opts?: { method?: string; body?: unknown }): Promise<unknown>;
|
||||
_updateActiveWebviewTab(): void;
|
||||
showToast?: (msg: string, kind: string) => void;
|
||||
}
|
||||
|
||||
function boot(pageUrl = 'http://192.168.1.135:8095/') {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><body><div class="main"></div><div id="sessionTabs"></div><div id="webviewLayer"></div></body>`,
|
||||
{ url: pageUrl, runScripts: 'outside-only' }
|
||||
);
|
||||
const win = dom.window as unknown as Window &
|
||||
typeof globalThis & { app: AppLike; CodemanApp: new () => AppLike; CodemanWebviewLinks: WebviewLinks };
|
||||
(win as unknown as { eval: (s: string) => void }).eval(
|
||||
[
|
||||
'window.CodemanApp = class CodemanApp {};',
|
||||
'window.CodemanBase = { url: (p) => p };',
|
||||
'if (!window.CSS) window.CSS = { escape: (s) => s };',
|
||||
'window.requestAnimationFrame = (fn) => { fn(); return 1; };',
|
||||
CONSTANTS,
|
||||
WEBVIEW_TABS,
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const calls: Array<{ path: string; method: string; body?: unknown }> = [];
|
||||
const app = new win.CodemanApp();
|
||||
app.webviews = new Map([
|
||||
['dev', { id: 'dev', name: 'localhost:5173', url: 'http://localhost:5173/' }],
|
||||
['direct', { id: 'direct', name: 'Direct', url: 'https://localhost:9443/', embedMode: 'direct' }],
|
||||
]);
|
||||
app.webviewOrder = [];
|
||||
app.activeWebviewId = null;
|
||||
app.renderSessionTabs = () => {};
|
||||
app._updateActiveWebviewTab = () => {};
|
||||
app.refreshWebviews = async () => {};
|
||||
app._apiJson = async (path: string, opts: { method?: string; body?: unknown } = {}) => {
|
||||
calls.push({ path, method: opts.method || 'GET', body: opts.body });
|
||||
if (path === '/api/webviews' && opts.method === 'POST') {
|
||||
const body = opts.body as { name: string; url: string };
|
||||
const created = { id: 'new-id', name: body.name, url: body.url, embedMode: 'proxy' };
|
||||
app.webviews.set(created.id, created);
|
||||
return created;
|
||||
}
|
||||
const open = /^\/api\/webviews\/([^/]+)\/open$/.exec(path);
|
||||
if (open) {
|
||||
const id = decodeURIComponent(open[1]);
|
||||
const webview = app.webviews.get(id);
|
||||
if (!webview) return null;
|
||||
return webview.embedMode === 'direct' ? { webview } : { webview, embedUrl: `/webview/cap-${id}/` };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
win.app = app;
|
||||
return { win, app, calls };
|
||||
}
|
||||
|
||||
interface WebviewLinks {
|
||||
isLoopbackHostname(host: string): boolean;
|
||||
isOnBoxHostname(host: string): boolean;
|
||||
webTabOriginKey(url: URL): string;
|
||||
linkNeedsWebTabProxy(url: string, pageHostname: string): boolean;
|
||||
}
|
||||
|
||||
const frameSrc = (win: Window, id: string) =>
|
||||
(
|
||||
win.document.querySelector(`.webview-frame[data-webview-id="${id}"] iframe`) as HTMLIFrameElement | null
|
||||
)?.getAttribute('src');
|
||||
|
||||
describe('loopback link decision', () => {
|
||||
const { win } = boot();
|
||||
const links = win.CodemanWebviewLinks;
|
||||
|
||||
it('recognises every loopback spelling and nothing else', () => {
|
||||
for (const host of ['localhost', 'LOCALHOST', '127.0.0.1', '127.1.2.3', '0.0.0.0', '[::1]', '::1']) {
|
||||
expect(links.isLoopbackHostname(host), host).toBe(true);
|
||||
}
|
||||
for (const host of [
|
||||
'192.168.1.135',
|
||||
'10.9.0.4',
|
||||
'172.16.0.2',
|
||||
'box.ts.net',
|
||||
'128.0.0.1',
|
||||
'',
|
||||
'localhost.example.com',
|
||||
]) {
|
||||
expect(links.isLoopbackHostname(host), host).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps *.localhost OUT of the auto-route set, because it is a DNS name an attacker can steer', () => {
|
||||
// Every other member of the set is an address literal that can only mean
|
||||
// this box. `evil.localhost` is not: on a resolver that does not synthesise
|
||||
// *.localhost locally and has a search domain configured, it NXDOMAINs as
|
||||
// absolute and is retried as `evil.localhost.<search domain>`. The link
|
||||
// source is agent-written terminal output, so this set is the whole
|
||||
// confinement on a tap that makes Codeman fetch a URL and persist it.
|
||||
expect(links.isLoopbackHostname('app.localhost')).toBe(false);
|
||||
expect(links.isLoopbackHostname('evil.localhost')).toBe(false);
|
||||
expect(links.linkNeedsWebTabProxy('http://evil.localhost/', '192.168.1.135')).toBe(false);
|
||||
|
||||
// The PAGE-side test stays broader: a false positive there only ever
|
||||
// DECLINES to proxy, leaving the caller's own direct open untouched.
|
||||
expect(links.isOnBoxHostname('app.localhost')).toBe(true);
|
||||
expect(links.linkNeedsWebTabProxy('http://localhost:5173/', 'app.localhost')).toBe(false);
|
||||
});
|
||||
|
||||
it('keys a dashboard per dev server, not per host spelling', () => {
|
||||
const key = (u: string) => links.webTabOriginKey(new URL(u));
|
||||
expect(key('http://localhost:5173/')).toBe(key('http://127.0.0.1:5173/'));
|
||||
expect(key('http://localhost:5173/')).not.toBe(key('http://localhost:5174/'));
|
||||
expect(key('http://localhost:5173/')).not.toBe(key('https://localhost:5173/'));
|
||||
expect(key('http://box.ts.net:3000/')).toBe('http://box.ts.net:3000');
|
||||
});
|
||||
|
||||
it('proxies a loopback http(s) link only when the page is not on that box', () => {
|
||||
expect(links.linkNeedsWebTabProxy('http://localhost:5173/', '192.168.1.135')).toBe(true);
|
||||
expect(links.linkNeedsWebTabProxy('https://127.0.0.1:8443/x?y=1', 'box.ts.net')).toBe(true);
|
||||
// On the box itself the browser reaches localhost directly.
|
||||
expect(links.linkNeedsWebTabProxy('http://localhost:5173/', 'localhost')).toBe(false);
|
||||
expect(links.linkNeedsWebTabProxy('http://localhost:5173/', '127.0.0.1')).toBe(false);
|
||||
// A LAN address may be reachable from the device; leave it direct.
|
||||
expect(links.linkNeedsWebTabProxy('http://192.168.1.135:3000/', '192.168.1.135')).toBe(false);
|
||||
expect(links.linkNeedsWebTabProxy('http://10.9.0.4:8095/', '192.168.1.135')).toBe(false);
|
||||
// Not a web URL at all.
|
||||
expect(links.linkNeedsWebTabProxy('ftp://localhost/', '192.168.1.135')).toBe(false);
|
||||
expect(links.linkNeedsWebTabProxy('not a url', '192.168.1.135')).toBe(false);
|
||||
expect(links.linkNeedsWebTabProxy('', '192.168.1.135')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openLinkThroughWebTabIfLoopback', () => {
|
||||
it('reuses the saved proxied dashboard on that origin and opens the deep path', async () => {
|
||||
const { win, app, calls } = boot();
|
||||
expect(app.openLinkThroughWebTabIfLoopback('http://localhost:5173/pages/report?tab=2#top')).toBe(true);
|
||||
await vi.waitFor(() => expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/pages/report?tab=2#top'));
|
||||
expect(calls.some((c) => c.path === '/api/webviews' && c.method === 'POST')).toBe(false);
|
||||
expect(app.activeWebviewId).toBe('dev');
|
||||
expect(app.webviewOrder).toEqual(['dev']);
|
||||
});
|
||||
|
||||
it('navigates an already-mounted frame instead of remounting it', async () => {
|
||||
const { win, app } = boot();
|
||||
await app.openWebview('dev');
|
||||
const first = win.document.querySelector('.webview-frame[data-webview-id="dev"] iframe');
|
||||
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/');
|
||||
|
||||
await app.openUrlInWebTab('http://localhost:5173/other');
|
||||
expect(win.document.querySelector('.webview-frame[data-webview-id="dev"] iframe')).toBe(first);
|
||||
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/other');
|
||||
expect(win.document.querySelectorAll('.webview-frame').length).toBe(1);
|
||||
});
|
||||
|
||||
it('navigates an already-mounted frame back to the origin ROOT, which used to do nothing', async () => {
|
||||
// openUrlInWebTab used to flatten '/' to '', and openWebview reads an empty
|
||||
// path as "no deep link", so it mounted with navigate:false and an open
|
||||
// frame stayed on whatever page it was showing. Deep links navigated; a tap
|
||||
// on the bare origin silently did not.
|
||||
const { win, app } = boot();
|
||||
await app.openUrlInWebTab('http://localhost:5173/deep/page?a=1');
|
||||
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/deep/page?a=1');
|
||||
|
||||
await app.openUrlInWebTab('http://localhost:5173/');
|
||||
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/');
|
||||
expect(win.document.querySelectorAll('.webview-frame').length).toBe(1);
|
||||
});
|
||||
|
||||
it('reuses one dashboard across host spellings of the same dev server', async () => {
|
||||
const { win, app, calls } = boot();
|
||||
// The saved dashboard is http://localhost:5173/; a 127.0.0.1 link to the
|
||||
// same port is the same server and must not mint a second tab.
|
||||
await app.openUrlInWebTab('http://127.0.0.1:5173/status');
|
||||
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/status');
|
||||
expect(calls.find((c) => c.method === 'POST' && c.path === '/api/webviews')).toBeUndefined();
|
||||
expect(win.document.querySelectorAll('.webview-frame').length).toBe(1);
|
||||
});
|
||||
|
||||
it('waits for an in-flight webview load instead of POSTing a duplicate record', async () => {
|
||||
// initWebviews() assigns a truthy EMPTY map synchronously and only then
|
||||
// awaits GET /api/webviews, so "is this.webviews set" answered "is it
|
||||
// loaded" wrongly: a tap inside that round trip found nothing to reuse and
|
||||
// saved a second dashboard for an origin that already existed server-side.
|
||||
const { win, app, calls } = boot();
|
||||
const loaded = app.webviews;
|
||||
app.webviews = new Map();
|
||||
let release: () => void = () => {};
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
(app as unknown as { _webviewsRefresh: Promise<void> })._webviewsRefresh = gate.then(() => {
|
||||
app.webviews = loaded;
|
||||
});
|
||||
|
||||
const tap = app.openUrlInWebTab('http://localhost:5173/late');
|
||||
release();
|
||||
await tap;
|
||||
|
||||
expect(calls.find((c) => c.method === 'POST' && c.path === '/api/webviews')).toBeUndefined();
|
||||
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/late');
|
||||
});
|
||||
|
||||
it('saves an unknown origin under its host:port, then opens it', async () => {
|
||||
const { win, app, calls } = boot();
|
||||
expect(app.openLinkThroughWebTabIfLoopback('http://127.0.0.1:3000/')).toBe(true);
|
||||
await vi.waitFor(() => expect(frameSrc(win, 'new-id')).toBe('/webview/cap-new-id/'));
|
||||
const post = calls.find((c) => c.path === '/api/webviews' && c.method === 'POST');
|
||||
expect(post?.body).toEqual({
|
||||
name: '127.0.0.1:3000',
|
||||
url: 'http://127.0.0.1:3000/',
|
||||
embedMode: 'proxy',
|
||||
trusted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reuse a TRUSTED dashboard, whose frame runs on Codeman's own origin", async () => {
|
||||
// A trusted webview is mounted with `allow-same-origin`. The link being
|
||||
// followed came from agent output, so auto-reusing that frame would let an
|
||||
// agent-chosen path be opened inside a privileged origin on one tap. A fresh
|
||||
// sandboxed record is saved instead.
|
||||
const { win, app, calls } = boot();
|
||||
app.webviews.set('trusted-dev', {
|
||||
id: 'trusted-dev',
|
||||
name: 'trusted',
|
||||
url: 'http://localhost:5173/',
|
||||
embedMode: 'proxy',
|
||||
trusted: true,
|
||||
} as never);
|
||||
app.webviews.delete('dev');
|
||||
|
||||
await app.openUrlInWebTab('http://localhost:5173/admin');
|
||||
|
||||
const post = calls.find((c) => c.method === 'POST' && c.path === '/api/webviews');
|
||||
expect(post, 'a trusted dashboard must not be reused for a tapped link').toBeTruthy();
|
||||
expect(post?.body).toMatchObject({ url: 'http://localhost:5173/', trusted: false });
|
||||
expect(frameSrc(win, 'trusted-dev')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not reuse a direct-mode dashboard, which cannot show a loopback page from elsewhere', async () => {
|
||||
const { win, app, calls } = boot();
|
||||
expect(app.openLinkThroughWebTabIfLoopback('https://localhost:9443/admin')).toBe(true);
|
||||
await vi.waitFor(() => expect(frameSrc(win, 'new-id')).toBe('/webview/cap-new-id/admin'));
|
||||
expect(calls.find((c) => c.method === 'POST' && c.path === '/api/webviews')?.body).toMatchObject({
|
||||
url: 'https://localhost:9443/',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a reachable link alone so the caller opens it directly', () => {
|
||||
const { app, calls } = boot();
|
||||
expect(app.openLinkThroughWebTabIfLoopback('http://192.168.1.135:3000/')).toBe(false);
|
||||
expect(app.openLinkThroughWebTabIfLoopback('https://example.com/')).toBe(false);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('opens loopback links directly when the page itself is on the box', () => {
|
||||
const { app, calls } = boot('http://localhost:8095/');
|
||||
expect(app.openLinkThroughWebTabIfLoopback('http://localhost:5173/')).toBe(false);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callers consult the hook first', () => {
|
||||
it('terminal URL links try the web tab before window.open', () => {
|
||||
const activate = TERMINAL_UI.indexOf('activate(_event, text) {');
|
||||
expect(activate).toBeGreaterThan(-1);
|
||||
const body = TERMINAL_UI.slice(activate, TERMINAL_UI.indexOf('},', activate));
|
||||
expect(body.indexOf('openLinkThroughWebTabIfLoopback?.(text)')).toBeGreaterThan(-1);
|
||||
expect(body.indexOf('openLinkThroughWebTabIfLoopback?.(text)')).toBeLessThan(body.indexOf('window.open('));
|
||||
});
|
||||
|
||||
it('response viewer links route through the hook and keep the file-path handler first', () => {
|
||||
const bind = APP_JS.indexOf('_bindResponseViewerInteractions(body) {');
|
||||
const section = APP_JS.slice(bind, bind + 2500);
|
||||
const pathHandler = section.indexOf("closest('a.rv-path')");
|
||||
const urlHandler = section.indexOf("closest('a[href]')");
|
||||
expect(pathHandler).toBeGreaterThan(-1);
|
||||
expect(urlHandler).toBeGreaterThan(pathHandler);
|
||||
expect(section.indexOf('openLinkThroughWebTabIfLoopback?.(urlLink.href)')).toBeGreaterThan(urlHandler);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user