Compare commits

...
Author SHA1 Message Date
Codeman maintainer a017e9a8e0 chore: version packages 2026-09-12 06:10:14 +02:00
Codeman maintainer 65ddedd1d4 fix: act on the 1.27.0 pre-release review
A Fable 5.1 reviewer read the whole release diff against 1.26.2 and returned
SHIP WITH FIXES. These are its findings, verified before acting on each.

**The changelog advertised a feature the code refuses (major).** The #401
changeset and docs/web-tabs.md both listed `*.localhost` in the loopback set.
The follow-up in 02b0e278 moved it out of the auto-route set on security
grounds and updated CLAUDE.md but neither of those, and that changeset becomes
the 1.27.0 CHANGELOG entry: a user would have read the release notes, tapped
`http://app.localhost:3000/` on a phone and got a connection error from a
documented feature. Both corrected, and the user guide now says why it is
excluded and that adding such a dashboard by hand still works.

**Dictation delivered its text twice (minor, #388).** `keydownSnapshot` started
`null`, so `keydownSnapshot ?? canonicalCount` at the input event read a counter
xterm had ALREADY bumped: on a fresh page load with no keydown yet, xterm's own
capture listener forwards the `insertText` itself (it is not gated behind a
keydown), then the snapshot equals the bumped count, `count > snapshot` is
false, and the controller emits the same text again. Reproduced directly
against the module: it emitted `hello` for input xterm had already delivered.
A `0` baseline restores that file's own invariant, that a missed recovery is
acceptable and a duplicated keystroke is not. Two regression tests, covering
both the xterm-already-delivered and genuinely-dropped halves.

**The sorted rail's arrow-key walk followed the DOM (minor).** `_tabKeydownHandler`
steps `querySelectorAll` order, which is `sessionOrder`, while a sorted rail
paints its rows with the flex `order` property, so ArrowDown from the top card
landed wherever that session happened to sit in the tab order. It now sorts its
node list by the COMPUTED order first: computed rather than inline, because web
tabs take their `order: 9999` from CSS and would otherwise read as 0 and lead
the walk. This is the one place that follows the paint; the Alt+N badge, the
drag model and the filter all still deliberately read the DOM.

**A trusted dashboard was auto-reused by a tapped link (minor, #401).** The
reuse loop skipped `managed` and direct-mode records but not `trusted`. A
trusted frame is mounted with `allow-same-origin`, i.e. on Codeman's origin
with the user's cookie, and these links come from agent output, which is the
threat model the loopback allowlist was just narrowed for. An agent that can
write into the dev server's tree could print a path that one tap opens inside
that privileged frame. Excluded from auto-reuse, with a test; opening it from
the Run dropdown is still an explicit action and unchanged.

**Two documentation claims that were no longer true.** CLAUDE.md said
test/location-overlay-commands.test.ts pins every remote pane command, but
remote claude and remote omp now have their own arm in `buildRemoteLaunchCommand`
and never reach `defaultRemoteCommandForMode`, which is what that test asserts,
so it pins nothing for them and changing either arm will not fail it. Named the
real pins instead. Also documented the arrow-key-walk exception in the rail
paragraph.

Left as follow-ups, deliberately: `POST /api/webviews` does not dedupe by URL
server-side, so two devices tapping one link concurrently can still save two
dashboards for one origin (pre-existing endpoint behaviour that #401 makes
reachable by a tap), and the location-overlay golden should assert the real
remote claude/omp commands rather than a branch neither reaches.

Full gate green: 359 files, 6869 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 06:09:43 +02:00
Codeman maintainer 8b23f3e260 feat(rail): sort the vertical tab rail by activity, and give its rows the home screen's card
The vertical rail lists exactly the sessions both home screens list, so it now
answers their question the same way instead of showing the raw tab order.

Order: new per-device `tabRailSort` (App Settings -> Appearance -> Tabs ->
Vertical Rail Order, default "By activity"). It runs `CodemanSessionOrder` over
rows classified by `_mobileOverviewState`, i.e. literally the home screens'
comparator, `lastSubmitAt`-anchored running group included.

It is applied as the flex `order` property, never by reordering the DOM.
`#sessionTabs` stays in `sessionOrder`, which is what keeps the Alt+N badge
honest (it names a shortcut, not a row position, so it deliberately does NOT
run 1,2,3 down a sorted rail), and keeps drag-and-drop, the arrow-key walk, the
sidebar filter and `_scrollActiveTabIntoView()` all reading the list they
always read. A session changing state then moves one inline style instead of
forcing the full rebuild that would restart every card's animation on every SSE
tick. The incremental render path re-applies it, since a state flip adds no tab
and never reaches the full rebuild, and an empty string is what clears it when
sorting stops. Web tabs are pinned past the cards by a CSS `order: 9999`, since
`renderWebviewTabs()` emits the same markup for every layout and the flex
default of 0 would interleave them. Drag is switched off while sorting (the
drop rewrites `sessionOrder` correctly and the sort puts the card straight
back, so the affordance would be a lie); 'manual' is the way back.

Cards: detailed rail rows become bordered cards on `--bg-card`, with the stamps
line on its own full-width row and the pill at its right end. The state dot
goes 6px to 9px, keeps its orbiting ring while working and gains the green
halo; idle mutes toward `--text-muted` as the home rail does. Needs/error/
waiting reuse `home-sessions-blink-red`/`-yellow` rather than a second copy.

These card rules are RAIL-SCOPED and deliberately absent from the comma-grouped
selectors that carry both vertical surfaces: the rail is an occasional,
resizable list you scan, while the sidebar is a permanently-docked nav column
where 20 stacked cards read as a wall. Every state-dot rule also excludes
`.tab-alert-action`/`.tab-alert-idle` by hand, because those alert rules are
only (0,3,0) and these are (0,5,1)+.

Lines: the lineage bracket already drew in the rail, but its track sat 6px from
the left edge, so half of its 11px outer glow was clipped by the window frame
and it read as a thread pinned to the frame. It now runs at 10px, mid-channel
in the gutter the rail already reserves.

Tests: test/tab-rail-order.test.ts (17) drives the real `isTabRailSorted()` and
`_tabRailSortOrder()` out of app.js, covering the row model (a WORKING row
ranked by `lastSubmitAt`, which would otherwise rank every running turn as
freshly started and fail no rendering test), the Alt+N badge, and the opt-out.

Verified in Chromium across sorted/manual/simple/header-strip/sidebar with the
setting flipped at runtime: no page errors, and the header strip and sidebar
render byte-identically to before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 06:09:28 +02:00
Codeman maintainer 02b0e27898 fix: merge-time follow-ups for #400, #401, #362 and #388
Each item is from the pre-merge review of the PR it names, applied on master
rather than by pushing to a contributor branch.

#400 (response viewer, shenlvkang-collab)
- The brief view opened at `scrollTop = 0`, right when it was a single card
  holding the last row. Now that it renders the whole turn, the top is the
  turn's first narration line and the answer can be screens below it, while
  loadFullContext already scrolls to the bottom of the same turn. A multi-row
  turn now opens at its newest text; a single card still opens at the top.

#401 (loopback links as web tabs, shenlvkang-collab)
- Drop `*.localhost` from the auto-route set. Every other member is an address
  literal that can only mean this box; a `*.localhost` DNS name is not one, and
  a resolver with a search domain retries `evil.localhost` as
  `evil.localhost.<search domain>`. The link source is agent-written terminal
  output, so that set is the whole confinement on a tap that makes Codeman
  fetch a URL server-side and persist it. The page-side test stays broader
  (`isOnBoxHostname`), where a false positive only declines to proxy.
- A link to the origin root navigated nothing: the path was flattened to '',
  which openWebview reads as "no deep link", leaving an open frame where it was.
- `this.webviews` being set does not mean it is loaded. initWebviews() assigns a
  truthy empty map and only then awaits the list, so a tap during page load
  found nothing to reuse and POSTed a duplicate record. Join the in-flight
  refresh instead.
- One dashboard per dev server rather than per host spelling, which is what the
  method's own comment already promised.
- Toast on the auto-create: it writes webviews.json, broadcasts over SSE and
  adds a Run-dropdown row on every signed-in device, with a new tab as its only
  previous signal.

#362 (remote omp continuation, timkjr)
- Accept the allowlisted `mode === 'omp'` arm as-is; a blanket registry render
  would hand deepseek a locally-resolved --profile and bypass claude's own
  overlay. A registry-declared switch is the follow-up if a third mode needs it.
- Revert the whole-file Prettier reformat of docs/remote-sessions.md (docs/ is
  hand-formatted and outside `npm run format`), keeping only the two new
  sections.
- Correct three stale passages: architecture-invariants' `exec claude
  --dangerously-skip-permissions`, the `exec <cli>` paragraph (claude and omp
  now have their own arms, and the claude pane's PID is the login shell), and
  omp-integration's `-c 'omp'`. RemoteCommandMode gains deepseek and omp.
- Add the missing `_maybeCaptureOmpSessionId` remote-guard test; the sibling
  guard in `_pinOmpRespawnId` had one and this path runs earlier, on the first
  idle turn.

#388 (keyCode 229 recovery, aakhter)
- Gate notifyCanonicalData on shouldSuppressTerminalQueryResponse and
  isTerminalFocusOrMouseReport. onData also carries the DA/DSR/CPR/OSC replies
  xterm answers during Ink redraws and its SGR mouse and focus reports; any of
  those landing between the keydown and the candidate's resolution was read as
  "xterm spoke for this keystroke", standing the recovery down and leaving the
  character dropped, worst on a busy agent pane. Reached through
  window.CodemanTerminalInput: the predicates live in a module IIFE that closes
  long before this call site, so bare references would throw into the
  surrounding try/catch and stop the notify from ever running.

Every fix has a test that fails without it (verified by reverting each).
Full gate green on the combined tree: 358 files, 6849 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 05:15:41 +02:00
Ark0N 9d664ffe01 Merge pull request #388 from aakhter/pr/keycode-229-input-recovery
fix(terminal): recover dropped keyCode 229 input (Android/IME)
2026-09-12 05:14:39 +02:00
Ark0N a28b04c368 Merge pull request #362 from timkjr/feat/omp-remote-continuation
fix(omp,remote): thread remote-omp resume/continue through respawn and reattach
2026-09-12 05:14:24 +02:00
Ark0N e35b68e253 Merge pull request #401 from shenlvkang-collab/pr/loopback-links-webtab
feat(webview): open localhost links through a proxied web tab from another device
2026-09-12 05:14:09 +02:00
Ark0N 77d9ad59f7 Merge pull request #400 from shenlvkang-collab/pr/claude-viewer-last-turn
fix(web): show the whole last turn in the Claude response viewer's brief view
2026-09-12 05:13:55 +02:00
shenlvkang-collabandClaude Fable 5.1 d9eeb039db feat(webview): open localhost links through a proxied web tab from another device
An agent prints `http://localhost:5173/` (a dev server, a preview it just
served) and the user taps it on a phone. That address only exists on the
Codeman box, so the link was a guaranteed connection error from any other
device — while the web-tab proxy fetches from the server, where it works.

A loopback link (`localhost`, `*.localhost`, 127/8, 0.0.0.0, ::1) activated
in the terminal or clicked in the Response Viewer now 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, with the link's own path,
query and fragment opened inside it (a mounted frame is navigated, not torn
down, so its state survives); otherwise one is saved under its host:port,
sandboxed like any other web tab, so it is in the Run dropdown next time.

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, so those keep opening in a new browser tab; on the
box itself every link opens directly. The terminal link provider and the
viewer's click handler consult one hook and fall through to their existing
behaviour when it declines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou
2026-09-10 12:48:47 +08:00
shenlvkang-collabandClaude Fable 5.1 bd61735393 fix(web): show the whole last turn in the Claude response viewer's brief view
The eye button rendered `data.text`, which is one row: the last assistant
row of the transcript. A Claude answer is a median of 3 model messages
(p90 11) split around tool calls, so the brief view usually showed the tail
of an answer ("Done.", "Let me look.") and the substance appeared only after
More. The full view was fine, which is why the brief one read as broken by
comparison.

The brief view now asks `?context=turn`. The reader answers with the
assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn`: the
highest `turn` that has an assistant row, so a prompt queued after the
answer does not blank the view) and the frontend renders them exactly as
the full view renders that turn: one badge, then continuation segments,
gated on the numeric `turn` as before.

`data.text` is unchanged in every context — still the last assistant row,
never `messages.at(-1)` — because agent pollers hash it. Readers that emit
no turns (Codex, the pane parser, DeepSeek, an older server) return `text`
only for `context=turn`, and the brief view keeps its single card for them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou
2026-09-10 12:45:16 +08:00
Codeman maintainer 5b667264b4 chore: version packages 2026-09-10 03:20:58 +02:00
Ark0N e3d5fd90cd Merge pull request #392 from JDProfresh/fix/ios-safari-toolbar-gap
fix(mobile): lift the iOS Safari toolbar by the measured chrome overlap
2026-09-10 03:10:12 +02:00
Ark0N 713f632a64 Merge pull request #397 from irisitymichaelgrundberg/fix/detached-session-owns-its-pane-size
fix(terminal): let a detached session's own window own its pane size
2026-09-10 02:58:54 +02:00
Ark0N 92b5dfacb0 Merge pull request #396 from irisitymichaelgrundberg/fix/terminal-font-settle-before-fit
fix(terminal): fit the terminal only once the terminal font is measurable
2026-09-10 02:58:48 +02:00
Ark0N 890a1b0902 Merge pull request #395 from irisitymichaelgrundberg/fix/full-history-replay-row-alignment
fix(terminal): keep row alignment in the full-history pane replay
2026-09-10 02:58:42 +02:00
Ark0N a360763890 Merge pull request #394 from irisitymichaelgrundberg/fix/ctrl-v-pastes-twice
fix(paste): handle only the first paste event the Ctrl+V trap receives
2026-09-10 02:58:36 +02:00
Codeman maintainer 57899f879e feat(ui): add a Blur entrance animation on all four surfaces
An iOS-style focus pull: the thing arrives out of focus and the blur fades
off it as the opacity comes up. Opacity leads the blur (full opacity around
45%, blur still lifting), which is what separates it from a cross-fade.
Ships on tabs (440ms), agent windows (560ms), the terminal pane (520ms) and
connection lines (380ms), plus a `Soft focus` theme that sets all four.
Default stays `legacy`, so an untouched install is unchanged.

The terminal pane is the one surface that cannot blur itself the documented
way, and `blur` takes a deliberate exception to the "never a filter on
.terminal-container" rule. Every alternative was measured against a live
xterm and does not work: a backdrop-filter veil on ::before blurs perfectly
while STATIC, and Chrome silently drops the backdrop the moment ANY
animation runs on that pseudo-element (the veil computes blur(15.3px) while
the text behind it stays razor sharp); driving the radius from rAF buys the
same full-screen blur per frame plus main-thread work. The cost the rule
exists to avoid is inherent to blurring a terminal, so the style buys it
knowingly: opt-in, off by default, one ~520ms run per session open, class
straight back off, will-change still unset. Worst-case price, headless
SwiftShader with no GPU: frame deltas 16.7ms -> 33.3ms for the run, against
16.7ms flat for `fade`. cols x rows measured unchanged at 178x38 before,
during and after, so FitAddon never sees it.

The line entrance animates `filter` too, where each line already carried
its glow. Both kinds now hold it in --line-glow and both keyframes say
`blur(N) var(--line-glow)`, so the function lists match and interpolate
instead of the glow vanishing for the run and popping back (a lineage
line's glow is a different colour, set per element). Its 100% frame omits
`opacity` on purpose so the endpoint comes from the element's own resting
value: 0.9 subagent, 0.72 lineage, 0.95 working.

test/entrance-animations.test.ts is a new static guard over the whole
feature, not just this style: the rule -> keyframes -> theme-option chain a
style silently does nothing without, the terminal's paint-only property
allowlist (the FitAddon rule), the --line-glow contract, and reduced-motion
coverage. Mutation-checked both ways.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 02:57:40 +02:00
Codeman maintainer d4fe3afc9d feat(files): raise the download cap to 2GB and stream /api/download
The 50MB cap on file-raw, the attachment /raw route and /api/download was
memory protection for a `readFile()` that no longer exists: file-raw and
/raw were rewritten to stream through `sendFileBody()` and answer Range
requests, so size costs a read stream rather than RSS (measured: a 600MB
download moved peak RSS by ~37MB). All the cap still did was refuse
legitimate downloads of build artifacts, videos and archives.

It is now MAX_FILE_DOWNLOAD_BYTES in config/buffer-limits.ts, default 2GB,
env CODEMAN_MAX_DOWNLOAD_BYTES, 0 = unlimited. `parseByteLimitEnv()` is
separate from the `parseInt(...) || default` idiom used elsewhere in that
file precisely because that idiom reads 0 as falsy and would silently
restore the default for the one value that means "no limit".

/api/download was the last route that really did buffer the whole file. It
now shares sendFileBody() with the other two, so it streams, advertises
Accept-Ranges, and is resumable. Its Content-Disposition also goes through
buildContentDisposition() rather than raw interpolation.

Refusals move from 400 to 413 across all three, which is the correct status
for the case; with the cap at 2GB it is a path almost nothing reaches now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 02:57:07 +02:00
Michael Grundberg 77fcd65b4a fix(terminal): force the re-measure, bound the wait, and test both
Review of the previous commit found that waiting for the font does not, on its
own, do anything.

`FitAddon.proposeDimensions()` measures nothing — it divides the container by a
CACHED cell size, and xterm refreshes that cache only from `open()`, from a
resize that actually changed the grid, and on a device-pixel-ratio change.
Nothing in it listens for font loading. So a fit that runs after the font
arrives can still divide by the fallback cell, propose the grid it already has,
and short-circuit before anything re-measures. The wait now ends by calling
`_charSizeService.measure()` itself, which is the step that makes the following
fit see the real font. Private API, as FitAddon's own dependency on `_core` is,
and guarded because a terminal can be disposed mid-wait.

The wait was also unbounded, and it sat behind the buffer-load gate. Neither
`FontFaceSet.load()` nor `FontFaceSet.ready` has a deadline, so a font request
that never settled left the tab spinning with live output queued behind it —
permanently, and on every session, since they share one promise. The comment
claimed the opposite ("a font that never loads must not block the terminal, so
this always resolves"), which was true of the per-face loads and false of
`ready`. It is now raced against TERMINAL_FONT_WAIT_MS, and the await moved
ahead of `_beginBufferLoad` so a slow font cannot hold output back at all —
which also removes the stale-select interaction with `_restoringFlushedState`,
since that flag is not yet set when the wait runs.

The awaited set no longer includes faces that cannot move the measured cell.
The bundled symbols font is ~1.2MB of private-use-area glyphs and xterm
measures `W`, so awaiting it put a megabyte in front of the first frame for
nothing; the generic families match no FontFace at all.

A runtime font change had the same race the boot-time one did:
applyTerminalFontFamily wrote the new family and fit on the next line, against
a family the browser might not have loaded. It now re-arms the wait and fits
again when it settles.

The claim that this could not be tested was wrong: the repo's vm harness
reaches both halves. The new suite pins the family filter, the forced
re-measure, the deadline, a rejecting load, a browser with no font API, and a
terminal disposed mid-wait — plus the four ordering properties in
selectSession, including that iOS Safari's synchronous focus still precedes the
first await. Each assertion was checked by reverting its fix.

Also corrects the docstring's reason for calling `document.fonts.load` (the
stylesheet is render-blocking and long parsed by then; the real reason is that
the WebGL renderer rasterises through a canvas atlas, and canvas text never
triggers a CSS font fetch), restores the JSDoc block the previous commit
displaced from getTerminalDimensions, and fixes a comment that described the
first fit as already having run when the mobile-Safari branch defers it.
2026-09-09 16:57:42 +02:00
Michael Grundberg 2b57c595df fix(terminal): gate the row-preserving skips on a capture, not the query flag
Review of the previous commit found the guard inverted: the three skips keyed
on `?full=1`, which is only what the client asked for. When the capture comes
back null — ENOBUFS, a timeout, a vanished pane, or a session with no mux at
all — the reply falls back to the byte history, which IS a stream of
successive frames and still needs stripping. Gating on the request returned it
whole: measured at 82KB against 4KB for the same buffer without `full=1`. A
direct-PTY session takes that path on every first selection, not only during
an outage. The skips now key on `isFullCapture`, meaning a capture arrived.

Three further defects the same review surfaced, all on this path:

Keeping the trailing rows is only sound when a cursor move follows to count
back up from them. On the two branches where the cursor query fails there is
no move, so the caret was left at the bottom of the pane — worse than before.
The cursor is now read first and settles both decisions together.

The move is relative rather than absolute. `CUP` numbers rows from the top of
the browser's screen, so it is only right while the browser's row count equals
the pane's, and `resizeWindow` does not wait for tmux, so a capture can be
taken before a requested resize applies. Measured against real tmux with a
browser four rows shorter than the pane: the absolute move lands on a blank
row, the relative one lands on the caret's row.

An all-blank pane no longer reads as content. Retaining trailing rows and
appending a move made it non-empty, and the caller treats non-empty as "replay
this", so a blank screen would have replaced real history — the downgrade
`_replayWouldShrinkBuffer` refuses, arriving from the server side where that
guard cannot see it.

The documentation claimed one line per screen row. `-J` joins a hard-wrapped
row into its logical line, so that is false whenever any row wrapped: measured
at 10 lines for a 12-row pane. Both entries now say what actually holds, and
the stale "NOT repositioned" contract in the mux interface is updated too.

Tests: the byte-history fallback is stripped, an empty capture leaves history
intact, and the extracted helpers are unit-tested directly rather than through
source-text matching. The slice window in the capture test is bounded at the
next method, having overrun into its neighbours.
2026-09-09 15:35:02 +02:00
Michael Grundberg 070e8da81b fix(terminal): yield only the resize send, and take sizing back on redock
Review of the previous commit found four defects in it.

The guard sat above the local fit, so it suppressed a reflow as well as the
server write. tab-rail-resize performs its single settle-time refit through
sendResize and has no fallback for a truthy activeSessionId, so dragging the
rail stopped reflowing a detached session's terminal in the dashboard. The
mobile-keyboard guard fourteen lines below already draws the line correctly —
withhold the send, never the reflow — and the guard now sits after the fit.

_lastResizeDims is one value for the whole window, and both guards skip
updating it, so while a popup owns a session that value no longer describes
the PTY. _redock repaired it only for the active session. Pop out A, switch to
B, close the popup: selecting A later found unchanged dimensions, returned
"unchanged", and selectSession skipped its 400ms redraw wait — while the
server, comparing against the real pane, did resize and did raise SIGWINCH, so
the fetch painted the pre-redraw frame. _redock now clears the record on every
path, active or not.

_redock could also fire a resize for a session already gone: _onSessionDeleted
redocks before cleanup, so the id can be dead and the request is a guaranteed
404. It now checks the session still exists.

restoreTerminalSize — the header's redraw button and Ctrl+Shift+R — silently
did nothing for a detached session while still reporting success with
dimensions nothing was set to. It now says the session is sized by its own
window, where the same button works.

The `force` comment claimed a client-side dedupe that does not exist; the
deduplication is server-side against the real pane. Corrected to say what the
flag actually buys. The _redock doc comment now records that the function
writes to the server and is not idempotent.

Tests: _redock was the untested half and is the half three of these defects
sit in. It now has coverage for clearing the stale record on both the active
and inactive paths, re-asserting only for the session being shown, and staying
silent for a deleted session. The existing sendResize test now asserts the
local fit still runs.
2026-09-09 15:24:55 +02:00
Michael Grundberg 0e82443222 fix(terminal): fit the terminal only once the terminal font is measurable
Opening a session could render its frame with characters spliced into each
other, as though two frames were overlaid — a status-line fragment landing
in the middle of a file path, for instance. Resizing the browser window
cleared it.

The first fit runs while the browser is still painting with a fallback font.
A cell measured against that font has a different width and height from one
measured against the terminal font, so the fit produces the wrong column and
row count. Codeman sizes the pane to it and replays the capture. When the
font finishes loading the measurement changes, the pane is resized a second
time, and the CLI repaints for a shape that does not match the frame already
on screen. Its later partial updates then land on the wrong rows.

selectSession now waits for the font before it measures, so the pane is
sized once, at the size that sticks, and the capture is taken at that size.
The wait always resolves, so a font that never loads cannot block a
terminal, and it resolves immediately once the font is in, so a tab switch
pays nothing after the first load.

document.fonts.ready alone is not enough: it can resolve before the
stylesheet declaring @font-face has been parsed. document.fonts.load for
each family in the stack is what actually requests the faces.

Measured on a session opening at 2328px wide: the cell went from 8.43x16.00
to 8.00x21.00 roughly 900ms in, moving the grid from 112x36 to 118x28 after
the replay had already been painted.
2026-09-09 14:20:34 +02:00
Michael Grundberg 323730a29d fix(terminal): keep row alignment in the full-history pane replay
Switching to a session left the caret one row below the composer's input
line, on the box border, and every cursor-relative update the CLI sent
afterwards was measured from the wrong row. Any fresh output repaired it,
because the CLI then repainted the whole frame.

Two things were wrong with the full-history replay, and they compound.

The capture never restored the cursor. The visible-frame path ends with an
absolute cursor move back to the pane's position; the linear path returned
its text and left the caret wherever the last character landed, which for an
agent CLI is the bottom-most row carrying text — the status line.

The rows it addressed did not line up with the pane's rows either. Four
transforms ran over the capture and each can delete a line: the trailing
blank rows were stripped, redraw-bloat stripping ran, the trim that cuts
everything above the Claude banner ran, and leading whitespace was removed.
All four are right for a byte stream of successive frames. A capture is the
rendered pane, one line per screen row, so each deletion shifted the frame
out from under the restored cursor.

The full-history path now appends the pane's own cursor position and keeps
every row, so row N of the reply is row N of the pane. The visible-frame and
tail paths are untouched.

Restoring the cursor is what makes row alignment load-bearing here, and
neither CLAUDE.md nor the architecture invariants said so — which is how
four line-deleting transforms accumulated on the path. Both now record it.

Verified against a live 315x59 pane: the reply carries 59 rows, its row 55
is the composer's input line matching tmux, and it ends with the cursor move
that lands there.
2026-09-09 14:20:34 +02:00
Michael Grundberg 5ac516dd3b fix(terminal): let a detached session's own window own its pane size
Popping a session out left both windows sizing the same pane. The dashboard
keeps the session active and keeps measuring it, and its terminal is
narrower than the popup because the session rail takes width the popup does
not have. One PTY cannot hold two sizes, so the CLI drew frames that fit
neither window and the popup showed a garbled frame.

sendResize and the debounced window-resize handler now stand aside for a
session this window has marked detached. A solo window is exempt, since it
is the owner. _maybeRefetchFullHistory already stood aside on exactly this
condition, so the rule is not a new one.

Sizing has to come back when the popup closes: while it owned the session
the dashboard sent no resizes, so the PTY still holds the popup's geometry.
_redock now re-asserts, with force, because the dimensions the dashboard
last sent are the ones it is about to send again.

Reproduced with a dashboard and a popup on one session: before, the pane
sat at 315 columns while the popup rendered 289. After, both report the
same size and the popup's frame matches the pane exactly.
2026-09-09 14:20:34 +02:00
Codeman maintainer 5130ca6633 fix(pr-bot): fail fast when the review model's budget is spent
Claude Code answers an exhausted model budget INSIDE the turn ("You've
reached your Fable limit. Run /usage-credits to continue or switch models
with /model.") and then sits there with nothing to write. The reviewer never
produces a report, so `runTurn` waited out its full 40-minute deadline and
reported a bare "timed out after 40 min without a report", which reads as a
hung reviewer rather than an account that needs attention.

Measured on 2026-09-08: #388, #393, #394 and #377 each lost 40 minutes this
way, and because every attempt counted, all four reached MAX_AUTO_RETRIES and
would NOT have been picked up again once the budget returned. One spent
afternoon quietly took the whole queue out of service.

`findModelLimitNotice()` reads the notice off the pane and `runTurn` returns
a new `limit` outcome instead of waiting. It is consulted in exactly two
places, both of which mean "the turn produced nothing": on a stop where
`isDone()` is still false, and on each timed-out wait slice. A review that
merely discusses usage limits in its own findings therefore cannot be
mistaken for one that hit the wall, and the pattern matches neither the model
name nor a straight apostrophe, since the pane renders a typographic one and
every model prints the same sentence.

A spent budget is an account condition, not a bad PR, so it no longer spends
the per-head retry budget: the queue resumes by itself when the budget does.
Telegram now names the cause and the file to change.

Tests use the pane captured verbatim off the run that lost the 40 minutes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 19:27:43 +02:00
Michael GrundbergandClaude Opus 5 b87bc6871b fix(paste): handle only the first paste event the Ctrl+V trap receives
Ctrl+V in the terminal inserted the clipboard text twice. Right-click →
Paste inserted it once.

`_handleImagePaste()` appends a hidden contenteditable div, focuses it, and
reads the clipboard out of the paste event that lands there. Two separate
routes deliver that event for a single keypress. The function issues
`document.execCommand('paste')` itself, which in Firefox dispatches a
trusted paste event and then returns false, because the trap cancels the
event and the command never completes; Chromium and WebKit refuse that
command and dispatch nothing. The keydown's own default action delivers the
other, because xterm calls the custom key handler before its own `cancel()`,
so returning false never calls preventDefault. Firefox therefore ran the
trap's listener twice and both runs reached `terminal.paste()`. The
context-menu paste involves no keydown at all, which is why that path stayed
correct.

The trap now accepts the first paste event and cancels every later one, so
how many paste events a browser delivers no longer changes what the PTY
sees. Measured on a live install, one Ctrl+V each: Firefox two events and
two writes before this change, Chromium and WebKit one and one, and every
engine one write after it.

The `execCommand('paste')` call stays. Stripping it out also ends the
doubling, and all three engines still deliver one event without it, since
`trap.focus()` has already run when the key's default action resolves. It is
kept because the trap technique arrived in #84 for plain HTTP and for
mobile, and a desktop measurement says nothing about real iOS Safari or
Android Chrome: where a browser aims the default action at the element
focused when the keydown began, the command is the only route into the trap,
and the trap is the only place clipboard image blobs are read.

test/image-paste-trap.test.ts loads image-input.js into a `node:vm` context
with a fake document and fires two paste events at the trap. It covers text
and images, and fails on the old code with the text pasted twice and the
image uploaded twice.

Docs: the invariant goes into docs/architecture-invariants.md as a Terminal
paste section and into CLAUDE.md as a Frontend entry, both recording the
measured event counts and why the redundant call is still there. README.md
and the Keyboard Shortcuts and Input and Voice wiki pages gain a Ctrl+V row,
which all three tables were missing while listing every other clipboard
binding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 16:49:50 +02:00
JD c367b12f77 fix(mobile): lift the iOS Safari toolbar by the measured chrome overlap, not 100vh minus the visual height
The phone block lifted the toolbar (and padded .main) by (100vh - --app-height) on iOS Safari to clear a bottom bar that position: fixed elements were assumed to sit behind. On iPhone Safari fixed elements already stop above the bar, and 100vh is the large viewport with the bar collapsed while --app-height is the visual viewport with it expanded, so the expression measures the bar's collapsible height and shows up as an empty band between the toolbar and the bar whenever the bar is expanded. The terminal was padded by the same amount.

The lift is now --chrome-overlap, set in updateAppHeight() as innerHeight minus the visual viewport height: the distance the layout viewport that anchors fixed elements extends past the visible area. That is 0 on iPhone Safari, so the toolbar meets the bar, and it is the overlap itself on any browser where fixed elements really do land behind the chrome, so those keep the lift. The keyboard-visible rules, which already override the toolbar offset, are unchanged.
2026-09-08 01:39:07 -04:00
timkjrandClaude Sonnet 5 797f0d387c fix(remote): address review feedback on omp/claude respawn continuity
- Remote omp command now renders through buildSpawnCommandFromRegistry
  (the mode-agnostic engine local/docker spawns use) instead of the
  buildOmpCommand() the CLI-registry refactor deleted.
- Session._pinOmpRespawnId()/_maybeCaptureOmpSessionId() now skip
  host-local ~/.omp resolution entirely for a remote session and fall
  back to --continue: that resolver only ever reads THIS host's
  filesystem, which is meaningless (and could wrongly alias an
  unrelated local conversation) for a conversation that lives on the
  remote host.
- Remote-claude launch now honors an explicit resumeSessionId distinct
  from sessionId (mirrors claudeDockerPaneCommand's shape), and
  validates sessionId the same way that sibling does before
  interpolating it into the remote shell command.
- Add the still-missing header-cwd half of the trailing-slash test,
  and document respawn/reattach continuation + auto-reconnect-vs-
  clean-exit in docs/remote-sessions.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 22:11:54 -05:00
timkjr 88243e9ffa fix(remote): never auto-revive a remote session after a clean agent exit
The COD-108 reconnect watcher treated any dead local pane as a dropped
transport and re-ran the pane command — so a normal ctrl-c/ctrl-d on a
remote omp/opencode/claude auto-spawned a FRESH agent (claude only
looked correct because its '--session-id || --resume' fallback resumed,
with a loud 'already in use' error first).

Distinguish a transport drop from an intentional exit: only reconnect
when the durable remote tmux session (codeman-ssh-*) is verifiably
still alive on the remote host. A clean exit tears that session down;
the watcher now probes it via ssh has-session and skips (remote-gone)
when it is gone OR unknown (fail closed). The probe is cached
per-session and fired async so the 5s tick never blocks on ssh.

Also thread ompConfig/resumeSessionId into the remote builders so a
dead-pane respawn of an omp session resumes (--resume <id>) or
continues (--continue) instead of launching bare omp.

Tests: 3 new cases pinning remote-gone / unknown / alive decisions;
remote omp resume + --continue fallback. Verified live: all three
remote CLIs stay dead after exit.
2026-09-07 21:30:31 -05:00
timkjr 0a5bc1ac2e fix(omp,remote): pin remote conversations on respawn so ctrl-d/ctrl-c resumes instead of relaunching fresh
Two independent defects made ANY clean exit from a remote SSH session (user
ctrl-d or ctrl-c, or a dropped pane) relaunch the agent as a NEW conversation:

1. SSH-remote claude was launched as a bare `claude --dangerously-skip-permissions`,
   so the remote-respawn path (COD-108 reattachRemote re-running the idempotent
   launch command) started a fresh conversation every time. Pin it to the
   deterministic Codeman session id, mirroring the docker-claude shape
   (claudeDockerPaneCommand): `--session-id <id>` to create, with the
   `|| --resume <id>` fallback so the idempotent re-run resumes instead of
   erroring with "already in use". A per-host commands.claude override still wins.

2. OMP --resume pinning silently degraded to ambiguous `--continue` whenever a
   case path ended in a trailing slash (e.g. remote `remotePath` stored verbatim
   as `/home/user/dotfiles/`): mangleOmpWorkingDir produced `-dotfiles-` while
   omp persists sessions under `-dotfiles`, readdirSync returned null for an
   existing dir, and findLatestOmpSessionId/resolveAndClaimOmpSessionId never
   matched. Normalize the trailing slash before mangling (new exported
   stripTrailingSlash) and compare the session header cwd against the same
   normalized value.

Both were found live 2026-08-29 on a remote OMP/Claude node: ctrl-c and ctrl-d
behaved identically, both relaunching a fresh session.
2026-09-07 21:20:41 -05:00
Aamer Akhter e8a93ada1f fix(terminal): forward the orphaned input event instead of replaying a guessed key
The previous shape guessed the character from `event.key` on keydown, re-emitted
it, and then tried to suppress a late canonical copy with a 250 ms
character-keyed dedupe. Review found three defects in that, all reproducible:
the dedupe matched on the character alone with nothing scoping a candidate to
the keydown that created it, so the same character typed twice inside the window
had its second, real byte swallowed; anything whose committed text differed from
`event.key` (Enter, IME punctuation) was delivered twice, because the dedupe
could never match it; and the trigger ignored `key === 'Unidentified'`, which is
what a soft keyboard reports, so it may never have fired where it was needed.

The input event already carries the committed text in `ev.data` — exactly what
xterm itself would have forwarded — so nothing has to be guessed. The controller
now only decides WHETHER to forward, by asking whether xterm produced canonical
data since the keydown that began the keystroke. No character-keyed matching
survives, so the first two defects are structurally impossible rather than
defended against, and nothing reads `key`/`keyCode`, so the third cannot recur.

Three details are load-bearing and each has a test that fails without it:

- The "did xterm speak?" snapshot is taken at KEYDOWN, not at the input event.
  `_keyPress` emits and sets `_keyPressHandled` before `input` fires, so a
  snapshot read at input time already contains that emission, reads it as
  silence, and delivers the character twice.
- Our `input` listener is registered with `capture: true`. The target is visited
  twice in the event path, so a capture listener calling `stopPropagation()`
  stops later BUBBLE listeners on that same target; xterm's `cancel()` runs
  exactly in the branch where it handled the input, so on bubble we would never
  observe handled events, and whether we observed them at all would hang off
  `options.cancelEvents`. Measured in jsdom and headless chromium; the table is
  in the module header.
- Enter is deliberately no longer special-cased. That mapping is what made the
  committed text differ from the re-emitted value in the first place.

The scope is also narrower than the old name suggests, and the browser test now
proves it rather than assuming it. For a keydown that reports keyCode 229 xterm
ALREADY self-rescues, via `CompositionHelper._handleAnyTextareaChanges()`
diffing the helper textarea on a 0 ms timer. A test asserting "we recovered it"
there passes while xterm does all the work, so the browser tests assert WHO
delivered the byte: zero canonical emissions for the genuinely orphaned case,
exactly one delivery for the case xterm rescues itself.

Also addresses review notes: the module gains an `@fileoverview` with
`@dependency`/`@loadorder` and an entry in the load-order list and module
inventory, and the wiring test moves out of the Ctrl+C smart-copy file into its
own. The keydown hook deliberately still runs for every key event rather than
moving behind the 229 gate: gating it would reinstate exactly the blindness
described above, and it is now a single counter assignment.
2026-09-07 19:11:20 -04:00
Aamer Akhter 82b090c74a fix(terminal): recover dropped keyCode 229 input
Android/GBoard-style keyboards fire keydown with keyCode 229 and, on some
paths, never mutate xterm's helper textarea. xterm has nothing to diff, so
it emits no data and the typed character is silently dropped: it never
reaches the PTY and never appears on screen.

terminal-keycode229-recovery.js is a standalone controller that re-emits
exactly those keys, and only once. xterm stays authoritative throughout:

- Only an explicit keyCode 229 keydown carrying a single printable key (or
  Enter) is eligible; Process/Unidentified/Dead, modifiers, AltGraph and a
  live composition are all left alone.
- The re-emit is scheduled from a microtask and then a zero-delay timer, so
  xterm's own textarea diff always gets the first opportunity; canonical
  data for the same key cancels the pending fallback.
- compositionstart and blur drop every pending candidate, so a real IME
  composition lifecycle is never second-guessed.
- After a recovery, one late canonical value attributed to that key token
  (via beforeinput/input on the helper textarea) is suppressed so the
  character cannot be delivered twice; the record expires after 250ms and
  an unattributed byte is never suppressed.

terminal-ui.js wires it at the two existing choke points — the custom key
handler and the onData registration, the latter now a named handler so the
recovery path can re-enter it — with both hooks wrapped so a failure in the
fallback can never break canonical input.

Unit coverage drives the module directly in a vm; the wiring itself is
covered end-to-end in the (browser-only) terminal-copy-shortcut suite.
2026-09-07 12:27:36 -04:00
61 changed files with 4364 additions and 180 deletions
+54
View File
@@ -1,5 +1,59 @@
# aicodeman
## 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
- Terminal rendering fixes, a Ctrl+V paste fix, an iOS Safari toolbar fix, a 2GB download cap, and a Blur entrance animation.
### Terminal rendering
Three independent causes behind #398, where opening a session rendered a frame with characters spliced into each other and left the caret on the composer's border instead of its input line, until the CLI next wrote anything:
- **The full-history replay now keeps row alignment** (#395). The linear capture path never restored the cursor, so every cursor-relative update the CLI sent afterwards was measured from the status line instead of the pane's real position, and four transforms that each can delete a line (trailing-blank stripping, redraw-bloat stripping, the pre-banner trim, leading-whitespace removal) shifted the frame out from under it. The full-history path now appends the pane's own cursor position and keeps every row, so row N of the reply is row N of the pane. The visible-frame and tail paths are untouched.
- **The first fit waits for the terminal font** (#396). A cell measured against a fallback font gives the wrong column and row count, so the pane was sized twice and the CLI repainted for a shape that no longer matched the frame on screen. `selectSession` now holds for the font before measuring, bounded at 2s so a font that never arrives cannot strand a session, and it ends by re-measuring explicitly — `FitAddon.proposeDimensions()` divides by a cached cell size and nothing in it listens for font loading, so waiting alone would still divide by the fallback cell.
- **A detached session's own window owns its pane size** (#397). Popping a session out left both windows sizing one PTY, and the dashboard's terminal is narrower than the popup because the session rail takes width the popup does not have, so the CLI drew frames that fit neither. The dashboard now withholds the resize send (never the local reflow) for a session showing in its own window, and takes sizing back on redock.
### Other fixes
- **Ctrl+V no longer pastes twice** (#394). One keypress delivered two paste events to the clipboard trap: Firefox dispatches a trusted event for `document.execCommand('paste')` and then returns `false`, and the key's own default action fires another, because xterm's custom key handler returns false without cancelling the keydown. Right-click → Paste has no keydown, which is why only the keyboard duplicated. The trap now consumes exactly one event per keypress.
- **iOS Safari: the phone toolbar sits on Safari's bottom bar** (#391, #392). The toolbar was lifted by `100vh - --app-height`, which on iPhone Safari measures the bar's collapsible height rather than an overlap — fixed elements there already stop above the bar — leaving an empty ~40px band and padding the terminal by the same amount. The lift is now `--chrome-overlap` (`innerHeight` minus the visual viewport height), which is 0 on iPhone Safari and equals the real overlap anywhere fixed elements do land behind the chrome.
### Downloads
`file-raw`, the attachment `/raw` route and `GET /api/download` now cap at **2GB** instead of 50MB, configurable via `CODEMAN_MAX_DOWNLOAD_BYTES` (`0` = unlimited). The old cap was memory protection for a `readFile()` that no longer exists: those bodies stream and answer `Range` requests, so size costs a read stream rather than RSS (measured: a 600MB download moved peak RSS by ~37MB), and all the cap still did was refuse legitimate downloads of build artifacts, videos and archives. `/api/download` was the last route that really did buffer the whole file; it now streams, advertises `Accept-Ranges` and is resumable. Refusals move from `400` to `413`, the correct status for the case.
### Blur entrance animation
A new opt-in `Blur` style on all four entrance surfaces (tabs, agent windows, the terminal pane, connection lines), plus a `Soft focus` theme that sets all four: an iOS-style focus pull where the thing arrives out of focus and the blur fades off it as the opacity comes up. App Settings → Appearance → Entrance Animations, or mix per surface at `?animlab=1`. Entrance animations stay off by default, so an untouched install is unchanged.
### Maintainer tooling
The PR bot now fails fast when the review model's budget is spent, instead of hanging a review for the full 40-minute timeout and burning its retry cap.
### Thanks
- @irisitymichaelgrundberg for #394, #395, #396 and #397, and for the #398 investigation that separated three causes behind one symptom
- @JDProfresh for reporting #391 and fixing it in #392
## 1.26.1
### Patch Changes
+11 -9
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -691,6 +691,7 @@ The web UI remains the primary surface; see **[docs/tui.md](docs/tui.md)** for t
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
| `Ctrl/Cmd+C` | Copy selection, or interrupt when nothing is selected |
| `Ctrl+Shift+C` | Copy selection (never interrupts) |
| `Ctrl/Cmd+V` | Paste, or upload a clipboard image and paste its path |
| `Ctrl/Cmd+L` | Clear terminal |
| `Ctrl+Shift+R` | Restore terminal size |
| `Ctrl+Shift+V` | Toggle voice input |
+1
View File
@@ -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
];
File diff suppressed because one or more lines are too long
+8
View File
@@ -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
+1 -1
View File
@@ -90,7 +90,7 @@ has to be copied; set them here to use a different bot.
| `PR_BOT_POLL_INTERVAL` | `600` | Seconds between GitHub polls (minimum 60). |
| `PR_BOT_MAIN_CHECKOUT` | the repo this script is in | The repository the clones share objects with and fetch from. |
| `PR_BOT_DATA_DIR` | `~/.codeman/pr-bot` | State, briefs, reports, clones. |
| `PR_BOT_MODEL` | unset (the session default) | Codeman `modelOverride` for the review sessions, e.g. `claude-fable-5-1`. |
| `PR_BOT_MODEL` | unset (the session default) | Codeman `modelOverride` for the review sessions, e.g. `opus[1m]`. ⚠️ Pick a model whose budget can absorb a re-review of every open PR on every head commit: when it runs out, Claude Code answers the limit **inside the turn** and the reviewer has nothing to write. The bot now names that failure in seconds (`findModelLimitNotice`) instead of burning the whole `PR_BOT_REVIEW_TIMEOUT`, and a limit does not spend the per-head retry budget, so the queue resumes by itself once the budget does. |
| `PR_BOT_EFFORT` | unset | Codeman `effort` for the review sessions. |
| `PR_BOT_REVIEW_TIMEOUT` | `40` | Minutes before a review is abandoned. |
| `PR_BOT_FOLLOWUP_TIMEOUT` | `20` | Minutes before a follow-up is abandoned. |
+50 -1
View File
@@ -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`:
+3 -3
View File
@@ -312,8 +312,8 @@ TOCTOU window.
| Route | Cap | Notes |
|-------|-----|-------|
| `file-content` | 10 MB | text preview |
| `file-raw` | 50 MB | inline MIME map; **`X-Content-Type-Options: nosniff` on all responses**; streamed, `Range`-aware (206 slices come from the same validated path, and the cap is checked before the range) |
| `POST /api/download` | 50 MB | forced `attachment`; sensitive‑path blocklist |
| `file-raw` | 2 GB (`CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) | inline MIME map; **`X-Content-Type-Options: nosniff` on all responses**; streamed, `Range`-aware (206 slices come from the same validated path, and the cap is checked before the range) |
| `GET /api/download` | same cap | forced `attachment`; sensitive‑path blocklist; streamed, `Range`-aware |
### SVG / content‑type XSS
@@ -340,7 +340,7 @@ the attachment guard below.
Live external attachments (`src/attachment-registry.ts`) mint an `att_<uuid>` id
for a host file so browser requests carry the id, never an absolute path. Serving
is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, 50 MB cap,
is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, same download cap,
`nosniff`) and re‑resolves the symlink + re‑checks the **attachment guard**
(`src/config/attachment-guard.ts`: the shared sensitive‑path blocklist **plus**
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
+31
View File
@@ -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
+1
View File
@@ -14,6 +14,7 @@ works, slash commands included.
| `Shift+Enter` / `Ctrl+Enter` | Newline without sending. |
| `Ctrl+C` | Copy if text is selected, otherwise interrupt. |
| `Ctrl+Shift+C` | Copy, never interrupts. |
| `Ctrl+V` | Paste. A clipboard image uploads instead. |
| `Ctrl+L` | Clear the terminal. |
### Exactly-once delivery
+1
View File
@@ -25,6 +25,7 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
| `Ctrl+Enter` | Same. |
| `Ctrl+C` | Copy the selection, or interrupt when nothing is selected. |
| `Ctrl+Shift+C` | Copy the selection. Never interrupts. |
| `Ctrl+V` | Paste. An image on the clipboard uploads and pastes its file path instead. |
| `Ctrl+L` | Clear the terminal. |
| `Ctrl+Shift+R` | Restore terminal size. |
| `Ctrl` `+` / `Ctrl` `-` | Font size. |
+3 -1
View File
@@ -19,7 +19,9 @@ It renders what it can:
| PDF and Office documents | Converted for preview when a converter is available. |
| Anything else | Download. |
Caps: 10 MB for text preview, 50 MB for raw and download. Sensitive paths (`.env`, anything
Caps: 10 MB for text preview, 2 GB for raw and download (set `CODEMAN_MAX_DOWNLOAD_BYTES`
to change it, `0` for no limit — these bodies are streamed, so a large file costs a read
stream rather than server memory). Sensitive paths (`.env`, anything
matching credentials, `~/.ssh`, AWS credentials) are blocked from download, and SVG and HTML
are served as downloads rather than rendered, so they cannot execute in the page.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.26.1",
"version": "1.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.26.1",
"version": "1.27.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.26.1",
"version": "1.27.0",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+2
View File
@@ -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',
+19 -5
View File
@@ -19,7 +19,7 @@
import { randomBytes } from 'crypto';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { CodemanClient, stripAnsi, type TurnOutcome } from './codeman-client.js';
import { CodemanClient, ModelLimitError, stripAnsi, type TurnOutcome } from './codeman-client.js';
import type { PrBotConfig } from './config.js';
import {
approveWorkflowRun,
@@ -434,7 +434,10 @@ export class PrBot {
if (report && !existsSync(reportMdPath)) writeFileSync(reportMdPath, last);
}
await this.recordClaudeSessionId(sessionId, rec);
if (!report) throw new Error(await this.describeFailure(sessionId, outcome, started, last));
if (!report) {
const why = await this.describeFailure(sessionId, outcome, started, last);
throw outcome.kind === 'limit' ? new ModelLimitError(why) : new Error(why);
}
const durationMin = Math.max(1, Math.round((Date.now() - started) / 60_000));
Object.assign(rec, {
@@ -460,9 +463,15 @@ export class PrBot {
if (rec) {
rec.status = 'failed';
rec.lastError = reason;
rec.failedAttempts = rec.failedSha === rec.headSha ? (rec.failedAttempts ?? 0) + 1 : 1;
rec.failedSha = rec.headSha;
const givingUp = rec.failedAttempts >= MAX_AUTO_RETRIES;
// A spent model budget is an account condition, not a bad PR, so it must not
// spend the per-head retry budget: otherwise one exhausted afternoon marks every
// open PR "not retrying on my own" and none of them come back when credits do.
const accountCondition = err instanceof ModelLimitError;
if (!accountCondition) {
rec.failedAttempts = rec.failedSha === rec.headSha ? (rec.failedAttempts ?? 0) + 1 : 1;
rec.failedSha = rec.headSha;
}
const givingUp = !accountCondition && (rec.failedAttempts ?? 0) >= MAX_AUTO_RETRIES;
await this.telegram
.sendMessage(
formatReviewFailure(rec, reason) +
@@ -523,6 +532,11 @@ export class PrBot {
}
case 'exit':
return 'the session exited before writing a report';
case 'limit':
return (
`the model budget for these review sessions is spent, so the reviewer never started:\n${outcome.message}\n` +
'Point PR_BOT_MODEL in ~/.codeman/pr-bot.env at a model with headroom and restart codeman-pr-bot.'
);
case 'timeout':
return `timed out after ${minutes} min without a report`;
default:
+50 -2
View File
@@ -50,7 +50,42 @@ export interface SessionRecord {
mode: string;
}
export type TurnOutcome = { kind: 'stop' } | { kind: 'blocked' } | { kind: 'exit' } | { kind: 'timeout' };
export type TurnOutcome =
| { kind: 'stop' }
| { kind: 'blocked' }
| { kind: 'exit' }
| { kind: 'timeout' }
| { kind: 'limit'; message: string };
/**
* Claude Code answers a spent model budget INSIDE the turn ("You've reached your Fable
* limit. Run /usage-credits to continue or switch models with /model.") and then simply
* sits there with nothing to write. Measured 2026-09-08: four reviews each burned their
* whole 40-minute deadline and reported a bare "timed out without a report", which reads
* as a hung reviewer rather than an account that needs attention, and the retries spent
* the per-head budget so the PRs would not have been picked up again once credits
* returned. Matching the notice turns 40 silent minutes into a named failure in seconds.
*
* Deliberately model-agnostic: the same sentence is printed for every model, and the
* apostrophe is typographic on the pane, so neither the model name nor `'` is matched.
*/
const MODEL_LIMIT_PATTERN = /reached your [^\n]{0,40}?\blimit\b|\/usage-credits/i;
/**
* Thrown instead of a plain Error when a review died on a spent model budget, so the
* caller can tell an account condition apart from a review that genuinely failed.
*/
export class ModelLimitError extends Error {
override readonly name = 'ModelLimitError';
}
/** The limit notice as one clean line, or undefined if the screen does not carry it. */
export function findModelLimitNotice(screen: string): string | undefined {
const line = stripAnsi(screen)
.split('\n')
.find((l) => MODEL_LIMIT_PATTERN.test(l));
return line?.replace(/^[\s>|]*(?:\u23bf|\u2514|\u256d|\u2570|\u23a2|\u2502|\u23bd)?\s*/u, '').trim() || undefined;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@@ -241,9 +276,22 @@ export class CodemanClient {
if (!r.delivered) throw new Error('the prompt was not delivered (pane dead?)');
let wait = r.wait;
let nudged = false;
// Only consulted when the turn produced nothing, so a review that merely QUOTES the
// notice in its report cannot be mistaken for one that hit it.
const limitNotice = async (): Promise<string | undefined> =>
findModelLimitNotice(await this.terminalText(id).catch(() => ''));
while (true) {
if (wait && !wait.timedOut) return toOutcome(wait);
if (wait && !wait.timedOut) {
const outcome = toOutcome(wait);
if (outcome.kind === 'stop' && !opts.isDone?.()) {
const limit = await limitNotice();
if (limit) return { kind: 'limit', message: limit };
}
return outcome;
}
if (opts.isDone?.()) return { kind: 'stop' };
const limit = await limitNotice();
if (limit) return { kind: 'limit', message: limit };
const remaining = opts.deadlineMs - (Date.now() - started);
if (remaining <= 0) return { kind: 'timeout' };
if (!nudged) {
+1 -1
View File
@@ -11,7 +11,7 @@
* worktree's project settings through the git common dir, i.e. the MAIN checkout's
* `.claude/settings.local.json`, whose model pin then silently overrides anything
* written into the worktree (measured 2026-09-05: a worktree pinned to
* `claude-fable-5-1` reported `claude-opus-5[1m]`). A shared clone has its own
* `claude-fable-5-1` reported `claude-opus-5[1m]`, the main checkout's pin). A shared clone has its own
* project root, so Codeman's `modelOverride` and hooks land where the CLI reads them,
* while `objects/info/alternates` keeps the object store shared (no duplication).
*
+1
View File
@@ -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` |
+47
View File
@@ -112,3 +112,50 @@ export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
* Override: CODEMAN_MAX_PASTE_IMAGE_BYTES (bytes)
*/
export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB
// ============================================================================
// File Download Limits
// ============================================================================
/**
* Parse a byte-limit env var, where `0` explicitly means "no limit".
*
* The `parseInt(...) || default` idiom used elsewhere in this file cannot
* express that: it treats 0 as falsy and silently restores the default.
*/
function parseByteLimitEnv(raw: string | undefined, fallback: number): number {
if (raw === undefined || raw.trim() === '') return fallback;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
return parsed;
}
/**
* Maximum size (bytes) of a file served by the raw/download file routes:
* `GET /api/sessions/:id/file-raw` (the Files panel's download link and the
* file-preview overlay), the attachment `/raw` route, and `GET /api/download`.
*
* ⚠️ This is a sanity bound, NOT memory protection. All three bodies are
* STREAMED and `Range`-aware (`sendFileBody` in file-routes.ts), so a large
* file costs one read stream rather than its size in RSS. The historical 50MB
* cap predates that streaming rewrite and its "prevent memory exhaustion"
* comment described a `readFile()` that no longer exists — all it did was
* refuse legitimate downloads of build artifacts, videos and archives.
*
* Set `CODEMAN_MAX_DOWNLOAD_BYTES=0` to remove the cap entirely.
* Override: CODEMAN_MAX_DOWNLOAD_BYTES (bytes)
*/
export const MAX_FILE_DOWNLOAD_BYTES = parseByteLimitEnv(
process.env.CODEMAN_MAX_DOWNLOAD_BYTES,
2 * 1024 * 1024 * 1024 // 2GB
);
/** True when `size` exceeds the download cap (a cap of 0 means unlimited). */
export function exceedsDownloadLimit(size: number): boolean {
return MAX_FILE_DOWNLOAD_BYTES > 0 && size > MAX_FILE_DOWNLOAD_BYTES;
}
/** Human-readable "File too large (…)" message for a refused download. */
export function downloadTooLargeMessage(size: number): string {
return `File too large (${Math.round(size / 1024 / 1024)}MB > ${Math.round(MAX_FILE_DOWNLOAD_BYTES / 1024 / 1024)}MB limit). Raise or remove it with CODEMAN_MAX_DOWNLOAD_BYTES (0 = unlimited).`;
}
+6 -1
View File
@@ -137,7 +137,12 @@ export interface RespawnPaneOptions {
/** Options for pane buffer capture (COD-47 full-history mode). */
export interface PaneCaptureOptions {
/** Capture the entire tmux scrollback instead of just the visible frame. */
/**
* Capture the entire scrollback instead of just the visible frame, as linear
* text ending with a cursor move back to the pane's caret position. An
* implementation returns '' when the pane holds nothing visible, which the
* caller reads as "nothing to replay" and keeps its existing history.
*/
fullHistory?: boolean;
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
historyLimitLines?: number;
+18
View File
@@ -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) {
+177 -47
View File
@@ -528,6 +528,73 @@ export function normalizeScrollbackEol(buffer: string): string {
return buffer.replace(/\r?\n/g, '\r\n');
}
/** Pane geometry and caret position, as `display-message` reports them. */
interface PaneCursorGeometry {
cols: number;
rows: number;
cursorX: number;
cursorY: number;
}
/**
* Read the pane's cursor and size, or null when tmux cannot say.
*
* Every field is validated together: a caller that gets a value back can place
* a caret with it, and one that gets null must not try.
*/
export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
let raw: string;
try {
raw = run().trim();
} catch (cursorErr) {
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
return null;
}
const [cursorX, cursorY, cols, rows] = raw.split(/\s+/).map((value) => parseInt(value, 10));
if (
!Number.isFinite(cursorX) ||
!Number.isFinite(cursorY) ||
!Number.isFinite(cols) ||
!Number.isFinite(rows) ||
cursorX < 0 ||
cursorY < 0 ||
cols <= 0 ||
rows <= 0
) {
return null;
}
return { cols, rows, cursorX, cursorY };
}
/** SGR attributes, which is all `capture-pane -e` emits. */
// eslint-disable-next-line no-control-regex
const CAPTURE_STYLE_SEQUENCE = /\x1b\[[0-9;:]*m/g;
/** Whether a capture holds anything a reader would see, styles discounted. */
export function hasVisibleContent(capture: string): boolean {
return /\S/.test(capture.replace(CAPTURE_STYLE_SEQUENCE, ''));
}
/**
* Put the caret back where the pane has it, counting UP from the bottom of what
* was just replayed.
*
* Relative rather than absolute (`CUP`) on purpose. `\x1b[<row>;<col>H` numbers
* rows from the top of the browser's screen, so it only lands correctly while
* the browser's row count equals the pane's — and it need not, because
* `resizeWindow` fires its tmux resize without waiting, so a capture can be
* taken before a requested resize has been applied. Counting up from the last
* replayed row is anchored to the content instead, which is the thing both ends
* genuinely share.
*/
export function formatCursorRestore(geometry: PaneCursorGeometry): string {
const up = Math.max(0, geometry.rows - 1 - geometry.cursorY);
const right = Math.max(0, geometry.cursorX);
// `\r` first so the column is known: the replay leaves the caret wherever the
// last row's text ended.
return `${up > 0 ? `\x1b[${up}A` : ''}\r${right > 0 ? `\x1b[${right}C` : ''}`;
}
export function formatPaneSnapshot(
lines: string[],
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
@@ -760,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
@@ -770,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
@@ -1242,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) {
@@ -1815,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)
@@ -2066,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 {
@@ -3199,11 +3327,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* the browser xterm reproduces the live frame. Used for fast tab switches.
* - Full history (`opts.fullHistory`): `capture-pane -p -e -J -S -<N>` grabs
* the tmux scrollback (COD-47, bounded to the configured history limit),
* returned as linear scrollback text with SGR codes preserved (NOT
* repositioned — a multi-screen history can't be painted into a single
* visible frame, so the snapshot repaint is skipped). `-J` re-joins lines
* hard-wrapped at the pane width so they reflow in the browser xterm.
* Used for full page reloads so the user gets back their scroll history.
* returned as linear scrollback text with SGR codes preserved. Rows are not
* repainted at absolute positions — a multi-screen history can't be painted
* into a single visible frame — but the capture DOES end with a cursor move
* putting the caret back where the pane has it, counted up from the last
* replayed row. `-J` re-joins lines hard-wrapped at the pane width so they
* reflow in the browser xterm. Used for full page reloads so the user gets
* back their scroll history. Returns '' for a pane holding nothing visible,
* so the caller keeps whatever history it already had.
* Caveat: lines tmux has already evicted past its history-limit are gone.
*/
/**
@@ -3262,42 +3393,41 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
execOpts.maxBuffer =
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES;
}
const buffer = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts).replace(
/\n+$/g,
''
);
// Full-history spans many screens — return it as raw linear scrollback
// rather than repainting rows at single-screen absolute positions. tmux
// joins scrollback rows with a bare `\n`; normalize to `\r\n` so a fresh
// xterm (convertEol:false) starts each replayed line at column 0 instead
// of staircasing diagonally (COD-138).
if (fullHistory) {
return normalizeScrollbackEol(buffer);
}
try {
const cursor = execSync(
const rawCapture = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts);
// Query the cursor BEFORE deciding anything else. On the full-history path
// it settles both how the capture is trimmed and whether a cursor move is
// appended, and those two have to agree: trailing blank rows are only safe
// to keep when a move follows to put the caret back above them.
const geometry = queryPaneCursor(() =>
execSync(
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
).trim();
const [cursorX, cursorY, cols, rows] = cursor.split(/\s+/).map((value) => parseInt(value, 10));
if (
Number.isFinite(cursorX) &&
Number.isFinite(cursorY) &&
Number.isFinite(cols) &&
Number.isFinite(rows) &&
cursorX >= 0 &&
cursorY >= 0 &&
cols > 0 &&
rows > 0
) {
return formatPaneSnapshot(buffer.split('\n'), { cols, rows, cursorX, cursorY });
}
} catch (cursorErr) {
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
)
);
if (fullHistory) {
// Without geometry there is no cursor move, so fall back to the old trim.
// Keeping the blank rows here would park the caret at the bottom of the
// pane with nothing to correct it — worse than not trying at all.
if (!geometry) return normalizeScrollbackEol(rawCapture.replace(/\n+$/g, ''));
// Take the line terminator off and nothing else. The trailing blank rows
// that remain are the real bottom of the screen, and the cursor move
// below counts up from it. tmux joins rows with a bare `\n`; normalize to
// `\r\n` so a fresh xterm (convertEol:false) starts each replayed line at
// column 0 instead of staircasing diagonally (COD-138).
const trimmed = rawCapture.replace(/\n$/, '');
// An all-blank pane has to keep reading as "nothing to replay". The caller
// treats an empty string as "capture unavailable" and keeps the byte
// history; blank rows plus a cursor move are not empty, so without this a
// blank pane REPLACES that history with a blank screen — the downgrade
// `_replayWouldShrinkBuffer` exists to refuse, arriving from the server
// side where that guard cannot see it.
if (!hasVisibleContent(trimmed)) return '';
return `${normalizeScrollbackEol(trimmed)}${formatCursorRestore(geometry)}`;
}
const buffer = rawCapture.replace(/\n+$/g, '');
if (geometry) return formatPaneSnapshot(buffer.split('\n'), geometry);
// Cursor query failed or geometry was invalid, so we skip the absolute-
// positioned snapshot repaint and fall back to the raw capture. Normalize
// its bare `\n` line endings to `\r\n` so the replay doesn't staircase
+22 -2
View File
@@ -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;
}
+184 -6
View File
@@ -1332,7 +1332,11 @@ class CodemanApp {
this._redock(id);
}
/** Clear all dashboard-side detached state/timers for a session. */
/** Clear all dashboard-side detached state/timers for a session, and take its
* sizing back: the popup owned the pane while it was open, so the dashboard's
* record of it is stale and the session it is showing needs re-measuring.
* ⚠️ Not idempotent — each call re-asserts, so a path that redocks twice for
* one close sends two SIGWINCHs. */
_redock(id) {
const t = this._detachWatchTimers.get(id);
if (t) { clearInterval(t); this._detachWatchTimers.delete(id); }
@@ -1340,6 +1344,21 @@ class CodemanApp {
this._detachOrphanStrikes.delete(id);
this.detachedWindows.delete(id);
this._markDetached(id, false);
// While the popup owned this session the dashboard sent no resizes, so
// `_lastResizeDims` — one value for the whole window — no longer describes
// the PTY, which the popup has been sizing. Clearing it makes the next
// sendResize report truthfully, on every redock path rather than only the
// active one: `selectSession` reads that answer to decide whether to wait
// for the TUI's redraw, and a false "unchanged" makes it fetch the frame
// before the redraw lands.
this._lastResizeDims = null;
// Sizing comes back with the session. `force` buys a guaranteed repaint for
// the case where popup and dashboard happened to agree on a size; the server
// already resizes on its own comparison against the real pane whenever the
// two differ.
if (this.sessions.has(id) && id === this.activeSessionId) {
this.sendResize(id, { force: true })?.catch?.(() => {});
}
}
/** Defer a channel-driven redock briefly. A popup *reload* emits 'redocked'
@@ -2130,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) {
@@ -2306,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
@@ -2326,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.
@@ -2347,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);
}
@@ -3971,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.
@@ -4592,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
@@ -4903,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.
@@ -4960,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">&#x2699;</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">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</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>
@@ -5045,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
@@ -5171,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');
@@ -5906,6 +6067,23 @@ class CodemanApp {
}
}
// Hold for the terminal font before measuring anything. A cell measured
// against a fallback font gives the wrong column and row count, and the
// correction would land after the replay, leaving the CLI drawing against a
// frame the terminal no longer shows. Resolves immediately once the font is
// in, so this costs a tab switch nothing after the first load, and it is
// bounded, so a font that never arrives cannot strand the session.
// ⚠️ BEFORE `_beginBufferLoad` on purpose: inside it, every live SSE event
// for this session queues instead of painting, so a slow font would hold
// output back rather than merely mis-measuring the grid.
if (this._terminalFontReady) {
await this._terminalFontReady;
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
}
// Load terminal buffer for this session
// Show cached content instantly while fetching fresh data in background.
// Use tail mode for faster initial load (128KB is enough for recent visible content).
+23 -1
View File
@@ -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
@@ -659,6 +665,22 @@ function sortSessionsByActivity(rows) {
// prompt icons (powerline segments, folder/git glyphs from p10k, starship,
// oh-my-posh) render even though the text fonts carry no private-use-area
// symbols — while all readable text keeps coming from the text fonts.
/**
* How long a terminal fit will wait for the terminal font, in ms.
*
* `FontFaceSet.ready` has no deadline of its own and the wait sits in front of
* the buffer replay, so a font request that never settles would leave the
* session unpainted. Past this we measure whatever is painted.
*/
const TERMINAL_FONT_WAIT_MS = 2000;
/**
* Families in the stack that cannot move the measured cell, so nothing waits on
* them: the generics match no `FontFace`, and the bundled symbols face carries
* private-use-area glyphs only (xterm measures `W`) while weighing ~1.2MB.
*/
const TERMINAL_FONT_UNMEASURED = new Set(['monospace', 'serif', 'sans-serif', 'system-ui', 'symbols nerd font mono']);
const TERMINAL_FONT_DEFAULT_STACK =
'"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, "Symbols Nerd Font Mono", monospace';
+9 -1
View File
@@ -25,7 +25,10 @@
* 3. The terminal pane is ONE shared element, so its entrance is marked at
* session creation but played at selection: a session created in the
* background must not animate the pane the user is currently looking at. Its
* styles are also restricted to transform/opacity/clip-path (see below).
* styles are also restricted to transform/opacity/clip-path (see below), with
* `blur` the one documented exception - a filter is the only thing that
* actually blurs a live xterm; styles.css carries the measurement and the
* three alternatives that do not work.
* 4. Nothing may animate on page load or reconnect replay. Only ids that pass
* through `markSessionTabEntering()` animate, and `_tabEnterSeen` makes that
* once-per-id even though the POST response and the SSE event both call
@@ -50,6 +53,7 @@ const TAB_ANIM_STYLES = [
{ key: 'unroll', label: 'Unroll', blurb: 'The strip makes room and the tab widens in.', duration: 480 },
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 720 },
{ key: 'flip', label: 'Flip', blurb: 'Drops in as a card hinged on its top edge.', duration: 520 },
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur fades off it.', duration: 440 },
{ key: 'off', label: 'Off', blurb: 'Tabs just appear.', duration: 0 },
];
@@ -65,6 +69,7 @@ const WIN_ANIM_STYLES = [
{ key: 'unfold', label: 'Unfold', blurb: 'Hinges down from its top edge in 3D.', duration: 560 },
{ key: 'beam', label: 'Beam down', blurb: 'Waits for its line to reach it, then materializes.', duration: 620 },
{ key: 'pop', label: 'Pop', blurb: 'Springs open from its centre.', duration: 460 },
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur fades off it.', duration: 560 },
{ key: 'off', label: 'Off', blurb: 'Windows just appear.', duration: 0 },
];
@@ -73,6 +78,7 @@ const LINE_ANIM_STYLES = [
{ key: 'draw', label: 'Draw', blurb: 'Draws itself from the tab down to the window.', duration: 420 },
{ key: 'packet', label: 'Packet', blurb: 'Line fades in, then a bright packet runs down it.', duration: 700 },
{ key: 'fade', label: 'Fade', blurb: 'Simply fades in.', duration: 300 },
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur fades off it.', duration: 380 },
{ key: 'off', label: 'Off', blurb: 'Lines just appear.', duration: 0 },
];
@@ -92,6 +98,7 @@ const TERM_ANIM_STYLES = [
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur lifts off the pane.', duration: 520 },
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
];
@@ -102,6 +109,7 @@ const BEAM_HOLD_MS = 360;
const ANIM_THEMES = [
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur' },
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
+13 -2
View File
@@ -60,9 +60,22 @@ Object.assign(CodemanApp.prototype, {
document.body.appendChild(trap);
trap.focus();
// One Ctrl+V can deliver TWO paste events to this trap. The
// execCommand('paste') below fires one wherever the browser honours that
// command, and the key's own default action fires another, because xterm's
// custom key handler returns false without cancelling the keydown. Handling
// both sends the clipboard text to the PTY twice, which is the "Ctrl+V
// pastes twice, right-click Paste does not" report: the context-menu paste
// has no keydown, so it only ever produces one event. The trap therefore
// accepts the first paste and drops every later one.
var pasteConsumed = false;
// Listen for the paste event on our trap
trap.addEventListener('paste', function(e) {
e.stopPropagation();
e.preventDefault();
if (pasteConsumed) return;
pasteConsumed = true;
// Check for images in clipboard items
var imageFiles = [];
@@ -84,7 +97,6 @@ Object.assign(CodemanApp.prototype, {
}, 0);
if (imageFiles.length > 0) {
e.preventDefault();
self._uploadAndInsertImages(imageFiles);
} else {
// No image -- route text through xterm's paste() so bracketed-paste
@@ -94,7 +106,6 @@ Object.assign(CodemanApp.prototype, {
// indistinguishable from typed input, weakening the CLI's
// prompt-injection defenses.
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
e.preventDefault();
if (text && self.terminal) self.terminal.paste(text);
}
});
+14 -1
View File
@@ -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)}
@@ -1889,6 +1889,7 @@
<option value="legacy">Off (default)</option>
<option value="terminal">Terminal (CRT)</option>
<option value="beamdown">Beam down</option>
<option value="softfocus">Soft focus (blur)</option>
<option value="quiet">Quiet</option>
<option value="playful">Playful</option>
<option value="custom">Custom (set in the lab)</option>
@@ -1943,6 +1944,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>
@@ -3458,6 +3469,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>
+7
View File
@@ -148,6 +148,13 @@ const MobileDetection = {
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) return;
const vh = window.visualViewport?.height || window.innerHeight;
document.documentElement.style.setProperty('--app-height', `${vh}px`);
// How far the layout viewport (which anchors position: fixed) extends below
// the visual viewport, i.e. behind the browser's bottom bar. 0 on iPhone
// Safari, where fixed elements already stop above the bar; the overlap
// where they do not. mobile.css lifts the toolbar by this rather than by
// (100vh - --app-height), which on iPhone measures the collapsible chrome
// instead and left an empty band between the toolbar and the bar.
document.documentElement.style.setProperty('--chrome-overlap', `${Math.max(0, window.innerHeight - vh)}px`);
},
/** Initialize mobile detection and set up resize listener */
+11 -8
View File
@@ -471,11 +471,11 @@ html.mobile-init .file-browser-panel {
padding-bottom: calc(40px + var(--safe-area-bottom));
}
/* iOS Safari: toolbar is pushed up by (100vh - --app-height) to clear the
browser's bottom bar. Match that offset in main's padding so the terminal
doesn't extend behind the toolbar. */
/* iOS Safari: the toolbar is lifted by --chrome-overlap where the browser's
bottom bar would otherwise cover it. Match that offset in main's padding so
the terminal doesn't extend behind the toolbar. */
.ios-device.safari-browser .main {
padding-bottom: calc(40px + var(--safe-area-bottom) + (100vh - var(--app-height, 100vh)));
padding-bottom: calc(40px + var(--safe-area-bottom) + var(--chrome-overlap, 0px));
}
.header-right {
@@ -780,11 +780,14 @@ html.mobile-init .file-browser-panel {
will-change: transform;
}
/* iOS Safari with tab bar: position: fixed uses the layout viewport which
extends behind the browser chrome. Offset the toolbar upward by the delta
between 100vh (layout) and --app-height (visual). */
/* iOS Safari: where position: fixed anchors to a layout viewport that
extends behind the browser's bottom bar, lift the toolbar by that overlap.
--chrome-overlap is innerHeight minus the visual viewport height, set in
mobile-handlers.js. On iPhone Safari it is 0 because fixed elements already
stop above the bar; the previous (100vh - --app-height) lift measured the
collapsible chrome instead and left an empty band above the bar. */
.ios-device.safari-browser .toolbar {
bottom: calc(var(--safe-area-bottom) + (100vh - var(--app-height, 100vh)));
bottom: calc(var(--safe-area-bottom) + var(--chrome-overlap, 0px));
}
/* When keyboard is visible the JS translateY already accounts for the full
+14 -2
View File
@@ -431,6 +431,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';
@@ -2097,6 +2099,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(
@@ -2500,6 +2503,7 @@ Object.assign(CodemanApp.prototype, {
tabOrientation: 'horizontal',
tabRailWidth: 256,
tabRailDetail: 'rich',
tabRailSort: 'activity',
sessionListLayout: 'header',
sessionSidebarFontSize: 12,
cjkInputEnabled: false,
@@ -2766,6 +2770,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 +2801,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,7 +3074,7 @@ 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',
'language',
+282 -5
View File
@@ -905,6 +905,28 @@ html[data-tab-anim="flip"] .session-tab.tab-enter {
100% { opacity: 1; transform: perspective(700px) rotateX(0deg); }
}
/* Blur, iOS-style focus pull: the tab arrives out of focus and the blur fades
OFF it as the opacity comes up, so it reads as resolving rather than moving.
Opacity leads the blur (full opacity around 45%, blur still lifting) - that
offset is what separates it from a plain cross-fade.
`filter` here, not on ::before: the tab's own box-shadow and border have to
blur with it or the shape stays sharp inside a blurred fill, and unlike
background/box-shadow (which .session-tab.active sets !important) nothing
overrides filter. */
html[data-tab-anim="blur"] .session-tab.tab-enter {
animation-name: tab-enter-blur;
animation-duration: calc(440ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
will-change: transform, opacity, filter;
}
@keyframes tab-enter-blur {
0% { opacity: 0; filter: blur(10px); transform: scale(0.94); }
45% { opacity: 1; }
100% { opacity: 1; filter: blur(0px); transform: none; }
}
/* ── Entrance lab (?animlab=1) ───────────────────────────────────────────── */
.anim-lab {
@@ -1186,6 +1208,25 @@ html[data-win-anim="pop"] .ultracode-window.win-enter {
100% { opacity: 1; transform: scale(1); }
}
/* Blur, iOS-style focus pull. `materialize` is the noisy cousin: it glitches the
opacity and rides a brightness boost. This one only defocuses, so it stays
readable next to a terminal. The scale is deliberately small (0.96): the
connection line is aimed at getBoundingClientRect(), which reports the
TRANSFORMED box, so a big scale would swing the line's target while it draws.
applyWindowEntrance() redraws the lines once the animation ends. */
html[data-win-anim="blur"] .subagent-window.win-enter,
html[data-win-anim="blur"] .ultracode-window.win-enter {
animation-name: win-enter-blur;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes win-enter-blur {
0% { opacity: 0; filter: blur(18px); transform: scale(0.96); }
50% { opacity: 1; }
100% { opacity: 1; filter: blur(0px); transform: none; }
}
/* ── Main terminal pane entrance animations ────────────────────────────────
⚠ transform / opacity / clip-path ONLY. xterm's FitAddon derives rows+cols
from getComputedStyle(parent).width/height, the untransformed layout box -
@@ -1327,6 +1368,47 @@ html[data-term-anim="fade"] .terminal-container.term-enter {
100% { opacity: 1; transform: none; }
}
/* Blur, iOS-style focus pull.
⚠ THE ONE PLACE A `filter` GOES ON THE TERMINAL CONTAINER, and it is a
deliberate exception to the rule above, not an oversight. Every other way of
blurring this pane was tried against a real xterm and does not work:
- `backdrop-filter` on ::before blurs perfectly while it is STATIC, and
Chrome silently drops the backdrop the moment ANY animation runs on that
pseudo-element (measured: the veil computes `blur(15.3px)` and the text
behind it stays razor sharp). Animating the container instead keeps the
backdrop, so the veil would have to hold one fixed radius, which is a
frosted pane that snaps off rather than a focus pull.
- Driving the radius from rAF avoids the compositor promotion, at the cost
of the same full-screen blur per frame plus main-thread work.
So the cost the rule exists to avoid is inherent to blurring a terminal at
all, and this style buys it knowingly: it is opt-in, OFF by default, bounded
to one ~520ms run when a session is opened (or switched to, with `Also on
every tab switch`), and the class comes straight back off. `will-change` is
still deliberately unset, per the base rule. Measured price on a headless
SwiftShader rasterizer with no GPU at all, i.e. the worst case: frame deltas
go 16.7ms -> 33.3ms for the length of the run, against 16.7ms flat for `fade`.
The blur must not change layout, or FitAddon would feed wrong dimensions into
resize() and through to the PTY. `filter` is paint-only (measured live: 178x38
before, during and after a run), and the property allowlist for every one of
these keyframes is pinned by test/entrance-animations.test.ts. */
html[data-term-anim="blur"] .terminal-container.term-enter {
animation-name: term-enter-blur;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
/* Opacity leads the blur - full opacity around 45%, blur still lifting - which
is what separates the effect from a plain cross-fade. */
@keyframes term-enter-blur {
0% { opacity: 0; filter: blur(14px); transform: scale(1.008); }
45% { opacity: 1; }
100% { opacity: 1; filter: blur(0px); transform: none; }
}
/* ── Connection-line entrance animations ───────────────────────────────────
`--line-len` is the measured path length, stamped inline by
_applyLineEntrances(); `--line-enter-delay` is negative when an entrance is
@@ -1393,6 +1475,26 @@ html[data-line-anim="packet"] .connection-line.line-enter {
100% { stroke-dashoffset: calc(-1 * var(--line-len)); opacity: 0; }
}
/* Blur, the line focuses in alongside a blurred tab and window. `filter` on an
SVG path takes CSS filter functions, so the blur simply rides in front of the
line's own glow (see --line-glow on .connection-line).
⚠ The 100% frame deliberately omits `opacity`, which makes the browser take
the endpoint from the element's own computed value: a subagent line rests at
0.9, a lineage line at 0.72, and a WORKING lineage line at 0.95. Pinning 0.9
here - as `line-enter-fade` above still does - lands every lineage line on the
wrong opacity and snaps it when the class comes off. */
html[data-line-anim="blur"] .connection-line.line-enter {
animation-name: line-enter-blur;
animation-duration: calc(380ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes line-enter-blur {
0% { opacity: 0; filter: blur(5px) var(--line-glow); }
100% { filter: blur(0px) var(--line-glow); }
}
.anim-lab-check {
display: flex;
align-items: center;
@@ -9833,10 +9935,17 @@ kbd {
stroke-dasharray: 5 3;
fill: none;
opacity: 0.9;
/* Dark outline for contrast, vibrant blue glow */
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
drop-shadow(0 0 4px rgba(59, 130, 246, 0.8))
drop-shadow(0 0 8px rgba(59, 130, 246, 0.5));
/* Dark outline for contrast, vibrant blue glow. Held in a variable because the
`blur` line entrance animates `filter`: a keyframe listing only the blur would
drop the glow for the length of the run and pop it back at the end, and the
lineage lines below - whose glow is a different colour entirely, set per
element - make that obvious. Both of its keyframes say
`blur(N) var(--line-glow)`, so the function lists match and interpolate while
each kind of line keeps its own glow. */
--line-glow: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
drop-shadow(0 0 4px rgba(59, 130, 246, 0.8))
drop-shadow(0 0 8px rgba(59, 130, 246, 0.5));
filter: var(--line-glow);
transition: opacity 0.2s, stroke-width 0.2s, filter 0.2s;
}
@@ -9930,8 +10039,10 @@ kbd {
stroke-dasharray: 5 5;
stroke-linecap: round;
opacity: 0.72;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
--line-glow: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7))
drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
drop-shadow(0 0 11px var(--lineage-color, var(--session-blue, #2b8fd9)));
filter: var(--line-glow);
}
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
@@ -17643,6 +17754,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);
+164 -2
View File
@@ -232,12 +232,22 @@ 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();
this.terminal = new Terminal({
theme: { ...window.codemanCurrentXtermTheme() },
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
@@ -292,6 +302,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
@@ -514,6 +534,11 @@ Object.assign(CodemanApp.prototype, {
} else {
this.fitAddon.fit();
}
// Whenever that first fit runs — on this line, or a frame or two later on
// the mobile-Safari branch above — it measures whatever font the browser has
// painted with so far, which is not necessarily the terminal font. Start the
// wait now so the buffer load can hold for it.
this._terminalFontReady = this._awaitTerminalFont();
// Register link provider for clickable file paths in Bash tool output
this.registerFilePathLinkProvider();
@@ -935,7 +960,10 @@ Object.assign(CodemanApp.prototype, {
// causes Ink to re-render at the new row count, garbling terminal output.
// Local fit() still runs so xterm knows the viewport size for scrolling.
const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
if (this.activeSessionId && !keyboardUp) {
// Same yield as sendResize: never resize a PTY whose session is showing
// in its own window. Dragging the dashboard's border must not reshape it.
const detachedElsewhere = !this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId);
if (this.activeSessionId && !keyboardUp && !detachedElsewhere) {
const dims = this.fitAddon.proposeDimensions();
// Enforce minimum dimensions to prevent layout issues
const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS;
@@ -1026,7 +1054,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
@@ -1348,6 +1376,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);
});
},
@@ -1440,6 +1511,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() {
@@ -3843,6 +3918,14 @@ Object.assign(CodemanApp.prototype, {
return;
}
// The pane belongs to the popup showing it, so this window has nothing to
// restore. Say so rather than reporting a size that was never sent — the
// same button in that window does the job.
if (!this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId)) {
this.showToast('This session is sized by its own window', 'warning');
return;
}
const dims = this.getTerminalDimensions();
if (!dims) {
this.showToast('Could not determine terminal size', 'error');
@@ -4785,6 +4868,15 @@ Object.assign(CodemanApp.prototype, {
const resolved = window.CodemanTerminalFont.resolve(custom);
if (!this.terminal || this.terminal.options.fontFamily === resolved) return;
this.terminal.options.fontFamily = resolved;
// Changing the family at runtime is the same race as the boot-time one: the
// option write makes xterm re-measure immediately, against a family the
// browser may not have loaded. Re-arm the wait for the new stack and fit
// again once it settles, so the setting takes effect at the right size
// without needing a tab switch. The fit below still runs, so the terminal
// is never left unfitted if the wait is slow.
this._terminalFontReady = this._awaitTerminalFont().then(() => {
if (this.terminal?.options?.fontFamily === resolved) this.fitAddon?.fit();
});
this.fitAddon?.fit();
this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont();
@@ -4801,6 +4893,65 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Wait for the terminal's own font, then make xterm re-measure against it.
*
* A character cell measured against a fallback font has a different width and
* height from one measured against the terminal font, so a fit taken too early
* produces the wrong column and row count. The correction then arrives after
* the buffer has been replayed, and the CLI redraws a frame that no longer
* matches what the terminal is showing.
*
* ⚠️ Waiting is not sufficient on its own, which is what the re-measure at the
* end is for. `FitAddon.proposeDimensions()` divides the container by a CACHED
* cell size, and xterm refreshes that cache only from `open()`, from a resize
* that actually changed the grid, and on a device-pixel-ratio change — nothing
* in it listens for font loading. So a fit that runs after the font arrives can
* still divide by the fallback cell, propose the grid it already has, and
* short-circuit before anything re-measures.
*
* `document.fonts.load` for each family is what actually REQUESTS the faces:
* the WebGL renderer rasterises glyphs through a canvas texture atlas, and
* canvas text never triggers a CSS font fetch, so `document.fonts.ready` can
* resolve with a face never having been asked for at all.
*
* Every step is best-effort and the whole thing is bounded, because a font
* request that never settles must not hold up the terminal: `FontFaceSet.ready`
* has no deadline of its own, and the caller awaits this in front of the buffer
* replay. Past the deadline we fit against whatever is painted, which is the
* old behaviour rather than a new failure.
*/
async _awaitTerminalFont() {
try {
if (typeof document === 'undefined' || !document.fonts?.load) return;
const size = this.terminal?.options?.fontSize || 14;
const families = String(this.terminal?.options?.fontFamily || '')
.split(',')
.map((family) => family.trim().replace(/^["']|["']$/g, ''))
.filter(Boolean)
// Only the faces that can supply the measured glyph are worth waiting on.
// The bundled symbols font is ~1.2MB and carries private-use-area glyphs
// only — xterm measures `W`, which it does not contain — so awaiting it
// puts a megabyte between the user and their first frame for nothing.
// Generic families match no FontFace at all.
.filter((family) => !TERMINAL_FONT_UNMEASURED.has(family.toLowerCase()));
const loaded = Promise.all(
families.map((family) => document.fonts.load(`${size}px "${family}"`).catch(() => {}))
).then(() => document.fonts.ready);
await Promise.race([loaded, new Promise((resolve) => setTimeout(resolve, TERMINAL_FONT_WAIT_MS))]);
} catch {
/* font loading is unavailable or failed — fit against whatever is painted */
}
// Force the cache refresh xterm will not do for us. Without this the wait
// buys nothing on the common path (see the warning above). Private API, as
// FitAddon itself is; guarded because a terminal can be disposed mid-wait.
try {
this.terminal?._core?._charSizeService?.measure();
} catch {
/* renderer not ready or internals moved — the next real resize re-measures */
}
},
/**
* Get terminal dimensions with minimum enforcement.
* Prevents extremely narrow terminals that cause vertical text wrapping.
@@ -4827,6 +4978,17 @@ Object.assign(CodemanApp.prototype, {
// Fit terminal to container before reading dimensions — ensures local
// terminal size matches what we report to the server PTY.
if (this.fitAddon) this.fitAddon.fit();
// One PTY cannot hold two sizes. A detached session is owned by its own
// window, and the dashboard's terminal is narrower than that window because
// the session rail takes width the popup does not have — so both sizing it
// makes the CLI draw frames that fit neither, which garbles the popup. The
// dashboard yields; the solo window sizes what it alone displays.
// (_maybeRefetchFullHistory already stands aside for the same reason.)
// ⚠️ AFTER the fit, never before: the local reflow keeps the dashboard's own
// xterm right, and only the SERVER write is the dashboard's to withhold —
// the mobile-keyboard guard below draws exactly this line. tab-rail-resize
// performs its one settle-time refit through this call and has no fallback.
if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false;
const dims = this.getTerminalDimensions();
if (!dims) return false;
// Did the dimensions actually change since the last resize we sent? Callers
+199 -9
View File
@@ -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';
+27
View File
@@ -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);
}
+16 -41
View File
@@ -50,6 +50,7 @@ import {
} from '../route-helpers.js';
import type { FastifyRequest } from 'fastify';
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
import { downloadTooLargeMessage, exceedsDownloadLimit } from '../../config/buffer-limits.js';
import { parseByteRange } from '../http-range.js';
import { isSensitivePath } from '../sensitive-path.js';
import { SseEvent } from '../sse-events.js';
@@ -183,16 +184,8 @@ async function serveRawFile(
rangeHeader?: string | string[]
): Promise<void> {
const stat = await fs.stat(resolvedPath);
const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download
if (stat.size > MAX_RAW_ATTACHMENT_SIZE) {
reply
.code(413)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_ATTACHMENT_SIZE / 1024 / 1024}MB limit)`
)
);
if (exceedsDownloadLimit(stat.size)) {
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(stat.size)));
return;
}
// Markup is download-only: served with a renderable type on our own origin it
@@ -1562,18 +1555,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const { resolvedPath } = validated;
try {
// Validate file size before reading (DoS protection - prevent memory exhaustion)
const MAX_RAW_FILE_SIZE = 50 * 1024 * 1024; // 50MB for raw files
// Sanity bound only: the body below is streamed and Range-aware, so size
// does not translate into resident memory. Configurable, 0 = unlimited.
const stat = await fs.stat(resolvedPath);
if (stat.size > MAX_RAW_FILE_SIZE) {
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_FILE_SIZE / 1024 / 1024}MB limit)`
)
);
if (exceedsDownloadLimit(stat.size)) {
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(stat.size)));
return;
}
@@ -1954,17 +1940,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
return;
}
// 50MB size limit
const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024;
if (stat.size > MAX_DOWNLOAD_SIZE) {
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > 50MB limit)`
)
);
if (exceedsDownloadLimit(stat.size)) {
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(stat.size)));
return;
}
@@ -1988,15 +1965,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
};
const filename = pathBasename(resolvedPath);
const content = await fs.readFile(resolvedPath);
// Bypass Fastify compression — write directly to raw response
reply.raw.writeHead(200, {
...inheritedHeaders(reply),
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': content.length,
});
reply.raw.end(content);
// Streamed rather than read into memory, and Range-aware, so a multi-GB
// artifact costs one read stream and can be resumed. sendFileBody()
// hijacks the reply, which also keeps Fastify's compression out of it.
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
reply.header('Content-Disposition', buildContentDisposition('attachment', filename));
reply.header('X-Content-Type-Options', 'nosniff');
sendFileBody(reply, resolvedPath, stat.size, req.headers.range);
return;
} catch (err) {
reply
+40 -6
View File
@@ -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: '' };
}
});
@@ -2603,6 +2616,12 @@ export function registerSessionRoutes(
? 'mux-full-history'
: 'mux-visible'
: 'history';
// What the three row-preserving skips below must key on. `isFullReload` is
// only what the CLIENT ASKED FOR: when the capture comes back null — ENOBUFS,
// a timeout, a vanished pane, or a session with no mux at all — rawBuffer
// falls back to the byte history, which is a stream of successive frames with
// no row alignment to protect and every reason to be stripped.
const isFullCapture = isFullReload && hasLiveMuxBuffer;
let rawBuffer: string;
if (liveMuxBuffer !== null && liveMuxBuffer.length > 0) {
// Full-history capture is the RENDERED form of everything already in the
@@ -2649,8 +2668,16 @@ export function registerSessionRoutes(
// During long thinking phases, Ink rewrites the same rows thousands of times
// (500KB+). Without stripping, tail mode returns only spinner frames and
// the terminal appears empty when switching tabs.
// A full reload's buffer IS the rendered pane, one line per screen row, and
// it ends with an absolute cursor move back to the pane's own position.
// Every transform below that can DELETE A LINE would shift the rows out from
// under that position, leaving the caret a row off — on the composer's
// border rather than its input line. Redraw-bloat stripping exists for a
// byte stream of successive frames; a capture holds no successive frames.
let strippedBuffer =
getCli(session.mode)?.capabilities.stripInkBloat === false ? rawBuffer : stripInkRedrawBloat(rawBuffer);
isFullCapture || getCli(session.mode)?.capabilities.stripInkBloat === false
? rawBuffer
: stripInkRedrawBloat(rawBuffer);
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
// streams. xterm.js obeys them by switching to its scrollback-less alt
@@ -2688,7 +2715,10 @@ export function registerSessionRoutes(
cleanBuffer = strippedBuffer;
// Find where Claude banner starts (has color codes before "Claude")
const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN);
// Skipped for a full reload: the banner sits at whatever row the pane has
// it, and cutting to it would drop the blank rows above and move every
// row up by that many.
const claudeMatch = isFullCapture ? null : cleanBuffer.match(CLAUDE_BANNER_PATTERN);
if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) {
let lineStart = claudeMatch.index;
while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') {
@@ -2699,7 +2729,11 @@ export function registerSessionRoutes(
}
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
// Leading whitespace goes too, except on a full reload where a leading
// blank line is the pane's own first row and dropping it shifts every row
// up by one.
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '');
if (!isFullCapture) cleanBuffer = cleanBuffer.replace(LEADING_WHITESPACE_PATTERN, '');
const finishedAt = performance.now();
reply.header(
+9
View File
@@ -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'":
+184
View File
@@ -0,0 +1,184 @@
/**
* @fileoverview A detached session's pane is sized by its own window, not by
* the dashboard.
*
* One PTY holds one size. When a session is popped out, the dashboard keeps it
* active and keeps measuring it, but the dashboard's terminal is narrower than
* the popup because the session rail takes width the popup does not have. Both
* windows sizing the same pane makes the CLI draw frames that fit neither, and
* the popup shows the result as a garbled frame.
*
* `sendResize` therefore returns early for a session this window has marked
* detached, and the debounced window-resize handler skips it for the same
* reason. A solo window is exempt: it IS the owner. `_maybeRefetchFullHistory`
* already stood aside on the same condition, so this follows a rule the code
* had already established.
*
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
* box; see connection-indicator.test.ts), the same way terminal-buffer-flush
* extracts the real mixin methods from terminal-ui.js.
*/
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';
/** The mixin runs inside the vm context, so its `fetch` must live there too. */
let currentFetch: ReturnType<typeof vi.fn> = vi.fn();
function loadTerminalMixin(): Record<string, unknown> {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
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(), innerWidth: 1600 },
document: { addEventListener: vi.fn() },
fetch: (...args: unknown[]) => currentFetch(...args),
});
vm.runInContext(source, context);
return FakeCodemanApp.prototype;
}
const mixin = loadTerminalMixin();
const SESSION = 'session-A';
function makeApp(overrides: Record<string, unknown> = {}) {
const fetchMock = vi.fn(async () => ({ json: async () => ({ data: { changed: true } }) }));
currentFetch = fetchMock;
const app = {
sendResize: mixin.sendResize,
getTerminalDimensions: () => ({ cols: 120, rows: 40 }),
fitAddon: { fit: vi.fn() },
detachedSessions: new Set<string>(),
isSoloWindow: false,
_lastResizeDims: null as { cols: number; rows: number } | null,
_wsReady: false,
_wsSessionId: null as string | null,
...overrides,
} as Record<string, unknown> & { sendResize: (id: string, o?: object) => Promise<boolean> };
return { app, fetchMock };
}
describe('detached sessions own their pane size', () => {
it('the dashboard does not resize a session showing in its own window', async () => {
const { app, fetchMock } = makeApp();
(app.detachedSessions as Set<string>).add(SESSION);
const changed = await app.sendResize(SESSION);
expect(changed).toBe(false);
// No request: the popup's size stands on the server.
expect(fetchMock).not.toHaveBeenCalled();
// The LOCAL fit still runs, so the dashboard's own xterm stays correct and
// tab-rail-resize's single settle-time refit is not swallowed. Same line the
// mobile-keyboard guard draws: withhold the send, never the reflow.
expect((app.fitAddon as { fit: ReturnType<typeof vi.fn> }).fit).toHaveBeenCalled();
});
it('the solo window still sizes the session it displays', async () => {
const { app, fetchMock } = makeApp({ isSoloWindow: true });
(app.detachedSessions as Set<string>).add(SESSION);
await app.sendResize(SESSION);
// The popup is the owner, so being marked detached must not stop it.
expect(fetchMock).toHaveBeenCalledTimes(1);
expect((app.fitAddon as { fit: ReturnType<typeof vi.fn> }).fit).toHaveBeenCalled();
});
it('the dashboard resizes a session that is not detached', async () => {
const { app, fetchMock } = makeApp();
await app.sendResize(SESSION);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
/**
* `_redock` lives on the CodemanApp class rather than the terminal mixin, so it
* needs app.js loaded. Same `vm` approach as terminal-flush-budget.test.ts.
*/
function loadAppClass() {
const dir = resolve(import.meta.dirname, '../src/web/public');
const context = vm.createContext({
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
performance: { now: () => 0 },
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 },
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 },
});
const constants = readFileSync(resolve(dir, 'constants.js'), 'utf8');
const appSource = readFileSync(resolve(dir, 'app.js'), 'utf8');
vm.runInContext(`${constants}\n${appSource}\nglobalThis.__CodemanApp = CodemanApp;`, context);
return (context as { __CodemanApp: { prototype: Record<string, unknown> } }).__CodemanApp;
}
describe('redock takes the sizing back', () => {
const CodemanApp = loadAppClass();
function makeDashboard(activeSessionId: string | null) {
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
app.detachedSessions = new Set([SESSION]);
app.detachedWindows = new Map();
app._detachWatchTimers = new Map();
app._redockGrace = new Map();
app._detachOrphanStrikes = new Map();
app.sessions = new Map([[SESSION, { id: SESSION }]]);
app.activeSessionId = activeSessionId;
app._lastResizeDims = { cols: 120, rows: 40 };
app.$ = () => null;
app.sendResize = vi.fn(() => Promise.resolve(true));
return app;
}
it('clears the stale dimensions so the next send reports truthfully', () => {
// The popup sized the pane while it owned the session, so this window's one
// global record of "what the PTY holds" is wrong. Left in place, the next
// sendResize returns "unchanged" and selectSession skips its redraw wait.
const app = makeDashboard(SESSION);
app._redock(SESSION);
expect(app._lastResizeDims).toBeNull();
});
it('clears them even when the redocked session is not the active one', () => {
// Pop out A, switch to B, close the popup: no resize is due, but the stale
// record still has to go or selecting A later lies about it.
const app = makeDashboard('some-other-session');
app._redock(SESSION);
expect(app._lastResizeDims).toBeNull();
expect(app.sendResize).not.toHaveBeenCalled();
});
it('re-asserts this window size for the session it is showing', () => {
const app = makeDashboard(SESSION);
app._redock(SESSION);
expect(app.sendResize).toHaveBeenCalledWith(SESSION, { force: true });
// Un-marked first, or the yield in sendResize would swallow the re-assert.
expect(app.detachedSessions.has(SESSION)).toBe(false);
});
it('sends nothing for a session that is gone', () => {
// _onSessionDeleted redocks before cleanup, so the id can already be dead;
// the resize would be a guaranteed 404.
const app = makeDashboard(SESSION);
app.sessions.delete(SESSION);
app._redock(SESSION);
expect(app.sendResize).not.toHaveBeenCalled();
});
});
+174
View File
@@ -0,0 +1,174 @@
/**
* @fileoverview Static guards for the entrance-animation styles (App Settings →
* Appearance → Entrance Animations, plus the `?animlab=1` picker).
*
* A style is FOUR things that have to line up, and any one of them missing fails
* silently rather than loudly: the entry in the style array in
* entrance-animations.js (which is what the lab lists and what `_styleDuration`
* reads), the `html[data-*-anim="<key>"]` rule in styles.css, the @keyframes
* block that rule names, and — for a style that belongs to a theme — the theme's
* `<option>` in index.html. A style with no CSS behind it renders as "the
* animation silently does nothing"; a rule naming a keyframe block that does not
* exist behaves the same way.
*
* The terminal pane carries an extra rule of its own, and it is the one with
* teeth: xterm's FitAddon derives rows+cols from getComputedStyle(parent)
* .width/height, so a terminal keyframe that animates a box-model property would
* resize the PTY mid-animation. Only paint-level properties are allowed there.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const animSource = readFileSync(resolve('src/web/public/entrance-animations.js'), 'utf8');
const stylesSource = readFileSync(resolve('src/web/public/styles.css'), 'utf8');
const indexSource = readFileSync(resolve('src/web/public/index.html'), 'utf8');
/** Surfaces, keyed by the `data-*-anim` attribute their styles are selected by. */
const SURFACES = [
{ attr: 'tab', array: 'TAB_ANIM_STYLES', selector: '.session-tab.tab-enter' },
{ attr: 'win', array: 'WIN_ANIM_STYLES', selector: '.subagent-window.win-enter' },
{ attr: 'line', array: 'LINE_ANIM_STYLES', selector: '.connection-line.line-enter' },
{ attr: 'term', array: 'TERM_ANIM_STYLES', selector: '.terminal-container.term-enter' },
] as const;
/**
* Styles with no CSS of their own, by design: `off` means "do nothing" and `fly`
* is the pre-existing JS transition in subagent-windows.js, which deliberately
* skips the `win-enter` class entirely.
*/
const CSS_LESS_STYLES = new Set(['off', 'fly']);
function styleKeys(arrayName: string): string[] {
const start = animSource.indexOf(`const ${arrayName} = [`);
expect(start, `${arrayName} not found`).toBeGreaterThan(-1);
const body = animSource.slice(start, animSource.indexOf('];', start));
return [...body.matchAll(/\{ key: '([^']+)'/g)].map((m) => m[1]);
}
function themes(): { key: string; tab: string; win: string; line: string; term: string }[] {
const start = animSource.indexOf('const ANIM_THEMES = [');
const body = animSource.slice(start, animSource.indexOf('];', start));
return [
...body.matchAll(/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)' \}/g),
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5] }));
}
/**
* Every `animation-name:` a `html[data-<attr>-anim="<key>"]` block asks for,
* tagged with whether it runs on the element itself or on its ::before overlay.
* The distinction matters for the terminal: the FitAddon rule below binds to the
* container, while ::before is a throwaway wash that may animate anything.
*/
function animationNamesFor(attr: string, key: string): { name: string; onPseudo: boolean }[] {
const rules = [...stylesSource.matchAll(new RegExp(`html\\[data-${attr}-anim="${key}"\\]([^{]*)\\{([^}]*)\\}`, 'g'))];
return rules.flatMap((rule) =>
[...rule[2].matchAll(/animation-name:\s*([\w-]+);/g)].map((m) => ({
name: m[1],
onPseudo: rule[1].includes('::before'),
}))
);
}
function keyframeBody(name: string): string | null {
const start = stylesSource.indexOf(`@keyframes ${name} {`);
if (start === -1) return null;
return stylesSource.slice(start, stylesSource.indexOf('\n}', start));
}
describe('entrance animation styles', () => {
for (const surface of SURFACES) {
describe(`${surface.attr} surface`, () => {
it('backs every style with a rule that names a keyframe block that exists', () => {
for (const key of styleKeys(surface.array)) {
if (CSS_LESS_STYLES.has(key)) {
expect(stylesSource).not.toContain(`html[data-${surface.attr}-anim="${key}"]`);
continue;
}
const names = animationNamesFor(surface.attr, key);
expect(names.length, `no animation-name for ${surface.attr}/${key}`).toBeGreaterThan(0);
for (const { name } of names) {
expect(keyframeBody(name), `@keyframes ${name} missing`).not.toBeNull();
}
// The style has to reach the element the surface actually animates,
// not just any selector carrying the attribute.
expect(stylesSource).toContain(`html[data-${surface.attr}-anim="${key}"] ${surface.selector}`);
}
});
});
}
it('ships the blur style on all four surfaces', () => {
for (const surface of SURFACES) expect(styleKeys(surface.array)).toContain('blur');
});
it('gives every theme an <option> and only styles that exist', () => {
for (const theme of themes()) {
expect(indexSource, `no <option value="${theme.key}">`).toContain(`<option value="${theme.key}">`);
for (const surface of SURFACES) {
expect(styleKeys(surface.array), `theme ${theme.key} names an unknown ${surface.attr} style`).toContain(
theme[surface.attr]
);
}
}
// 'custom' is a readout of a lab mix, never a theme you can select into.
expect(indexSource).toContain('<option value="custom">');
expect(themes().map((t) => t.key)).not.toContain('custom');
});
it('keeps every entrance under the reduced-motion kill switch', () => {
const start = stylesSource.indexOf('@media (prefers-reduced-motion: reduce) {\n .session-tab.tab-enter,');
expect(start, 'the entrance reduced-motion block moved or was renamed').toBeGreaterThan(-1);
const block = stylesSource.slice(
start,
stylesSource.indexOf('\n}', stylesSource.indexOf('animation: none', start))
);
for (const surface of SURFACES) expect(block).toContain(surface.selector);
});
/**
* ⚠ The FitAddon rule. It reads getComputedStyle(parent).width/height, i.e. the
* untransformed LAYOUT box, so paint-level properties are invisible to it and a
* box-model property here would resize the PTY mid-animation.
*/
it('animates only paint-level properties on the terminal pane', () => {
const allowed = new Set(['opacity', 'transform', 'clip-path', 'filter']);
for (const key of styleKeys('TERM_ANIM_STYLES')) {
if (CSS_LESS_STYLES.has(key)) continue;
for (const { name, onPseudo } of animationNamesFor('term', key)) {
if (onPseudo) continue; // a wash over the pane, it has no layout of its own
const body = keyframeBody(name);
expect(body).not.toBeNull();
for (const [, prop] of (body as string).matchAll(/(?:\{|;)\s*([a-z-]+):/g)) {
expect(allowed.has(prop), `@keyframes ${name} animates ${prop} on the terminal pane`).toBe(true);
}
}
}
});
/**
* The `blur` line entrance animates `filter`, and a keyframe listing only the
* blur would drop each line's own glow for the length of the run and pop it
* back at the end. Both frames say `blur(N) var(--line-glow)` so the function
* lists match and interpolate, which only works while both kinds of line
* actually define that variable.
*/
it('routes both kinds of connection line through --line-glow', () => {
for (const selector of ['.connection-line {', '.connection-line.lineage-line {']) {
const start = stylesSource.indexOf(selector);
expect(start, `${selector} not found`).toBeGreaterThan(-1);
const block = stylesSource.slice(start, stylesSource.indexOf('\n}', start));
expect(block, `${selector} must define --line-glow`).toContain('--line-glow:');
expect(block, `${selector} must apply it`).toContain('filter: var(--line-glow);');
}
const blur = keyframeBody('line-enter-blur') as string;
expect(blur).not.toBeNull();
expect(blur.match(/var\(--line-glow\)/g)?.length).toBe(2);
// The 100% frame deliberately omits opacity so the endpoint comes from the
// element's own resting value: 0.9 on a subagent line, 0.72 on a lineage
// line, 0.95 on a working one. Pinning a number here snaps three of them.
expect(blur).toMatch(/100%\s*\{\s*filter:[^}]*\}/);
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
});
});
+176
View File
@@ -0,0 +1,176 @@
/**
* @fileoverview Unit tests for the Ctrl+V paste trap in image-input.js.
*
* `_handleImagePaste()` appends a hidden contenteditable div (the "paste
* trap"), focuses it, and reads the clipboard out of the paste event the
* browser delivers there. Two things can deliver that event for a single
* Ctrl+V: the `document.execCommand('paste')` the function issues itself, and
* the keydown's own default action, which still runs because xterm's custom key
* handler returns false without cancelling the event. A browser that honours
* execCommand('paste') therefore fires the trap's listener twice, and the
* clipboard text used to reach the PTY twice with it — while right-click →
* Paste, which involves no keydown, stayed correct.
*
* Loads the browser module into a vm sandbox with a fake document, so the tests
* drive the trap's listener directly rather than through a real browser.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
interface TrapListener {
(e: Record<string, unknown>): void;
}
interface FakeTrap {
contentEditable: string;
style: { cssText: string };
parentNode: unknown;
focus: () => void;
addEventListener: (ev: string, fn: TrapListener) => void;
}
interface Harness {
/** Fire a paste event on the trap the last _handleImagePaste() call created. */
firePaste: (payload: { text?: string; images?: string[] }) => void;
/** Text handed to xterm's terminal.paste(), one entry per call. */
pastedText: string[];
/** Image batches handed to _uploadAndInsertImages(), one entry per call. */
uploadedBatches: Array<Array<{ type: string }>>;
/** How many trap divs are still attached to the fake body. */
attachedTraps: () => number;
runTimers: () => void;
}
function loadPasteHarness(): Harness {
const traps: FakeTrap[] = [];
const listeners: TrapListener[] = [];
const attached = new Set<FakeTrap>();
const timers: Array<() => void> = [];
const documentObj = {
createElement: (): FakeTrap => {
const trap: FakeTrap = {
contentEditable: '',
style: { cssText: '' },
parentNode: null,
focus: () => {},
addEventListener: (ev: string, fn: TrapListener) => {
if (ev === 'paste') listeners.push(fn);
},
};
traps.push(trap);
return trap;
},
body: {
appendChild: (el: FakeTrap) => {
attached.add(el);
el.parentNode = documentObj.body;
},
removeChild: (el: FakeTrap) => {
attached.delete(el);
el.parentNode = null;
},
},
// A browser that honours the command fires the trap's paste listener from
// here as well; the tests model that by firing the listener twice.
execCommand: () => true,
getElementById: () => null,
};
const context = vm.createContext({
window: {},
document: documentObj,
setTimeout: (fn: () => void) => {
timers.push(fn);
return timers.length;
},
clearTimeout: () => {},
console,
});
vm.runInContext('class CodemanApp {}', context);
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/image-input.js'), 'utf8');
vm.runInContext(src, context, { filename: 'image-input.js' });
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
const pastedText: string[] = [];
const uploadedBatches: Array<Array<{ type: string }>> = [];
const app = new CodemanApp();
app.activeSessionId = 'session-1';
app.terminal = {
paste: (text: string) => pastedText.push(text),
focus: () => {},
};
app._uploadAndInsertImages = (files: Array<{ type: string }>) => {
uploadedBatches.push(Array.from(files));
};
app.showToast = () => {};
(app._handleImagePaste as () => void).call(app);
return {
firePaste({ text = '', images = [] }) {
const items = images.map((type) => ({ type, getAsFile: () => ({ type }) }));
const event = {
clipboardData: {
items,
getData: () => text,
},
preventDefault: () => {},
stopPropagation: () => {},
};
for (const fn of listeners) fn(event);
},
pastedText,
uploadedBatches,
attachedTraps: () => attached.size,
runTimers: () => {
const pending = timers.splice(0, timers.length);
for (const fn of pending) fn();
},
};
}
describe('Ctrl+V paste trap', () => {
it('sends clipboard text to the terminal once for a single paste event', () => {
const h = loadPasteHarness();
h.firePaste({ text: 'hello world' });
expect(h.pastedText).toEqual(['hello world']);
});
it('ignores a second paste event for the same Ctrl+V', () => {
const h = loadPasteHarness();
// execCommand('paste') and the uncancelled keydown's default action both
// land on the same trap in browsers that honour the command.
h.firePaste({ text: 'hello world' });
h.firePaste({ text: 'hello world' });
expect(h.pastedText).toEqual(['hello world']);
});
it('uploads a pasted image once when the trap sees two paste events', () => {
const h = loadPasteHarness();
h.firePaste({ images: ['image/png'] });
h.firePaste({ images: ['image/png'] });
expect(h.uploadedBatches).toHaveLength(1);
expect(h.uploadedBatches[0]).toEqual([{ type: 'image/png' }]);
expect(h.pastedText).toEqual([]);
});
it('removes the trap and hands focus back after the paste it accepted', () => {
const h = loadPasteHarness();
h.firePaste({ text: 'hello world' });
expect(h.attachedTraps()).toBe(1);
h.runTimers();
expect(h.attachedTraps()).toBe(0);
});
});
+88 -1
View File
@@ -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();
});
});
+41 -1
View File
@@ -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}
+33 -1
View File
@@ -20,7 +20,7 @@ import {
} from '../scripts/pr-bot/report.js';
import { classifyCi, latestRunPerWorkflow, type PrSummary, type WorkflowRun } from '../scripts/pr-bot/github.js';
import { parseCallback, parseCommand, prNumberFromMessageText } from '../scripts/pr-bot/telegram.js';
import { trustDialogKey } from '../scripts/pr-bot/codeman-client.js';
import { findModelLimitNotice, trustDialogKey } from '../scripts/pr-bot/codeman-client.js';
import { buildConfig, parseEnvFile } from '../scripts/pr-bot/config.js';
import { buildReviewBrief } from '../scripts/pr-bot/review-task.js';
@@ -302,6 +302,38 @@ describe('trustDialogKey', () => {
});
});
describe('findModelLimitNotice', () => {
// Captured off prbot-394's pane on 2026-09-08, the run that lost 40 minutes: Claude
// Code answers a spent budget inside the turn and then simply sits there.
const SPENT_PANE = [
'\x1b[38;5;153m\u276f\x1b[39m Read /home/arkon/.codeman/pr-bot/jobs/pr-394/brief.md and do the review.',
" \u23bf You've reached your Fable limit. Run /usage-credits to continue or switch models with /model.",
'\u273b Saut\u00e9ed for 1s \u00b7 done 7:16 PM',
].join('\n');
it('finds the notice on a real pane, ANSI and gutter glyph stripped', () => {
expect(findModelLimitNotice(SPENT_PANE)).toBe(
"You've reached your Fable limit. Run /usage-credits to continue or switch models with /model."
);
});
it('is not tied to one model name or to a straight apostrophe', () => {
// The pane renders a typographic apostrophe, and every model prints this sentence.
expect(
findModelLimitNotice(' \u23bf You\u2019ve reached your Opus limit. Run /usage-credits to continue.')
).toContain('reached your Opus limit');
expect(findModelLimitNotice('You have reached your Sonnet 5 limit.')).toContain('Sonnet 5');
});
it('says nothing about an ordinary working pane', () => {
expect(findModelLimitNotice('\u273b Actualizing\u2026 (13m 23s \u00b7 esc to interrupt)')).toBeUndefined();
expect(findModelLimitNotice('')).toBeUndefined();
// The bare word is not the notice: a review whose own findings discuss usage limits
// must not be reported as an exhausted account.
expect(findModelLimitNotice('the usage limit parser handles the 5-hour reset')).toBeUndefined();
});
});
describe('config', () => {
it('parses env files with quotes, comments and export prefixes', () => {
const env = parseEnvFile('# c\nexport A="x y"\nB=\'z\'\nC=plain\nbad line\n=nokey\n');
+25
View File
@@ -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')
+218
View File
@@ -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');
});
});
+16 -2
View File
@@ -191,7 +191,9 @@ describe('file-raw range requests', () => {
});
it('still refuses files past the raw size cap before looking at Range', async () => {
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024, isFile: () => true } as never);
// 3GB, past the 2GB CODEMAN_MAX_DOWNLOAD_BYTES default. The cap is checked
// before the range, so a small slice of an oversized file is refused too.
mockedStat.mockResolvedValue({ size: 3 * 1024 * 1024 * 1024, isFile: () => true } as never);
const res = await harness.app.inject({
method: 'GET',
@@ -199,6 +201,18 @@ describe('file-raw range requests', () => {
headers: { range: 'bytes=0-99' },
});
expect(res.statusCode).toBe(400);
expect(res.statusCode).toBe(413);
});
it('serves a 100MB file that the historical 50MB cap would have refused', async () => {
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024, isFile: () => true } as never);
const res = await harness.app.inject({
method: 'GET',
url: rawUrl('big.mp4'),
headers: { range: 'bytes=0-99' },
});
expect(res.statusCode).toBe(206);
});
});
+32 -6
View File
@@ -802,14 +802,27 @@ describe('file-routes', () => {
expect(res.statusCode).toBe(404);
});
it('rejects overly large raw files', async () => {
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024 } as never); // 100MB
it('serves a file past the historical 50MB cap', async () => {
// The body is streamed and Range-aware, so size costs a read stream, not
// RSS. The old 50MB refusal only blocked legitimate artifact downloads.
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024, isFile: () => true } as never); // 100MB
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=huge.bin`,
});
expect(res.statusCode).toBe(400);
expect(res.statusCode).toBe(200);
});
it('still refuses a file past the configured download cap', async () => {
mockedStat.mockResolvedValue({ size: 3 * 1024 * 1024 * 1024, isFile: () => true } as never); // 3GB > 2GB default
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=enormous.bin`,
});
expect(res.statusCode).toBe(413);
expect(JSON.parse(res.body).error).toContain('CODEMAN_MAX_DOWNLOAD_BYTES');
});
});
@@ -863,8 +876,9 @@ describe('file-routes', () => {
});
it('downloads files scoped to the session working directory', async () => {
const content = Buffer.from('download content');
mockedReadFile.mockResolvedValue(content as never);
// The body is streamed (shared sendFileBody path), so the bytes come from
// the createReadStream mock rather than from readFile.
const content = Buffer.from('fake file bytes');
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true } as never);
const res = await harness.app.inject({
@@ -874,7 +888,19 @@ describe('file-routes', () => {
expect(res.statusCode).toBe(200);
expect(res.headers['content-disposition']).toContain('filename="report.txt"');
expect(res.body).toBe('download content');
expect(res.headers['accept-ranges']).toBe('bytes');
expect(res.body).toBe('fake file bytes');
});
it('refuses a download past the configured cap', async () => {
mockedStat.mockResolvedValue({ size: 3 * 1024 * 1024 * 1024, isFile: () => true } as never); // 3GB > 2GB default
const res = await harness.app.inject({
method: 'GET',
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=enormous.bin`,
});
expect(res.statusCode).toBe(413);
});
it('rejects absolute paths outside the session working directory', async () => {
@@ -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
+79
View File
@@ -851,6 +851,85 @@ describe('session-routes', () => {
);
});
it('full reload (?full=1) keeps every leading row so the restored cursor lands on the right line', async () => {
// The capture ends with an absolute cursor move, so its rows and the pane's
// rows must line up one for one. Three transforms used to run over it and
// each could delete a leading line: redraw-bloat stripping, the trim that
// cuts everything above the Claude banner, and a leading-whitespace strip.
// Any one of them shifted the frame up and left the caret a row off.
harness.ctx._session.mode = 'claude';
harness.ctx._session.terminalBuffer = '';
// A blank first row, then the banner — the shape a real pane has.
const rendered = ['', '\x1b[1mClaude Code v2.1.266', 'conversation', '\u276f ', '\x1b[4;3H'].join('\r\n');
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(_name: string, opts?: { fullHistory?: boolean }) => (opts?.fullHistory ? rendered : 'visible frame')
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.source).toBe('mux-full-history');
// The blank first row survives, so row N of the reply is row N of the pane.
expect(body.data.terminalBuffer.startsWith('\r\n')).toBe(true);
expect(body.data.terminalBuffer.split('\r\n')).toHaveLength(rendered.split('\r\n').length);
});
it('full reload (?full=1) still strips the byte history when no capture came back', async () => {
// The row-preserving skips exist for a rendered pane. When the capture is
// unavailable the reply IS the byte stream — successive Ink frames, no row
// alignment to protect — so keying the skips on the query parameter rather
// than on the capture returned it unstripped, which is the whole reason
// stripInkRedrawBloat exists. A session with no mux takes this path on
// every first selection, not just during an outage.
harness.ctx._session.mode = 'claude';
// A VPA cluster the stripper will collapse: >= 10 sequences, spanning the
// 32KB minimum, with real content after it.
const frame = '\x1b[12d' + 'spinner frame '.repeat(240);
harness.ctx._session.terminalBuffer = frame.repeat(20) + 'REAL CONTENT AFTER THE BLOAT';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(() => null);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.source).toBe('history');
expect(body.data.terminalBuffer).toContain('REAL CONTENT AFTER THE BLOAT');
// Stripped, not passed through whole.
expect(body.data.terminalBuffer.length).toBeLessThan(harness.ctx._session.terminalBuffer.length);
});
it('full reload (?full=1) keeps the byte history when the capture is empty', async () => {
// Pins the contract the capture side relies on: an empty capture means
// "nothing to replay" and the byte history survives. capturePaneBuffer
// returns '' for an all-blank pane precisely to reach this branch, since
// retaining trailing blank rows and appending a cursor move would
// otherwise make a blank screen non-empty and replace the history with it.
// (The blank-pane decision itself is unit-tested on hasVisibleContent —
// capturePaneBuffer short-circuits under IS_TEST_MODE and cannot run here.)
harness.ctx._session.mode = 'claude';
harness.ctx._session.terminalBuffer = 'a real conversation worth keeping';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(_name: string, opts?: { fullHistory?: boolean }) => (opts?.fullHistory ? '' : 'visible frame')
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.source).toBe('history');
expect(body.data.terminalBuffer).toContain('a real conversation worth keeping');
});
it('full reload (?full=1) falls back to the byte history when the capture is unavailable', async () => {
harness.ctx._session.mode = 'claude';
harness.ctx._session.terminalBuffer = 'byte history survives';
+333
View File
@@ -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] {"
);
});
});
+210
View File
@@ -0,0 +1,210 @@
/**
* @fileoverview A terminal is measured only once its own font can be measured.
*
* The first fit runs while the browser is still painting with a fallback font,
* whose character cell is a different size from the terminal font's. The grid
* that fit produces is therefore wrong, the pane is sized to it, and the
* correction arrives after the session's buffer has been replayed — so the CLI
* repaints for a shape that does not match what is on screen.
*
* Two properties carry the fix and both are pinned here:
*
* - The wait REQUESTS each measurable face and then forces xterm to re-measure.
* Waiting alone buys nothing: `FitAddon.proposeDimensions()` divides by a
* cached cell size that xterm refreshes only from `open()`, from a resize
* that changed the grid, and on a device-pixel-ratio change. Nothing in it
* listens for font loading, so a fit after the font arrives can still divide
* by the fallback cell and short-circuit.
* - The wait is BOUNDED. `FontFaceSet.ready` has no deadline, and `selectSession`
* awaits this before painting, so an unbounded wait would strand the session
* instead of merely mis-measuring it.
*
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
* box; see connection-indicator.test.ts), the same way terminal-buffer-flush
* extracts the real mixin methods from terminal-ui.js.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/** The mixin runs inside the vm context, so its `document` must live there. */
let currentDocument: unknown;
function loadTerminalMixin(): Record<string, unknown> {
const dir = resolve(import.meta.dirname, '../src/web/public');
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() },
get document() {
return currentDocument;
},
});
// constants.js supplies TERMINAL_FONT_WAIT_MS and TERMINAL_FONT_UNMEASURED.
const constants = readFileSync(resolve(dir, 'constants.js'), 'utf8');
const source = readFileSync(resolve(dir, 'terminal-ui.js'), 'utf8');
vm.runInContext(`${constants}\n${source}`, context);
return FakeCodemanApp.prototype;
}
const mixin = loadTerminalMixin();
type FontApp = {
_awaitTerminalFont: () => Promise<void>;
terminal: unknown;
};
function makeApp(fontFamily: string, opts: { measure?: () => void } = {}) {
const measure = vi.fn(opts.measure);
const app = {
_awaitTerminalFont: mixin._awaitTerminalFont,
terminal: {
options: { fontFamily, fontSize: 14 },
_core: { _charSizeService: { measure } },
},
} as unknown as FontApp & { terminal: { _core: { _charSizeService: { measure: typeof measure } } } };
return { app, measure };
}
/** A FontFaceSet stub recording what was asked for. */
function fontsStub(overrides: { load?: unknown; ready?: Promise<unknown> } = {}) {
const requested: string[] = [];
return {
requested,
fonts: {
load: overrides.load ?? ((spec: string) => (requested.push(spec), Promise.resolve([]))),
ready: overrides.ready ?? Promise.resolve(),
status: 'loaded',
},
};
}
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
currentDocument = undefined;
vi.useRealTimers();
});
describe('terminal font settle', () => {
it('requests every measurable family in the stack, unquoted', async () => {
const stub = fontsStub();
currentDocument = stub;
const { app } = makeApp('"Fira Code", "JetBrains Mono", monospace');
await app._awaitTerminalFont();
expect(stub.requested).toEqual(['14px "Fira Code"', '14px "JetBrains Mono"']);
});
it('does not wait on faces that cannot move the measured cell', async () => {
// The symbols face is ~1.2MB of private-use-area glyphs and xterm measures
// `W`, so awaiting it puts a megabyte in front of the first frame for
// nothing. The generics match no FontFace at all.
const stub = fontsStub();
currentDocument = stub;
const { app } = makeApp('"JetBrains Mono", "Symbols Nerd Font Mono", monospace, serif, system-ui');
await app._awaitTerminalFont();
expect(stub.requested).toEqual(['14px "JetBrains Mono"']);
});
it('forces xterm to re-measure, because loading a font does not', async () => {
// The property the whole change rests on. Without this the fit that follows
// still divides the container by the fallback cell.
currentDocument = fontsStub();
const { app, measure } = makeApp('"JetBrains Mono"');
await app._awaitTerminalFont();
expect(measure).toHaveBeenCalledTimes(1);
});
it('gives up on a font that never arrives, and still re-measures', async () => {
// FontFaceSet.ready has no deadline of its own, and selectSession awaits
// this before painting: unbounded here means a session that never renders.
currentDocument = fontsStub({ ready: new Promise(() => {}) });
const { app, measure } = makeApp('"JetBrains Mono"');
const started = Date.now();
await app._awaitTerminalFont();
expect(measure).toHaveBeenCalledTimes(1);
// Bounded by TERMINAL_FONT_WAIT_MS (2s), not left pending.
expect(Date.now() - started).toBeLessThan(4000);
}, 10_000);
it('survives a rejecting load and a browser with no font API', async () => {
currentDocument = fontsStub({ load: () => Promise.reject(new Error('network')) });
const { app: rejecting, measure: m1 } = makeApp('"JetBrains Mono"');
await expect(rejecting._awaitTerminalFont()).resolves.toBeUndefined();
expect(m1).toHaveBeenCalledTimes(1);
currentDocument = {};
const { app: noApi, measure: m2 } = makeApp('"JetBrains Mono"');
await expect(noApi._awaitTerminalFont()).resolves.toBeUndefined();
// No font API means nothing to wait for and nothing to re-measure against.
expect(m2).not.toHaveBeenCalled();
});
it('does not throw when the terminal was disposed mid-wait', async () => {
currentDocument = fontsStub();
const app = { _awaitTerminalFont: mixin._awaitTerminalFont, terminal: null } as unknown as FontApp;
await expect(app._awaitTerminalFont()).resolves.toBeUndefined();
});
});
describe('selectSession font gate', () => {
const appSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const selectStart = appSource.indexOf('async selectSession(sessionId, options = {})');
const body = appSource.slice(
selectStart,
appSource.indexOf('\n // Shared cleanup for all session data', selectStart)
);
it('waits for the font before the first fit', () => {
const wait = body.indexOf('await this._terminalFontReady');
const fit = body.indexOf('if (this.fitAddon) this.fitAddon.fit();');
expect(wait).toBeGreaterThan(-1);
expect(fit).toBeGreaterThan(-1);
expect(wait).toBeLessThan(fit);
});
it('waits BEFORE opening the buffer-load gate', () => {
// Inside the gate every live SSE event queues instead of painting, so a slow
// font would hold output back rather than only mis-measuring the grid.
const wait = body.indexOf('await this._terminalFontReady');
const gate = body.indexOf('this._beginBufferLoad(selectGen)');
expect(gate).toBeGreaterThan(-1);
expect(wait).toBeLessThan(gate);
});
it('keeps the synchronous focus ahead of the wait (iOS Safari)', () => {
// iOS honours programmatic focus only inside the user-gesture call stack,
// which the first await ends.
const focus = body.indexOf('if (shouldFocusTerminal && this.terminal) this.terminal.focus();');
const wait = body.indexOf('await this._terminalFontReady');
expect(focus).toBeGreaterThan(-1);
expect(focus).toBeLessThan(wait);
});
it('re-checks for a newer selection after the wait', () => {
const wait = body.indexOf('await this._terminalFontReady');
const guard = body.indexOf('this._isStaleSelect(selectGen)', wait);
expect(guard).toBeGreaterThan(-1);
expect(guard - wait).toBeLessThan(200);
});
});
@@ -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([]);
});
});
+378
View File
@@ -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([]);
});
});
+68 -6
View File
@@ -11,11 +11,15 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { formatCursorRestore, hasVisibleContent } from '../src/tmux-manager.js';
describe('tmux full-history pane capture (COD-47)', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
const methodBody = source.slice(methodStart, methodStart + 4000);
// Bounded at the next method so `methodBody` really is one method: the
// ordering assertions below would otherwise be satisfiable by a neighbour.
const methodEnd = source.indexOf('captureActivePaneBuffer(muxName: string', methodStart);
const methodBody = source.slice(methodStart, methodEnd);
it('capturePaneBuffer accepts pane-capture options with a fullHistory flag', () => {
expect(methodStart).toBeGreaterThan(-1);
@@ -42,13 +46,37 @@ describe('tmux full-history pane capture (COD-47)', () => {
});
it('returns full-history capture as raw scrollback (skips the single-screen repaint)', () => {
// When fullHistory, return the raw buffer BEFORE the formatPaneSnapshot
// repaint (which is single-screen and would clip a multi-screen history).
const earlyReturn = methodBody.indexOf('return normalizeScrollbackEol(buffer);');
// The fullHistory branch returns before the formatPaneSnapshot repaint,
// which is single-screen and would clip a multi-screen history.
const branch = methodBody.indexOf('if (fullHistory) {\n // Without geometry');
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
expect(earlyReturn).toBeGreaterThan(-1);
expect(branch).toBeGreaterThan(-1);
expect(snapshot).toBeGreaterThan(-1);
expect(earlyReturn).toBeLessThan(snapshot);
expect(branch).toBeLessThan(snapshot);
// …and what it returns is normalized linear scrollback, not a repaint.
expect(methodBody.slice(branch, snapshot)).toContain('normalizeScrollbackEol(');
});
it('appends the pane cursor to the full-history capture', () => {
// A linear replay leaves the caret wherever the last character landed — the
// status line, for an agent CLI — and every cursor-relative update the CLI
// sends afterwards is then measured from the wrong row.
const restore = methodBody.indexOf('formatCursorRestore(geometry)');
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
expect(restore).toBeGreaterThan(-1);
expect(restore).toBeLessThan(snapshot);
});
it('keeps the trailing rows only when a cursor move will follow', () => {
// Trailing blank rows are the bottom of the screen and the cursor move counts
// up from them, so the two decisions travel together: no geometry, no move,
// and the old trim applies instead.
expect(methodBody).toContain("rawCapture.replace(/\\n$/, '')");
expect(methodBody).toContain("if (!geometry) return normalizeScrollbackEol(rawCapture.replace(/\\n+$/g, ''))");
});
it('defers to the byte history when the pane holds nothing visible', () => {
expect(methodBody).toContain("if (!hasVisibleContent(trimmed)) return ''");
});
it('captureActivePaneBuffer forwards the capture options', () => {
@@ -59,3 +87,37 @@ describe('tmux full-history pane capture (COD-47)', () => {
expect(body).toContain('this.capturePaneBuffer(muxName, target, opts)');
});
});
describe('full-history cursor restore', () => {
it('counts up from the last replayed row rather than down from the top', () => {
// Relative, not `CUP`: absolute row addressing is only correct while the
// browser's row count equals the pane's, and resizeWindow does not wait for
// tmux, so a capture can be taken before a requested resize has applied.
expect(formatCursorRestore({ cols: 80, rows: 24, cursorX: 2, cursorY: 20 })).toBe('\x1b[3A\r\x1b[2C');
});
it('emits no row move when the caret is already on the last row', () => {
expect(formatCursorRestore({ cols: 80, rows: 24, cursorX: 5, cursorY: 23 })).toBe('\r\x1b[5C');
});
it('emits no column move for column zero', () => {
expect(formatCursorRestore({ cols: 80, rows: 10, cursorX: 0, cursorY: 0 })).toBe('\x1b[9A\r');
});
});
describe('hasVisibleContent', () => {
it('is false for a pane of blank rows', () => {
expect(hasVisibleContent('\n'.repeat(23))).toBe(false);
});
it('is false for blank rows carrying only SGR attributes', () => {
// `capture-pane -e` styles every row, so an all-blank pane is not an empty
// string. Treating it as content would replace the byte history with a
// blank screen.
expect(hasVisibleContent('\x1b[m \x1b[0m\n\x1b[m \x1b[0m')).toBe(false);
});
it('is true as soon as one row carries a character', () => {
expect(hasVisibleContent('\x1b[m \x1b[0m\n\x1b[m x \x1b[0m')).toBe(true);
});
});
+37
View File
@@ -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', () => {
+317
View File
@@ -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);
});
});