mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
92921b9107908a99b447764842c7d162218972a8
12
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
00f022ccf8 |
fix(terminal): recover a dropped output frame, do not merely schedule it
`_onSessionTerminal` drops an incoming frame once the app-owned render queues already hold 128KB. That is the right call — the alternative is an unbounded backlog — but a hole in a TUI byte stream is a desynced cursor, and a desynced cursor is muffled text (#464). The drop was only half of it. The recovery was a fire-and-forget timer: it nulled its own handle and then called `_onSessionNeedsRefresh()`, which opens with four early returns. Two of them — a buffer load already in flight, a refresh already owning this session — are MOST likely to be true during exactly the output burst that caused the drop. So the recovery was skipped precisely when it was needed, with nothing left to retry it, and the dropped bytes were never replayed. `_onSessionNeedsRefresh` reports whether it actually repainted now, and `_scheduleDroppedOutputRecovery` re-arms while it has not. Bounded by `DROP_RECOVERY_MAX_ATTEMPTS`, because every reason the refresh can be skipped is transient contention that clears in seconds and a permanently failing refresh must not become a loop against the API; giving up at the cap leaves exactly what the old code left, so the floor is no worse than before. The same 2s debounce still collapses a burst of drops into one attempt. This is the principle Ark0N established reviewing #431 for the WebSocket output-gap marker — only a repaint that actually happened settles the recovery — applied to the one recovery path that still trusted a timer having fired. The retry decision is a pure function in constants.js so the gate can reach it, and the scheduler itself is driven from app.js under a fake clock. The retry case and the no-retry case only pin the fix AS A PAIR: either alone passes against something wrong, one against the old fire-and-forget timer and the other against retrying forever. Checked by reverting app.js to the old shape, where three of the twelve fail. Two harness details that would otherwise have made the tests lie. The vm context baked in the real `setTimeout`, so `vi.useFakeTimers()` could not reach the scheduler and every case reported zero calls; it delegates lazily now. And app.js reached `CodemanDroppedOutput` as a bare global, which resolves in a browser but not in the vm — worth fixing beyond the test, because that call sits inside a timer where a ReferenceError is swallowed and would take the recovery with it. It reads through `window.` like terminal-ui.js does with its own constants. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e1e7dc5bd8 |
fix(terminal): Ark0N's read of the #464 geometry work
Five items, two of which he could only see by running it, plus six smaller ones. Taking the two blockers first, because both were wrong in ways the existing tests could not catch. **Adopting the PTY's rows put the CLI's input line off-screen.** A phone that took a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container; xterm's own viewport then had nothing to scroll, so the bottom of the frame sat below the container with no gesture able to reach it. Output visible, typing invisible, for as long as the desktop kept the claim hot. `reconcilePtyGeometry` adopts COLUMNS ONLY now: width is the axis Ink's wrap and `eraseLines` arithmetic depend on, and keeping the local row count keeps the composer at the bottom of a viewport that scrolls. Measured at his geometry — a 360x300 container against a 198x43 pane now keeps 13 rows, takes 198 columns, paints 202px into a 210px container, and the input line is inside the box. **`capture-geometry-retry.browser.test.ts` failed, and CI could not see it** because the file is in `BROWSER_TEST_GLOBS`. Its premise WAS the clamp — `getTerminalDimensions()` floored while `fitAddon.fit()` did not — which this work removes at the source, so it can never hold again at any viewport. The case survives on its own terms: a pane already drawing at the requested size must not be replayed. Its premise is now the #464 invariant itself, that the floored report and the terminal agree, which is a stronger guard because the clamp coming back fails it here rather than silently restoring the replay loop. The helper docblock that repeated the old premise is corrected too. **A session with no pane reported 120x40 and the client adopted it.** `resize()` writes `_ptyCols`/`_ptyRows` only when `ptyProcess` is set and nothing seeds them from the spawn geometry, so a dead-pane session still held the constructor defaults — clicking that tab resized the browser terminal to 120x40 and, on anything narrower, claimed another device owned the pane when none existed. `Session.ptyGeometry` returns null without a pane, the HTTP route answers `{}` and the socket sends no frame at all. The raw `ptyCols`/`ptyRows` getters are deleted rather than left available to be misused again. **The 40-column floor clipped the pane with nothing able to reach it.** The affordance keyed on a PTY mismatch, and the floor produces no mismatch — xterm and the PTY agree throughout, the terminal is simply wider than the box. It keys on what does not FIT now, MEASURED (`.xterm-screen` against the container, on the next frame, because the screen takes its width with the render) rather than derived from cell arithmetic. Measured at 360px: font 24 applies 40 columns and paints 560px, and all 200px of the overhang is reachable. `.pty-oversized` is renamed `.term-overflows-x`, because after this the old name describes only one of the two causes. **"Scroll sideways" did not work on touch for the sessions it targets.** `touch-action: pan-x` is cancelled before it starts by the `preventDefault()` `touchstart` calls on every 'content' tap. The terminal's own touchmove handler pans the container now, with the axis locked once per gesture so a diagonal cannot pan and scroll at once, and the CSS grants no `touch-action` at all — handing the browser a pan AS WELL would move the pane twice for one finger on the taps where that preventDefault does not run. Measured under real touch dispatch: a 140px swipe reaches `scrollLeft` 140 where it reached 0 before, the buffer does not move with it, and a vertical swipe still scrolls the scrollback. Three defects in the above, found while checking it rather than by being told: - `canPanHorizontally` first tested `scrollWidth > clientWidth` alone, which is true of a container that is not a scroller — a sideways swipe would have locked the axis, done nothing, AND suppressed the vertical scroll it should have been. Gated on the class as well. - The notice advised scrolling sideways whenever the PTY was wider, including when it still fitted and nothing scrolled. It is gated on measured overflow, and on a comparison against the width this container WOULD request rather than the one it currently holds — once adopted those are equal, so the second question answers itself false while the condition is still true. - `_syncTerminalOverflowAffordance` could throw out of `document.getElementById` before reaching its try block. It runs off every geometry change, so a cosmetic affordance could have taken the resize down with it. The smaller items: - `docs/architecture-invariants.md` no longer explains the equality guard as a clamp signature; it records what the clamp used to do and why it cannot any more. Edited by hand — that file is outside the Prettier glob, and letting Prettier near it rewrote eleven unrelated emphasis markers. - `throttledResize`'s HTTP fallback reads the reply. It is the path where a declined resize is least likely to be noticed, because no socket means no `{"t":"zc"}` frame either. - The changeset covers the whole release: the geometry work, the queued replay clear, the renderer watchdog, the body-covering fetch deadline, the WebSocket output-gap reconcile, the build-generated service-worker precache and per-build cache key, and the crash-trail hygiene. - `@xterm/headless` is declared in the root devDependencies instead of being reached through workspace hoisting. - The output-gap marker is cleared after any response arrives, not only when the capture was non-empty: a server that answers with an empty capture HAS reconciled us, and leaving the marker set refetched on every reconnect. - `e587d845`'s message claimed a test asserted the failed-load copy against the built asset. It did not — that assertion lived in a probe deleted with the other scratch scripts, so the claim was false when it was written. There is a real test now, and it reads the source rather than `dist/`, because `dist/` is not committed and a test that skips when it is absent would pass for the wrong reason in CI. `Session.ptyGeometry` gets behavioural coverage against the real class in `session-resize-arbitration.test.ts` rather than a source guard, including the contrast — a pane that does exist still reports, and still follows a resize — so "always null" would fail it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e587d84590 |
fix(terminal): make the failed-load notice fit the narrowest terminal
A third pass in a real browser, at the widths this app actually renders at. The notice a failed history load writes into the blanked pane was one 70-character sentence. At 430px that exactly filled the line; at 320px it wrapped and left a lone '.' on a line of its own. The floor this app will render at is 40 columns — reachable today by raising the font on a phone — so the notice is three lines now, none over 25 columns, one fact each: what failed, that the session is still alive, and what to do. It says RELOAD rather than "reopen the tab" because `selectSession` early-returns when the session is already active, so clicking the tab you are already on retries nothing. The earlier wording named no next step at all, which left a mostly-empty terminal and no way out of it. CLAUDE.md no longer cites "758px reachable to the right" as evidence: that figure is a property of the test content, not of the fix, and the file's value is that a reader can trust a claim without re-deriving it. What is pinned instead is the invariant that survives any content — the full pane width is reachable, and removing the class returns scrollLeft to 0, so a resolved mismatch cannot leave the pane parked off-screen. Verified at 430, 360 and 320px against the shipped bundle, with the test asserting the built asset carries the copy so an edit that never reached the build fails rather than passing on the source's wording. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d9fa9ba1eb |
test(terminal): follow the existing suites to the one geometry owner
The gate caught fourteen failures the focused tests could not: every harness that builds a partial app out of cherry-picked mixin methods, and every source guard that named `fitAddon.fit()` by hand. Most are wiring — `syncTerminalGeometry`, `_refitAfterCellSizeChange` and `_resizeTerminalTo` added to the fakes so the real chain runs rather than a stub of it. `file-browser-search` is the one that shows why it matters: without the method on the fake, selectSession's unconditional call threw into its own catch and every later assertion in the file measured a load that never happened. Two are not wiring. `detached-session-pane-sizing` pinned the behaviour this change deliberately reverses. It asserted the LOCAL fit still runs for a session owned by its own window — "withhold the send, never the reflow" — so the assertion is restated rather than patched, with the reason beside it and in the file's docblock: a reflow the PTY is never told about leaves this xterm rendering a CLI's frames against a shape that does not exist, and the popup that owns the pane is drawing for its own width regardless. The old rule bought a garbled frame, not a correct one. `mobile-prompt-composer` sliced `_cleanupSessionData` as a fixed 1200-character window, so the assertion depended on how much unrelated code sat above the line it cared about. It reads the whole method now. `terminal-scroll-intent` records `syncTerminalGeometry` rather than `fit`, under its own name: recording a bare fit there would name the very thing the subject was changed to stop doing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
abf1d1f1ca |
fix(terminal): the PTY and the browser terminal must never disagree about size
Issue #464, "text gets muffled sometimes, in both TUI default and fullscreen". The screenshot is not a dropped frame or a frozen renderer — it is arithmetic. Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the rows it believes that frame took. A browser terminal of a different width makes each logical line occupy more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased: doubled lines, and short tool summaries sitting inside longer prose rows with the prose's tail still visible. Reproduced against this repo's own xterm before changing anything — a 120-column PTY against a 62-column terminal renders every wrapped line twice. `test/ terminal-pty-geometry.test.ts` pins that, and pins the clean render at matching widths beside it, so the assertion cannot be satisfied by code that fixes nothing. Four ways the two drifted apart, none of them observable from either end: 1. `fitAddon.fit()` resizes xterm to `proposeDimensions()` RAW while every server-facing path reported those floored at 40x10. Measured in Chrome at 430px: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. Three call sites each did their own fit-then-floor, and two re-read the proposal after the fit — `_shrinkPaddingToFit()` runs exactly there, so the container had moved. 2. `throttledResize` (keyboard up) and `sendResize` (session detached into its own window) reflowed locally and withheld only the SIGWINCH. That is the one combination that cannot be right: a reflow nothing is rendering for buys nothing and costs correctness. Both now withhold everything, and the keyboard's settle timer still sends the one resize that stops the PTY going stale. 3. `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size — a geometry change — and told the server nothing at all, so raising the font on a phone left the CLI wrapping at the old column count. 4. `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim, and said nothing, because resize was write-only. `syncTerminalGeometry()` is now the one function that may change the terminal's size: it fits, floors and applies as a single step, so the numbers xterm holds are the numbers the server is told. A test sweeps every module for a bare `fit()` on the main terminal, and finds exactly one — the owner's own. For (4) the client cannot win, so it is told the truth instead: both transports answer a resize with `session.ptyCols`/`ptyRows` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts them. A terminal that keeps a shape the PTY refused does not render "too narrow", it renders garbled. Adopting can leave the pane wider than the screen and the container is `overflow: hidden`, so `.pty-oversized` grants horizontal reach for exactly as long as the mismatch lasts: correct-and-reachable beats correct-and-clipped beats garbled. That rule sets both overflow axes and its own `touch-action` because mobile.css loads later and sets `.terminal-container { overflow: visible; touch-action: none }` — a bare `overflow-x` would leave overflow-y computing to `auto` and hand the browser a vertical scroll container the terminal's touch handler knows nothing about. Verified in Chrome at 430px against a live server, with a desktop client holding the claim: the phone adopts 198x43, gets `overflow-x: auto` / `overflow-y: hidden` / `touch-action: pan-x`, 758px of reach to the right, and keeps its own vertical scrolling. The pre-fix build was measured in the same harness for the control. Two things this deliberately does not do. It does not change who owns the pane size — the desktop still wins, and `_startMobileResizeRetry` still takes it back once that goes idle. And `throttledResize` still holds the PTY's shape for the whole keyboard animation rather than sending a SIGWINCH per step; that decision predates this and was not re-tested here. Also in this commit, Ark0N's third-pass review items on #431: - The response viewer's byte-buffer fallback and `_onSessionClearTerminal` both used the no-param `/terminal` form, capped only by `terminalBufferMaxBytes` (32MB) — the largest body the frontend asks for anywhere. One carried no deadline at all and the other got the 15s tail budget. Both now take the full-history budget. - A `?full=1` capture that outruns its deadline falls back to the bounded tail. The pane is blanked before that fetch, so an abort used to leave a black rectangle, discard the queued live output and never reach `_connectWs`. A failed load now still opens the socket, says one dim line where the content would have been, and clears the tab's spinner — which nothing did, so a failed select left `aria-busy="true"` set forever. - `_wsOutputGapSession` is cleared at the repaint that settles it, not in a `finally` that also ran on the catch. A reconcile that threw, or hit the new deadline — the flaky link the marker exists for — dropped the gap with nothing to retry it. `ws.onopen` no longer clears it up front either. - The replay-clear invariant is pinned in the gate, which is the drift this PR exists to fix: `_resetTerminalForReplay` must be a queued write and nothing else, and no module may blank the terminal with a `clear()+reset()` pair. - `DIAG_ENTRY_MAX_CHARS` replaces the hardcoded 300, bound through a local first: `CodemanDiag?.x` still throws a ReferenceError when the identifier was never declared, and that is the one function in the app that must not throw. - panels-ui's two kill-all clears route through the same helper, and the xterm-version guard's comment says "resolved lockfile version" rather than "dependency RANGE", which is what it has pinned since the last round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
abd39318e6 |
fix(terminal): deadline must cover the body, precache must ignore the cache-bust query
Review fixes. Two of these are defects in the previous commit.
1. The fetch deadline only covered time-to-headers. `await fetch()` settles on
response headers, so clearing the abort timer in a finally around it left the
body — the multi-megabyte `?full=1` capture the deadline exists for —
completely unbounded; it only ever bounded a server that accepts a connection
and never replies. Measured against a server that sends headers immediately
and stalls the body 4s under a 1s deadline: fetch resolved at 30ms, timer
cleared there, body completed at 4026ms unaborted. Now the body is read
inside `_fetchTerminalCapture`, which returns {json, headers, headersAt} —
headers because two callers read server-timing, headersAt because those same
callers measure header-vs-body time and can no longer observe that moment.
`_terminalCaptureInflight` is scoped the same way, so a body still streaming
counts toward a capture starting beside it. Same test now aborts at 1005ms.
2. The precache could never be hit, and the previous commit made that expensive
rather than free. `renderIndexHtml` runs `cacheBustAssets`, which appends
`?v=<mtime>` to every same-origin .js/.css reference INCLUDING content-hashed
names — confirmed against a running instance:
`vendor/xterm-zerolag-input.6fee72f2.js?v=1789402869101`. `caches.match` is
query-sensitive, so entries keyed on the bare hashed path were unreachable;
deriving the list from the manifest turned cheap 404s into ~1.3MB downloaded
at every install that nothing could read back, once per deploy now that
CACHE_NAME rotates. The fallback match takes `{ ignoreSearch: true }`, which
also lets runtime-cached entries survive an mtime change.
3. `_wsOutputGapSession` was only cleared in ws.onopen, so paths that already
repaint the buffer left it set and the socket replayed everything a second
time. `selectSession` loads the buffer and only THEN calls `_connectWs`, so
neither the _isLoadingBuffer nor the _terminalRefreshOwner guard applied.
`_markTerminalBufferReconciled()` is now called from _onSessionNeedsRefresh's
finally, from selectSession after its load, and from _cleanupSessionData.
The scope claim was also wrong and is corrected in the comment: when the
network drops, SSE drops with it and handleInit's keepTerminal branch already
reconciles. The genuinely uncovered case is the WS dying while SSE stays up,
where _onSSETerminal discards SSE terminal frames until _wsReady flips in
onclose — up to the ping+pong window of output nothing writes.
4. CLAUDE.md said "all of them measured rather than reasoned", which the PR's
own "not verified" section contradicted. Split explicitly: the replay race is
measured, the watchdog mechanism is verified against xterm 6.0.0 under jsdom
(field path resolves, a forced stale handle makes refreshRows a no-op, the
kick schedules a fresh frame), and the iOS rAF-discard premise is reasoned
and still wants a device. Adds the two missing entries — the WebSocket
reconcile and the sw.js/build.mjs "keep these in sync or the build throws"
contract.
Also: test/xterm-private-api.test.ts pins the RESOLVED lockfile version instead
of the declared `^6.0.0` range, which was the wrong assertion in both directions
— a real upgrade to 6.4.0 can rename a private field while resolving inside the
range, and an innocuous range edit failed while changing nothing installed. And
test/sw-precache-manifest.test.ts now parses HASHABLE out of scripts/build.mjs
rather than hand-copying it, which was the same drift this PR exists to fix; the
parse is guarded against silently matching nothing.
The deadline fix has a behavioural test against a real socket plus a source
guard asserting `await res.json()` precedes the finally — verified to fail when
the helper is reverted to the old shape, so it is not vacuous.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
c0422c4e21 |
feat(terminal): renderer watchdog, atomic replay clear, fetch deadlines, reconnect recovery
Four ways the terminal can silently stop being correct — in each case the
buffer keeps updating, nothing throws, and the only recourse is a reload.
1. Renderer freeze after backgrounding. iOS DISCARDS scheduled rAF callbacks
when a PWA backgrounds, and xterm's RenderDebouncer only clears its
`_animationFrame` handle from inside that callback — so one drop leaves it
permanently set and every later refresh() early-returns. Parsing is
decoupled from rendering, so bytes keep filling the buffer correctly while
nothing paints. Codeman has exactly ONE xterm for the whole page load, so a
single backgrounding wedges it until a reload. Adds a 2s liveness poll and
`_kickRenderer()`, which does what the dropped `_innerRefresh` would have.
2. Replay clears raced live output. xterm's write() is async-queued while
reset() is synchronous and, per upstream, "does not clear input buffers and
does not reset the parser" — so bytes queued before a reset are parsed after
it and fuse into the snapshot. Verified against the real xterm 6 here:
write('p8'); reset(); write('rmissions') renders "p8rmissions". The main
path was already safe via a queued erase; the needsRefresh and clearTerminal
paths were not. All three now share one queued `\x1bc` (RIS), which unlike
3J/H/2J also resets modes, charsets, scroll regions and SGR state.
3. Output lost on WebSocket reconnect. Input frames carry seq+cid and are
delivered exactly once; output frames carry nothing. ws.onopen re-sends dims
and flushes queued input, and needsRefresh only fires on external-CLI
startup and SSE backpressure drain — never on reconnect. Output produced
while offline was simply absent afterwards. Interim fix: reaching onclose
means the drop was unintentional, so the session is marked and the next open
reconciles from the server buffer. Sequencing output is the follow-up.
4. Terminal captures had no deadline. No AbortController anywhere in the
frontend, including `?full=1`, which the code itself calls "unbounded-ish
work: at the default history limit it can be megabytes". Adds a budget that
scales with full-vs-tail and with captures in flight, degrading to a plain
fetch where AbortController is missing.
Also: the service-worker precache was dead — the build content-hashes assets
but sw.js listed pre-hash names, so 15 of 23 entries 404'd (verified against a
running instance) and cache.add().catch() hid it. Offline still worked via
runtime caching, but CACHE_NAME was a constant so activate's cleanup never
deleted anything and every past release's assets accumulated. Both are now
derived from the build manifest. Crash-trail entries are flattened and capped,
since they are joined with \n into one value and one call site interpolates a
server-controlled WS close reason.
The watchdog reads xterm privates — there is no public API. Every access is
optional-chained so a shape change degrades to a no-op. `_renderService` only
exists after open(), which needs a real DOM, so the gate cannot assert the
field path; test/xterm-private-api.test.ts pins the dependency range instead.
Tests: 23 new (terminal-resilience, sw-precache-manifest, xterm-private-api),
all pure/static so they run in the gate, which excludes the mobile suite. One
static source guard in history-truncation-notice updated for the renamed call;
the behaviour it pins is unchanged.
Not verified: no browser available, so no runtime reproduction of the freeze
and no real-device test of the reconnect path. Both warrant a device pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
aae90599e5 |
fix(terminal): stitch a wrapped line through the indent its continuation carries
An agent's numbered list wraps its URL, and the link opened a PREFIX of it:
1. https://github.com/users/someone/packages/container/p
ackage/thing
opened `…/container/p`. The provider already stitched hard wraps — Ink emits a real
newline, so nothing is flagged `isWrapped` and a row that fills the last column is
taken as continuing — but it joined the row texts VERBATIM, and the continuation
carries the list's own three-space indent. That whitespace lands in the middle of
the token, which is exactly where the URL pattern stops. Flush-left wrapped URLs
(Claude Code's own `/login`) worked, which is why this survived.
The touch-selection helpers had the shallower version of the same bug: they walked
`isWrapped` only, so `Line` grabbed the single row on screen rather than the
logical line, and a long-press on a wrapped token selected only its visible half.
So the reconstruction now lives in ONE place, `terminalLogicalLine` in
constants.js, and both consumers use it — the link provider matching patterns over
its text and the selection helpers measuring words and lines with it. A link that
spans a wrap and a `Line` that stops at the screen edge were the same bug twice.
The helper drops the leading whitespace of a HARD continuation (the program's
indent) and keeps that of a SOFT one (the emulator inserts nothing, so it is real
content), records the dropped width per segment so the offset↔cell mapping stays
exact in both directions, trims only the final row so earlier offsets stay aligned
to cells, and keeps the 12-row bound that stops a screenful of full-width output
from being re-scanned on every hover.
⚠️ Selection spans are computed in CELLS, not text offsets: an xterm selection is
one contiguous run, so a token spanning a hard wrap also covers the indent cells
between its halves. A run that skipped them cannot be expressed, and would not
match what is highlighted.
Tests: `test/terminal-logical-line.test.ts` (8 cases: the indent drop, resolving
from either row, both mapping directions, soft continuations kept verbatim, no
over-reach past a short row, the row bound, final-row trimming, a missing row) and
5 in `terminal-touch-tap.test.ts` (the whole URL from either row, a token selected
across the wrap, `Line` spanning both rows, no reach into the next line). Removing
either half of the fix reds 5 and 8 of them respectively.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
2e58da7479 |
docs(mobile): document the phone gestures, and translate the selection bar
The three fixes in this branch change what a tap and a long-press MEAN on a phone, and add a UI surface with its own z-index — all of which this repo keeps written down rather than discoverable only by reading the handlers. - `docs/wiki/Mobile-Guide.md` (the published user manual): a new "Tapping, links and copying" section, and the long-prompt behaviour in the keyboard section where the existing scroll/tap rules live. - `CLAUDE.md`: the touch-gesture invariants next to the scrollback/wheel material (why the caret line is the boundary rather than the tap intent; why all three selection guards exist), the overlay's new bottom bound alongside the single-source note, and the selection bar in the z-index registry — 900, above terminal content and the local-echo overlay and deliberately below floating agent windows so it can never cover their controls. - `i18n.js`: zh-CN for the bar's `Copy` / `Line` / `Clear selection`. The bar is a SIBLING of `.xterm`, not a descendant, so `SKIP_SELECTOR` does not cover it and the entries actually apply. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ba843bb272 |
fix(mobile): keep a long prompt visible instead of hiding it behind the keyboard
Typing a prompt long enough to wrap ran the text off the bottom of the screen: the tail — the part being typed, where the cursor is — sat behind the on-screen keyboard, so the user was typing blind. Two independent causes. **The overlay had no bottom bound.** On touch devices keystrokes are buffered in the local-echo overlay and do not reach the PTY until Enter, so the CLI never learns the prompt is long and nothing scrolls or reflows to make room. Meanwhile the renderer lays its wrapped lines out straight DOWNWARD from the prompt row (`top = promptRow * cellH`, each line at `i * cellH`) with nothing clamping it to the visible rows — and with the keyboard up there are only a handful of those. The block now grows UPWARD once it would pass the last visible row: it is lifted so its final line lands ON that row. Every line div is opaque, so it covers transcript above rather than vanishing under the keyboard below — the same thing a real terminal does when a composer expands. A prompt taller than the whole viewport keeps its TAIL, for the same reason the fix exists: the end is what the user is looking at. `startCol` indents only the line that starts at the prompt marker, so it is dropped along with that line when only the tail fits, and the cursor follows the last VISIBLE line. `rows` joins the render key: the layout depends on it, so a keyboard opening — which changes rows without changing the text — must not be skipped as a redundant render. **`_shrinkPaddingToFit()` was reclaiming the bars' own space.** On phones the toolbar and accessory bar are `position: fixed`, so they occupy no layout space and `main`'s padding-bottom is the ONLY thing reserving room for them. Shrinking it by the full sub-row slack pulled the terminal's bottom edge down underneath them, and the row the following re-fit gained was painted behind them — clipping the last line of a long prompt. The shrink now has a floor: the MEASURED height of the currently-visible fixed bars, so genuine over-reservation of the hard-coded 84px is still reclaimed while a device that needs those pixels keeps them. The floor is `Math.min(currentPadding, measured)`, so it can only ever prevent a shrink, never cause a grow that would resize the terminal as a side effect. Overlay behaviour lives in `packages/xterm-zerolag-input/` (single-source; the vendor bundles are generated), so the fix is in the package with the row count passed in as an optional `totalRows` — absent, the layout is exactly as before. Tests: 7 cases in the package's `overlay-renderer.test.ts` (upward lift, tail retention, indent drop, cursor on the last visible line, and the unclamped fallbacks) and 7 in a new `test/mobile-keyboard-bottom-padding.test.ts` (reclaim, floor, partial reclaim, no-grow, hidden bars, CJK strip, whole-row slack). 5 and 4 of them respectively fail without the fix. Package suite 238 pass, including the codex byte-identity and replay tests. Verified on Android + Chrome against a live instance: a ~460-character prompt wrapping ~12 rows stays on screen while typing and arrives at the PTY intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
756728e553 |
feat(mobile): long-press to select terminal text, tap to extend, Copy
There was no way to copy terminal text from a phone at all, and three layers ruled it out independently: `user-select: none` across the whole terminal subtree on touch devices (taps are cursor gestures there, so the OS callout had to go), the WebGL renderer drawing glyphs as pixels with only the accessibility tree behind them, and xterm's own selection being a mouse DRAG while the touch path dispatches a zero-movement mousedown/mouseup pair — a click. `copyTerminal()` exists but is wired to no button and calls `navigator.clipboard` directly, which is undefined on the plain-HTTP LAN install the installer offers. So the gesture drives xterm's `select()` directly: public API, renderer- independent, and the highlight is drawn by xterm itself. Long-press is free real estate — tap and swipe are taken, long-press and double-tap are used by nothing. - **Long-press** (350ms, finger still within the shared tap slop) selects the run of non-whitespace under the finger. Whitespace is the only delimiter on purpose: every punctuation-aware word rule cuts a path, URL or hash in half, which is what you came to copy. - **Drag** while held extends the selection; touchmove diverts from scrolling. - **Tap** while the bar is up extends it too. That is the ergonomic core: picking up a 4px handle with a fingertip is a coin flip, tapping the other end is not. Dismissal stays explicit (✕ or Copy), so no tap is spent leaving a mode the user is still using. - **Copy** goes through the existing `copyTerminalSelection()`, so it inherits the execCommand fallback that is the only route that works on plain HTTP. - **Line** takes the whole logical line, wraps included, trailing pad trimmed. Three guards are what make the gesture survive contact with a real phone, and each fixes a symptom measured on Android Chrome: 1. **The compat mouse pair after touchend.** xterm focuses from its screen-element mousedown and SelectionService resets the model there, so lifting your finger popped the keyboard and dissolved the selection in one go. The tap path already had a guard for those events; the selection path simply never armed it. Armed now, and the touchend is `preventDefault`ed so the synthesis is stopped at the source (that listener is no longer passive). 2. **The platform's own long-press.** Android Chrome runs its handling at ~500ms and focuses the nearest editable element — xterm's helper textarea, parked at the cursor — which no touch handler can preventDefault because it never sees an event. A focus guard blurs the terminal input for the duration of the gesture, whatever focused it, bounded by a self-expiring deadline so a stuck flag can never leave the keyboard unreachable. `contextmenu` is suppressed for the same window, and the threshold sits at 350ms so it lands clear of the platform's. 3. **Copy re-focusing the terminal.** `copyTerminalSelection()` ends with `terminal.focus()`, which is right on a desktop and wrong on a phone: the keyboard covers what was just copied with nothing waiting to be typed. The bar is built in JS because index.html is read once at server start, and its styles live in styles.css rather than mobile.css because the gesture is touch-driven, not width-driven — a touch tablet in landscape gets the gesture and would otherwise have no bar to copy from. 12 tests in `terminal-touch-tap.test.ts` cover the word rule, forward and backward extension, cross-row selection, Line, tap-to-extend, the copy path, and each of the three guards including the focus guard's expiry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f2d3a7e3c1 |
fix(mobile): links open in a new tab from a tap, in the terminal and the chat
On a phone no link was openable, on either surface, for two unrelated reasons. **Terminal.** xterm resolves the link under the pointer on `mousemove` and activates it on `mouseup` over its SCREEN element. A touch tap delivers neither: `touch-action: none` on the terminal subtree plus touchstart's preventDefault for a 'content' tap suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive inside the 450ms tap window, and the synthetic mousedown/mouseup pair dispatched for mouse REPORTING goes to the `.xterm` root — an ancestor of the node the linkifier listens on, so it cannot reach it — and carries no mousemove either way. Every URL and file path in the terminal was therefore inert on phones and tablets, Claude Code's own `/login` URL included. The tap path now activates the link itself, through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`), so a tap and a desktop click can never disagree about what is a link or where it ends — containment mirrors xterm's own `_linkAtPosition`. It runs synchronously inside the touchend handler, which is what keeps the user gesture that lets `window.open` past the popup blocker, and before any mouse report, exactly as `_handleDesktopTerminalClick` already skips the SGR tap for a hovered link. Two kinds of row keep their existing meaning: the caret's logical line, where a tap places the cursor and a URL the user typed must stay editable, and TUI-owned rows, where a numbered choice or an expandable readback is answering a dialog and routinely carries the very path the tap would otherwise open. The caret line is the boundary rather than the tap intent, because a plain shell classifies EVERY tap as 'input' and gating on that would leave every URL in shell output inert. **Chat.** `marked` emits a bare `<a href>` and the markdown sanitizer's allowlist carries no `target`, so a tap in the response viewer navigated the current tab away: on a phone that unloads the whole dashboard — SSE, terminal buffers, unsent composer text — and there is no middle-click or open-in-new-tab affordance to work around it. `_renderMarkdown` now decorates anchors in the template pass it already makes for code blocks. That pass runs AFTER sanitizing, so it is the only source of both attributes: an agent-authored `target`/`rel` is already stripped, and `rel="noopener noreferrer"` is set on the same element in the same breath, so no page Codeman opens gets a `window.opener` handle back. Fragment links stay in-page; mailto:/tel: are left to the OS rather than stranding an empty tab. Tests: 10 cases in `terminal-touch-tap.test.ts` (URL, file path, log path, scrollback, no-double-report, composer, shell mode, dialog row, no provider) and a new `response-viewer-external-links.test.ts` driving the shipped marked + DOMPurify + app.js. 7 of them fail without the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |