feat(mobile): links open from a tap, text can be copied, long prompts stay visible, wrapped links open whole - #321
Conversation
…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>
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>
…he 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>
…n 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>
58e411c to
2e58da7Compare…ion 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>
Ark0N
commented
Aug 19, 2026
Merged, thank you! This is one of the best PRs this repo has received: three real phone problems, each traced to its actual causes, guards that were found on a real device instead of reasoned into existence, and tests that fail without their fix. The wrapped-link stitching through the indent was a great catch on top, and filing #322 first for the selection design was exactly the right move. Since CI didn't run on your fork's branch, I verified the merge result with current master locally before merging: typecheck, lint, format, syntax checks, the full test gate (5351 passing) and the package suite (238 passing), all green. Your cron-service flake didn't reproduce here either. This ships with the next release and you'll be credited in the release notes. Hope to see more from you! |
…gotcha The new local-echo-overlay gotcha landed as a list item but left the xterm-zerolag-input entry below it without its leading '- ', splitting the Common Gotchas bullet list in two. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rounakdatta
commented
Aug 20, 2026
Thanks for the note @Ark0N! |
Three things a phone could not do. Separate causes, separate fixes, kept as three clean commits so any one of them can be taken or dropped on its own:
f2d3a7e3756728e5ba843bb22e58da74aae90599Rebased on
masteras ofd4ccff07.Both were verified by hand on Android + Chrome against a live 1.19.3 instance, not just in tests — and in both cases the device is what found the real bug.
1.
fix(mobile): no link opens from a tapNot the URLs an agent prints in the terminal, not the links in the response viewer. One symptom, two independent causes.
Terminal links are structurally unreachable on touch
xterm's
Linkifierresolves the link under the pointer onmousemove(the only thing that populates_currentLink) and activates it onmouseup, both bound to the screen element. A tap delivers neither:touch-action: noneon the terminal subtree plustouchstart'spreventDefault()for a'content'tap suppress the compatibility mouse events;_installMobileTapMouseGuarddrops the trusted ones that still arrive inside the 450 ms post-tap window;mousedown/mouseuppair_dispatchSyntheticTerminalClicksends for mouse reporting carries nomousemove, and goes toterminal.element(.xterm) — an ancestor of the node the linkifier listens on, so it cannot reach it even in principle.Every URL and file path in terminal output has therefore been inert on phones and tablets. Claude Code's own
/loginURL is in that set, which makes that flow unfinishable from a phone.Fix: the tap path 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 is xterm's own_linkAtPositionrule. It runs synchronously insidetouchend, preserving the user gesture that letswindow.openpast the popup blocker, and before any mouse report — mirroring_handleDesktopTerminalClick, which already skips the SGR tap for a hovered link.Two kinds of row keep their existing meaning: the caret's logical line (a URL the user typed must stay editable) and TUI-owned rows (
_isActionableMobileTerminalTap— a numbered choice or expandable readback is answering a dialog, and those rows routinely carry the very path the tap would otherwise open). The caret line is the boundary rather than the tap intent, because a plain shell short-circuits_classifyMobileTerminalTapto'input'for the whole screen, and gating on intent would leave every URL in shell output inert.Response-viewer links replace the app
markedemits a bare<a href>and the sanitizer'sALLOWED_ATTRhas notarget, so a tap navigated the current tab away — unloading the dashboard, SSE, terminal buffers and unsent composer text, with no middle-click or open-in-new-tab affordance to work around it.Fix:
_renderMarkdowndecorates 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-authoredtarget/relis already stripped by DOMPurify, andrel="noopener noreferrer"is set on the same element in the same breath. Fragment links stay in-page;mailto:/tel:are left to the OS. The sanitizer config is untouched.2.
feat(mobile): no way to copy terminal textNot a word, not a line. Three layers rule it out independently:
user-select: none !important+-webkit-touch-callout: noneacross the terminal subtree underbody.touch-device. Correct for the tap-to-position gesture that owns taps there.webglRendererEnableddefaults true, so glyphs are GPU-drawn pixels and the DOM underneath is the accessibility tree.terminal.select(/selectLines/selectionStartappear zero times in any touch path, and there's no long-press handler at all. xterm's selection is a mouse DRAG; the touch path dispatches a zero-movement pair, i.e. a click.Fix: drive xterm's public
select()— renderer-independent, and xterm draws the highlight itself. Long-press is free real estate: tap and swipe are taken, long-press and double-tap are used by nothing.✕orCopy).copyTerminalSelection(), inheriting the execCommand fallback that is the only route that works on the plain-HTTP LAN installinstall.shoffers.styles.css, notmobile.css— the gesture is touch-driven, not width-driven, so a touch tablet in landscape would otherwise get the gesture with no bar to copy from.Three guards, each fixing a symptom measured on the device
touchend—CoreBrowserTerminalfocuses from its screen-elementmousedownandSelectionServiceresets the model there, so lifting your finger popped the keyboard and dissolved the selection together. The tap path already owned a guard for those events; the selection path never armed it. Armed now, and the touchend ispreventDefaulted so the synthesis stops at the source (that listener is no longer passive).preventDefaultbecause 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.contextmenuis suppressed for the same window, and the 350 ms threshold sits clear of the platform's.copyTerminalSelection()ends withterminal.focus(), right on a desktop and wrong on a phone, where the keyboard covers what was just copied with nothing waiting to be typed.Design rationale, alternatives and the two adjacent bugs I did not fold in (
copyTerminal()is wired to no button, and callsnavigator.clipboarddirectly so it cannot work on plain HTTP) are in #322.No setting, deliberately
Every new UI surface in this project ships opt-in and OFF —
approvalsInboxEnabled,readMyMindEnabled, the entrance animations — so the absence of a toggle here is a choice, not an oversight.The reasoning: this is not a new capability competing for screen space, it is the only way to get text out of the terminal on a phone. There is no prior behaviour for it to regress either — long-press was an unclaimed gesture, and every path that handled a tap before still handles it, which is exactly what the three guards above are protecting. A fix nobody finds is a fix nobody has, and a
terminalTouchSelectioncheckbox defaulting ON would mostly add a way to end up without copying again.Happy to add one if you disagree — it would want both
displayKeysmembership and deliberate absence from the strict settings schema, per the per-device/synced split in CLAUDE.md.3.
fix(mobile): a long prompt hides behind the keyboardType a prompt long enough to wrap and its tail — the part being typed, where the cursor is — ends up behind the on-screen keyboard. You are typing blind. Two independent causes again.
The local-echo overlay has no bottom bound
On touch devices keystrokes are buffered in the 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 ati * cellH) with nothing clamping it to the visible rows — and with the keyboard up there are only a handful of those.Fix: the block grows upward once it would pass the last visible row, lifted so its final line lands on that row. Every line div is opaque (
makeLinepaintsfont.backgroundColor), 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 at all.startColindents only the line that begins 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.rowsjoins the render key: the layout now depends on it, so a keyboard opening — which changes rows without changing the text — must not be skipped as a redundant render.Overlay behaviour is single-source in
packages/xterm-zerolag-input/, so the fix lives in the package with the row count passed in as an optionaltotalRows. Absent, the layout is exactly what it was._shrinkPaddingToFit()reclaims the bars' own spaceOn phones the toolbar and accessory bar are
position: fixed, so they occupy no layout space andmain'spadding-bottomis 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.Fix: the shrink now has a floor, the measured height of the currently-visible fixed bars. Genuine over-reservation of the hard-coded 84px is still reclaimed; 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 of a function whose job is reclaiming slack.4.
fix(terminal): a wrapped link opens a prefix of itselfFound by using part 1 on a phone. An agent prints a numbered list, the URL wraps, and the link opens the part on screen:
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 precisely whereurlPatternstops. Flush-left wrapped URLs — Claude Code's own/loginlink, the case the stitching was built for — have no indent, which is why this survived.The touch-selection helpers from part 2 had the shallower version of the same bug: they walked
isWrappedonly, soLinegrabbed the single row on screen rather than the logical line, and a long-press on a wrapped token selected only its visible half.Fix: the reconstruction moves into one place —
terminalLogicalLineinconstants.js— and both consumers use it: the provider matches its patterns over that text, and the selection helpers measure words and lines with it. A link that stops at a wrap and aLinethat stops at the screen edge were the same bug twice, so they can no longer disagree. 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 those spaces are 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 being re-scanned on every hover.This half also fixes the desktop hover-click, which truncated identically.
Tests
npm run test:ci— 5343 passing. (cron-service.test.tshas one failure that reproduces on a clean checkout ofmaster, unrelated to this branch.)typecheck,lint,format:check,check:frontend-syntaxandcheck:public-assetsall clean, as is the package's owntsc --noEmit. The package suite passes 238, including the codex byte-identity and replay tests — which matters because part 3 touches shared overlay code.test/terminal-touch-tap.test.ts— 22 new cases (51 in the file) across both halves: URL, file path, log path, scrollback, no-double-mouse-report, the composer guard, shell mode and the dialog-row guard for links; the word rule, forward/backward extension, cross-row selection, Line, tap-to-extend, the copy path and each of the three guards including the focus guard's expiry for selection. Every guard has a test that fails without it.test/response-viewer-external-links.test.ts— new, drives the shippedmarked+ vendored DOMPurify +sanitize-html.js+app.jsunder jsdom, including an agent-suppliedtarget="_self" rel="opener"being overridden and ajavascript:href staying undecorated.packages/xterm-zerolag-input/test/overlay-renderer.test.ts— 7 new cases: the upward lift, tail retention, the dropped prompt indent, the cursor riding the last visible line, and the two fallbacks that pin the old layout whentotalRowsis absent.test/mobile-keyboard-bottom-padding.test.ts— new file, 7 cases drivingKeyboardHandler._shrinkPaddingToFitin a vm with fake bars: reclaim, floor, partial reclaim, no-grow, a hidden bar, the CJK strip, and whole-row slack. Deliberately outsidetest/mobile/, which is Playwright-driven and excluded fromtest:ci, so a guard living only there is invisible to CI.test/terminal-logical-line.test.ts— new file, 8 cases on the shared reconstruction: 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, and a missing row. Plus 5 interminal-touch-tap.test.tsdriving the tap andLinepaths against a wrapped, indented URL.Across the four fixes, 29 of the new tests fail without their corresponding fix; the rest are guards pinning unchanged behaviour. CI runs the package suite too (
947ff6f6), so the overlay tests are covered there and not only locally.The harness in
terminal-touch-tap.test.tsnow also loads the realconstants.js, so the tap path is exercised against the shipped link patterns rather than a copy.Device verification
Android + Chrome, live 1.19.3 instance, patched assets swapped into a running
dist/web/publicso the checks ran against real sessions and real agent output:Lineselects the whole wrapped line.Most of the guards in parts 2 and 3 exist because the device found them: the platform's own long-press focusing the helper textarea, and both halves of part 3, were invisible to reasoning and to tests — they only appeared on a real phone with a real keyboard.
Documentation
docs/wiki/Mobile-Guide.mdgains a "Tapping, links and copying" section and the long-prompt behaviour, since all three change what a gesture means on a phone.CLAUDE.mdgains the touch-gesture invariants next to the scrollback/wheel material, the overlay's new bottom bound beside the single-source note, and the selection bar in the z-index registry.i18n.jsgains zh-CN for the bar's three labels — it is a sibling of.xterm, not a descendant, soSKIP_SELECTORdoes not cover it and the entries apply.No changeset and no
CHANGELOG.mdedit, per CONTRIBUTING.Scope
Three commits in one PR because they came out of one stretch of using Codeman from a phone, and each is small and independently revertible. Squash them, take one, or ask me to split — whatever reviews best. Part 2's rationale, the alternatives weighed, and two adjacent bugs I deliberately did not fold in are in #322.
🤖 Generated with Claude Code