@vibelord/lanes 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibelord/lanes",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Installer for Lanes: shared agent terminals over SSH with Zellij views, registry sync, uploads and a phone interface.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -160,6 +160,8 @@ across sync/reconnects, and removes its close control. On a phone, tap the **+**
160
160
  On iPhone, open the printed private HTTPS URL in Safari and choose **Share → Add to Home Screen**.
161
161
  The interface provides Workspaces, Lanes and Panes controls, safe areas, portrait/landscape layout and adjustable
162
162
  terminal text. The keyboard opens when you tap **Type here** or another input; swipes and updates leave it closed.
163
+ A lane's tab has quick keys (`⏎ y n Esc ^C ↑ ↓`), and **Copy** and **Paste** in the header copy the screen and paste
164
+ into the lane.
163
165
 
164
166
  On macOS, allow **Lanes Helper** to read Downloads/Desktop/Documents when prompted. Upload progress and original
165
167
  errors are retained; an active large transfer is not cut off by a total-duration timeout. Files are read only
@@ -1 +1 @@
1
- 1.1.0
1
+ 1.1.1
@@ -248,8 +248,13 @@ Zellij's existing token/cookie checks remain authoritative; the mobile layer add
248
248
  other route, `/enrol/*`, goes to the enrolment service on loopback 8184 (see Access).
249
249
 
250
250
  The phone shell uses the pinned client's window hooks for workspaces, panes, input and sizing. A visible
251
- **Type here** field holds the original terminal input. Programmatic input focus is blocked on phones, so a
252
- swipe, refresh or server message cannot summon the keyboard. Native taps on input fields still work.
251
+ **Type here** field holds the original terminal input, as a text field. Programmatic input focus is blocked on
252
+ phones, so a swipe, refresh or server message cannot summon the keyboard. Native taps on input fields still work.
253
+ Quick keys, the lane filter and pastes go to the terminal WebSocket through the client's own `sendFn`, as its typed
254
+ keys do. The filter never puts an Esc and other bytes in one frame (the server would repeat what follows the Esc): it
255
+ sends a lone Esc, then `/` and the query, then only each edit's backspaces and letters. **Copy** and **Paste** call
256
+ the browser clipboard inside the tap; a picker tab takes no paste, because the picker reads one as a file drop
257
+ ([web/README.md](../web/README.md), Input, keys and clipboard).
253
258
  Safe-area insets and the visual viewport handle the Home indicator, rotation and keyboard.
254
259
 
255
260
  `web/mobile.js` chooses one of three modes once at load, by capability (`laneShellMode`), and records it on
@@ -954,18 +954,59 @@ show them (DEF-1531).
954
954
  The phone's light/dark toggle repaints the whole terminal, not only the shell: the page applies its own sixteen-colour
955
955
  palette on attach, on every toggle and after every Zellij config reload (DEF-1413; not yet looked at on a device).
956
956
 
957
- On a lane's own tab a row of quick keys sits above the dock: `⏎ y n Esc ^C ↑ ↓`. A tap writes the key to the lane
958
- without opening the keyboard. `n` and `^C` write only after you hold them for 400 ms; a short tap says
959
- "Hold to send". With the keyboard open the row stays above the keyboard's modifier bar. The filter bar on the picker
960
- sends its text as UTF-8, so a filter like `ø` matches.
961
-
962
- Device check (plan Task 13 step 3, not yet run): on a real iPhone in Safari and from the Home Screen, tap each quick
963
- key on a lane tab and confirm that the keyboard does NOT open, that `y`/`⏎`/arrows arrive in the lane, that a short tap
964
- on `n`/`^C` sends nothing and a hold sends it, and (plan Task 26 has landed; DEF-1531 waits on this) that the Workspaces
965
- sheet shows each picker's counts. A phone device created before Task 26 keeps the old picker pane (`upgrade-session`
966
- does not re-lay-out a tab): run `lanes rebuild <device>` first (`lanes doctor` must not list it under DEF-1531), or no
967
- counts appear and the check says nothing about the title path. Record the
968
- device, iOS version and result here. The unit suite covers only the picker's text, not the browser.
957
+ On a lane's own tab a row of quick keys sits above the dock: `⏎ y n Esc ^C ↑ ↓`. A tap sends the key to the lane on
958
+ the terminal WebSocket, as the client's own keys do, without opening the keyboard. `n` and `^C` send only after you
959
+ hold them for 400 ms; a short tap says "Hold to send" in the header for about 1.5 s. With the keyboard open the row
960
+ stays above the keyboard's modifier bar. The filter bar on the picker sends its text as UTF-8, so a filter like `ø`
961
+ matches; on a picker tab it takes the place of **Panes** in the dock. Until 2026-10-07 the quick keys and the filter
962
+ were sent as a control-socket `Write` message the server does not have, so none of them reached a lane (DEF-1733).
963
+ Their repair then sent each filter keystroke as one frame, `Esc /` and the whole query, which the server (as read from
964
+ the Zellij source) delivers as that frame plus the query again, so `abc` filtered on `abcabc` (DEF-1751). The filter
965
+ now never puts an Esc and other bytes in one frame: a new query is a lone `Esc`, then at least 60 ms later `/` and the
966
+ text; each edit after that sends only its backspaces and new letters; clearing sends a lone `Esc`. Leaving the picker
967
+ forgets the query and sends nothing, because the lane has the focus by then and an `Esc` can interrupt its agent
968
+ (DEF-1752).
969
+
970
+ **Type here** is a plain text field (it was a password field: iOS showed the secure-entry keyboard and the
971
+ strong-password bar, DEF-1734). A QuickType word arrives whole (composition input — CJK, GBoard — stays the client's
972
+ own); a paste (long-press the field → **Paste**) arrives as one bracketed paste with its line breaks (it used to
973
+ arrive letter by letter, `ll` as `l`, newlines lost, DEF-1735). A QuickType suggestion that replaces a partial word
974
+ removes the range the browser names for it before the word is sent (DEF-1753), and a paste keeps its escape sequences
975
+ except an end-of-paste marker (DEF-1755). **Copy** and **Paste** in the header put the visible screen on the clipboard
976
+ and send the clipboard to the lane (DEF-1736); the header says `Copied`, `Pasted` or `Clipboard blocked`. On a picker
977
+ tab **Paste** is hidden and a paste into **Type here** says `Open a lane to paste`: the picker reads a paste as a file
978
+ drop (DEF-1754). **More** closes the menu it opened (DEF-1738). The mechanism and its limits are in [frontend
979
+ maintenance](../web/README.md) (Input, keys and clipboard); `tests/web/phone.test.mjs` proves the bytes against a fake
980
+ of the pinned client, not a browser.
981
+
982
+ Device check (plan Task 13 step 3 and phone shell input v2, CAP-142; not yet run — no one has run it on an iPhone): on
983
+ a real iPhone in Safari and from the Home Screen —
984
+
985
+ 1. Tap **Type here**: the normal text keyboard opens with the QuickType bar; no key icon, no "Strong Password" or
986
+ password-autofill bar. Type `hello` letter by letter: `hello` arrives (both `l`s). Tap a QuickType suggestion
987
+ after a partial word and note what arrives (the suggestion alone is right; the partial word before it is the
988
+ documented caveat).
989
+ 2. On a lane tab, tap each quick key with the keyboard closed: the keyboard does NOT open; `⏎`, `y`, `Esc`, `↑`, `↓`
990
+ act in the lane; a short tap on `n`/`^C` sends nothing and shows "Hold to send" for about 1.5 s even while the lane
991
+ is updating; a 400 ms hold sends it; holding about 1 s still sends on release (no iOS callout or cancel).
992
+ 3. Copy `hello`, a newline and `world` (e.g. from Notes), then long-press **Type here** → **Paste** into a shell lane at
993
+ a `cat` prompt: both lines arrive intact (`hello` / `world`), nothing doubled or dropped.
994
+ 4. Tap **Copy**: the header says `Copied`; paste into Notes and find the screen's text.
995
+ 5. Tap **Paste**: iOS shows its own **Paste** confirmation; after you accept, the clipboard arrives in the lane and the
996
+ header says `Pasted`. With the keyboard open, **Copy** and **Paste** leave it open.
997
+ 6. Tap **More**, then **More** again: the menu opens, then closes. On a picker tab the dock shows Workspaces, Lanes,
998
+ the filter and **Type here** on one row, and the header has no **Paste**.
999
+ 7. On a picker tab, type `ab` in the filter, backspace once, type `c`: the picker's footer shows `/ac` (not `/acac`,
1000
+ the doubled query) and the list shows the matching lanes; clear the field: the footer is back to `Swipe to scroll ·
1001
+ Tap to open`. Filter again and open a lane from the list: the lane receives nothing (its agent is not
1002
+ interrupted), and back on the picker the field is empty.
1003
+ 8. (Plan Task 26 has landed; DEF-1531 waits on this.) The Workspaces sheet shows each picker's counts. A phone device
1004
+ created before Task 26 keeps the old picker pane (`upgrade-session` does not re-lay-out a tab): run
1005
+ `lanes rebuild <device>` first (`lanes doctor` must not list it under DEF-1531), or no counts appear and the check
1006
+ says nothing about the title path.
1007
+
1008
+ Record the device, iOS version and result of each step here. The unit suite covers only the picker's text and the
1009
+ shell's bytes against a fake page, not the browser.
969
1010
 
970
1011
  Tailscale Serve forwards HTTPS 8443 to `lanes-mobile.service` on `127.0.0.1:8083`. The separate unprivileged
971
1012
  Caddy serves `web/` and forwards native authentication/assets/WebSockets to Zellij on `127.0.0.1:8082`.
@@ -834,14 +834,24 @@ The frontend chooses once per page load, by capability:
834
834
  the client's own menu, where Lanes adds **Text size** (remembered on the phone). The light/dark toggle repaints the
835
835
  whole terminal.
836
836
  - The keyboard opens only when you tap **Type here** or another input. Swipes, live updates and reconnects leave it
837
- closed.
837
+ closed. **Type here** is an ordinary text field: the normal keyboard with its suggestion bar, never the password
838
+ keyboard. A suggestion you tap arrives as the whole word; long-press the field and choose **Paste** to paste.
838
839
  - The lane picker shows three rows per lane: the viewing mark, agent glyph and name; then what the agent needs, since
839
840
  when and the harness (`Needs you · 25m · claude`; a shell says `Quiet · 2h`); then the agent's task. Its first line
840
841
  counts the lanes that need you (`◆1 need approval · ◇2 need you · ▸6 working`). The footer says
841
842
  `Swipe to scroll · Tap to open`. The picker has no upload strip.
842
- - On a lane's own tab a row of quick keys sits above the dock: `⏎ y n Esc ^C ↑ ↓`. A tap sends the key without opening
843
- the keyboard; `n` and `^C` need a 400 ms hold (a short tap says "Hold to send").
844
- - The filter bar on the picker sends its text as UTF-8, so non-ASCII filters match.
843
+ - On a lane's own tab a row of quick keys sits above the dock: `⏎ y n Esc ^C ↑ ↓`. A tap sends the key to the lane
844
+ without opening the keyboard; `n` and `^C` need a 400 ms hold (a short tap says "Hold to send" in the header for
845
+ about a second and a half).
846
+ - **Copy** and **Paste** sit in the header, beside **More**. **Copy** puts the text on the screen on the clipboard
847
+ (Zellij's frames and bars included; a selection instead, when the terminal has one). **Paste** sends the clipboard to
848
+ the lane as one paste, line breaks kept; iOS first shows its own **Paste** button to confirm. The header then says
849
+ `Copied`, `Pasted` or `Clipboard blocked`. Neither closes the keyboard. The lane picker takes no paste (it would read
850
+ it as a file drop): there **Paste** is hidden, and a paste into **Type here** says `Open a lane to paste`.
851
+ - **More** opens the client's menu; tap it again to close the menu.
852
+ - The filter bar on the picker filters the lane list as you type, non-ASCII letters included: it sends each change
853
+ (a typed letter, a backspace) once, and clearing the field or pressing Esc closes the filter. On a picker tab the dock
854
+ shows the filter in place of **Panes**. Leaving the picker forgets the filter.
845
855
  - `+` and `×` on picker rows add and close unregistered sessions, as on the desktop; tapping `+` then a workspace
846
856
  needs no typing.
847
857
 
@@ -851,7 +861,8 @@ The frontend chooses once per page load, by capability:
851
861
  the same agents; the server keeps serving when your Mac sleeps.
852
862
  - Does not: a public URL (Tailscale only), uploads from a phone browser (the drop helper is a desktop client feature),
853
863
  approving an agent's permission prompt from a push notification.
854
- - Not yet verified on a device: the quick keys on a real iPhone, the light/dark terminal repaint, the counts that the
864
+ - Not yet verified on a device: the quick keys, the lane filter, the text keyboard, paste, **Copy** and **Paste** on a
865
+ real iPhone (the [runbook](RUNBOOK.md) lists the check), the light/dark terminal repaint, the counts that the
855
866
  picker's pane title carries to the Workspaces sheet and the **Lanes** button (a phone view created before that
856
867
  change needs `lanes rebuild <device>`; `lanes doctor` lists such devices), and an iPad Safari pass of the tablet mode
857
868
  (top bar, left panel, whole-tab view). The mode choice itself is unit-tested.
@@ -18,8 +18,8 @@ undoes the client's per-load single-pane start once
18
18
  view's sidebar shows beside its lanes. `tests/web/activation.test.mjs` (`node --test`) pins the activation matrix,
19
19
  the picker model and that render preference; `tests/unit/test_web_view.py` pins the size budgets and runs that
20
20
  suite when node is installed. After an upgrade,
21
- check that `__zjMobileUi`, `__zjSendControl`, `__zjSoftKbdCapture`, `__zjSyncInactiveCursorStyle` and `window.term`
22
- still exist in the served `app.js`.
21
+ check that `__zjMobileUi`, `__zjSendControl`, `__zjSoftKbdCapture` (with `sendFn` = `websockets.sendAnsiKey`),
22
+ `__zjSyncInactiveCursorStyle` and `window.term` still exist in the served `app.js`.
23
23
 
24
24
  The phone keyboard opens through a native tap on an input field. The bottom **Type here** field contains
25
25
  Zellij's original capture input in its closed shadow root, preserving its keyboard/IME handling. The client
@@ -30,7 +30,11 @@ The guard is installed before Zellij's first terminal or login modal: otherwise
30
30
  touchstart, touchend, click and server `SetSoftKeyboard` messages. The **Type here** field keeps the original input
31
31
  and its event handlers inside the closed shadow root (including its protection from password-manager extensions) and
32
32
  makes it a real, tappable field instead of a 1 px invisible focus target; keystrokes appear in the terminal and its
33
- internal padding/history stays invisible. Upstream cancels `pointerdown` on the extra keys, which also suppresses
33
+ internal padding/history stays invisible. Upstream creates that input as `type="password" autocomplete="new-password"`
34
+ (to switch off prediction); shown to the user, that gave iOS a secure-entry keyboard and the strong-password bar
35
+ (DEF-1734), so the shell makes it `type="text" autocomplete="off" autocapitalize="none"` and keeps upstream's
36
+ `autocorrect="off"`, `spellcheck="false"`, `inputmode="text"` and the `data-*ignore` attributes. Upstream cancels
37
+ `pointerdown` on the extra keys, which also suppresses
34
38
  their touch click in Safari, so the shell cancels `mousedown` instead: typing focus is kept while the native click
35
39
  still activates CTRL, the arrows and the other keys. The shell attaches only once server state has arrived — resizing
36
40
  before the control socket opens makes 0.45.1 throw `InvalidStateError` and leaves the server with the old size.
@@ -60,9 +64,94 @@ is stored locally. Minimum touch targets are 44 px (workspace cards are 74 px).
60
64
  `tests/mobile_view.py` verifies the touch-to-lane path with isolated holders; sidebar unit tests cover the
61
65
  complete touch target.
62
66
 
63
- Every byte the shell sends to a terminal goes through `__zjSendControl({type: 'Write', bytes})`
64
- (`sendToTerminal`); there is no focus-and-type fallback, since the focus guard above would make it type nowhere.
65
- The lane filter bar sends `Esc /` and the query as UTF-8 (`TextEncoder`), dropping control bytes and DEL.
67
+ ### Input, keys and clipboard (phone shell input v2, CAP-142)
68
+
69
+ Every byte the shell sends to a terminal goes through `send()` to `__zjSoftKbdCapture.sendFn`, which is upstream's
70
+ `websockets.sendAnsiKey`: a text frame on the terminal WebSocket, the path the client's own capture input, ESC/arrow
71
+ bar and xterm `onData` use (an armed CTRL/ALT on the modifier bar therefore applies to a one-character key, through
72
+ upstream's `__zjMobileMergeKey`). Until 2026-10-07 the shell posted `__zjSendControl({type: 'Write', bytes})` on the
73
+ control socket, whose message enum has no `Write` variant: the server logged "Ignoring unknown control message type"
74
+ and dropped every quick key and filter keystroke (DEF-1733). The quick keys send `\r`, `y`, `n`, `\x1b`, `\x03`,
75
+ `\x1b[A`, `\x1b[B` — upstream's own arrow bytes (CSI, never SS3; Zellij re-encodes a key for its pane). With no
76
+ `sendFn` the text is queued and sent by the next `refresh()` (no timer); the pinned client installs `sendFn` together
77
+ with the capture element the shell waits for, so the queue only covers an upstream reordering. A key the socket
78
+ refuses (it throws while not open) is dropped, never replayed later into whatever lane is focused then. There is no
79
+ focus-and-type fallback: the focus guard above would make it type nowhere (DEF-1529).
80
+
81
+ The lane filter bar never puts an Esc and other bytes in one frame. The server parses each text frame with termwiz,
82
+ which holds a leading ESC while more bytes follow: `\x1b/abc` became one Alt-`/` key whose raw bytes are the whole
83
+ frame, then `a`, `b`, `c` again as keys of their own, so the picker sidebar got `\x1b/abc` + `abc` and filtered on
84
+ `abcabc` — on every keystroke, since the one-frame filter re-sent the whole query each time (DEF-1751, INFERRED from
85
+ the independent review's reading of the Zellij 0.45.1 source; `tests/web/phone.test.mjs` models it). The filter now
86
+ sends only what changed. When the field goes from empty to text: a lone `Esc` frame (it leaves whatever the sidebar is
87
+ in — an open filter, a rename, a close prompt, the key sheet), then, no sooner than 60 ms later, `/` alone and the
88
+ text. After that, one DEL (`\x7f`, which the sidebar's filter takes as backspace, as it does `\x08`) for each
89
+ character of the old query after its common prefix with the new one, then the new query's characters after that
90
+ prefix; an emptied field or the Esc key sends a lone `Esc`. The text is the `TextEncoder` bytes with control bytes and
91
+ DEL dropped, decoded back to text. There is no `setTimeout`: `send()` keeps a pause marker in its queue after a lone
92
+ `Esc` and re-arms the one interval, whose next tick sends what follows once 60 ms have passed. The phone keeps the
93
+ last query it sent (`laneFilterPrev`); on any tab other than a picker it forgets it, empties the field and drops a
94
+ filter tail still waiting in the queue, without sending anything — the lane now has the focus, and an `Esc` there
95
+ can interrupt an agent (DEF-1752; the sidebar clears its own filter when it opens a lane). Limits: the deltas assume the
96
+ sidebar holds what the phone sent, which each new query's leading `Esc` restores — another client typing into the same
97
+ picker pane, or a character the sidebar does not print (a no-break space, a zero-width joiner), puts them out of step
98
+ until the field is cleared; and an `Esc` while the sidebar's upload strip is armed (`U`) returns the focus to the
99
+ lane, so the `/` and text after it could reach the lane unless the tab change arrives within the pause.
100
+
101
+ Typing in **Type here**: one character at a time flows through upstream's `input` handler unchanged (a diff of the
102
+ field against its no-break-space padding, with an 8 ms duplicate filter for the two `deleteContentBackward` events one
103
+ backspace fires). A text field brings back QuickType, whose multi-character inserts would lose a repeated letter to
104
+ that filter (`hello` → `helo`). The shell therefore cancels two cancelable `beforeinput` types and sends their text
105
+ whole: `insertText` longer than one character, and `insertReplacementText` of any length. The text is `data`, or
106
+ `dataTransfer.getData('text')` where an engine leaves `data` null. A replacement first sends one DEL per character it
107
+ replaces: the length of `getTargetRanges()[0]` (its `endOffset − startOffset` in the field) — iOS accepts a QuickType
108
+ suggestion over a partial word with a caret, not a selection, and names the replaced range there — else the field's
109
+ selection, else nothing (DEF-1753; before, only the selection was read, so a caret replacement sent `hehello`). Every
110
+ other type stays upstream's or another handler's: a composition (`insertCompositionText`, which the Input Events spec
111
+ makes uncancelable, and `insertFromComposition`) is upstream's diff, a paste is the `paste` handler's, a delete is a
112
+ key. Caveat, unverified on a device: an engine that names neither the range nor a selection gives the terminal the
113
+ partial word and then the suggestion (`hehello`); delete by hand or type without the suggestion bar.
114
+
115
+ Paste: a `paste` on the field is cancelled and its `text/plain` sent as ONE bracketed paste — `\x1b[200~`, the text
116
+ with `\r?\n` as `\r` and every end marker neutralised, `\x1b[201~` — so upstream's per-character path, which collapsed
117
+ `ll` to `l` and stripped the newlines (DEF-1735), never sees it. Only the end marker is touched: each run of ESCs
118
+ directly before `[201~` is removed (a run, so `\x1b\x1b[201~` or `\x1b[20\x1b[201~1~` cannot reassemble one), so the
119
+ clipboard cannot end the bracket early and run the rest as typed keys, while a deliberate ANSI paste keeps its other
120
+ escape sequences (until DEF-1755 every ESC was removed). On a picker tab there is no paste: the picker sidebar turns
121
+ on bracketed paste to take file drops and reads any paste as one (text says `that was not a file drop`; a pasted path
122
+ becomes an upload attempt), so the header's **Paste** is hidden there and a paste into **Type here** is cancelled,
123
+ sends nothing and says `Open a lane to paste` (DEF-1754). Explicit markers rather than xterm's `term.paste` (also
124
+ reachable, as `window.term`): `term.paste` brackets only when the server has turned on xterm's bracketed-paste mode,
125
+ which nothing in this tree shows; Zellij reads the markers as a paste and removes them for a pane that has not asked
126
+ for bracketed paste.
127
+
128
+ **Copy** and **Paste** sit in the header beside **More** (nine 44 px quick keys do not fit a 390 px phone, so the row
129
+ keeps its seven). Copy writes the terminal's selection when xterm has one, else the visible rows of
130
+ `window.term.buffer.active` (`translateToString(true)` per row, trailing blank rows dropped), with
131
+ `navigator.clipboard.writeText` called inside the tap: Safari refuses a write outside a user gesture, which is why the
132
+ client's clipboard add-on (OSC 52) and copy-on-select never reached the phone — upstream cancels `touchstart`, a drag
133
+ scrolls and a long press is a right click (DEF-1736). The buffer is the only text source: `initTerminal` loads xterm's
134
+ WebGL renderer (`addon-webgl`), so the DOM holds no row text. The copy is the whole visible screen, Zellij's frames
135
+ and bars included. Paste calls `navigator.clipboard.readText()` inside the tap (iOS shows its own Paste confirmation)
136
+ and sends the text through the bracketed path above; it is hidden on a picker tab. Both cancel `mousedown`, so an open
137
+ keyboard stays open. The header subtitle says `Copied`, `Pasted` or `Clipboard blocked` (no clipboard API, or a
138
+ refusal).
139
+
140
+ Hints (`Hold to send` and the clipboard results) hold the header subtitle for eight ticks of the one 200 ms interval:
141
+ `hint()` re-arms it, `tick()` counts down, restores the subtitle through `refresh()` and clears the interval again,
142
+ and `refresh()` leaves the subtitle alone while a hint is up (before, the next `MobileState` refresh overwrote it at
143
+ once, DEF-1739). **More** toggles: upstream's capture-phase `pointerdown` hook closes an open menu before the button's
144
+ click, which then reopened it (DEF-1738), so a window capture-phase `pointerdown` listener records whether the menu was
145
+ open and the click then leaves it closed. On a picker tab the dock shows the filter bar and hides **Panes** (a picker
146
+ tab has one pane, and upstream's pane list duplicates the picker): four items for the four grid columns, where five
147
+ wrapped **Type here** below the fixed-height dock (DEF-1737).
148
+
149
+ The pinned `app.js` facts this section relies on (`sendFn` = `sendAnsiKey`, the capture input's attributes and
150
+ handlers, the menu dismiss hook, `window.term`, the WebGL addon) were read on 2026-10-07 from the asset embedded in
151
+ the zellij 0.45.1 release binary: the bytes between the `app.js` and `auth.js` names in its asset table hash to this
152
+ `index.html`'s SRI pin (sha384 `zEYVY/2Dhk…`). `tests/web/phone.test.mjs` runs `mobile.js` against a fake page shaped
153
+ on those facts (`node --test`, no jsdom).
154
+
66
155
  Attention counts come from the picker sidebar's pane title (`WS · ◆1 ◇2 ▸6`, written by the sidebar as OSC 2):
67
156
  `parseCounts` (also `window.__lanesParseCounts`) feeds the picker subtitle, `document.title`, the Lanes button's
68
157
  `data-count` and the workspace cards, inside the existing `refresh()` / `renderCards()` — no fetch, file or timer.
@@ -75,8 +164,10 @@ phone device keeps the old picker pane until `lanes rebuild <device>` (`upgrade-
75
164
  `panes[].title` is still unchecked in a browser (no WebKit pass has run; fallback: the counts stay in the
76
165
  sidebar header). The quick-key row (`⏎ y n Esc ^C ↑ ↓`, `.lanes-quick`) shows on view
77
166
  tabs only; `refresh()` sets `--lanes-quick` (48 px or 0), which the terminal height subtracts. `n` and `^C` send
78
- only when `pointerup.timeStamp − pointerdown.timeStamp ≥ 400` (no `setTimeout`); the buttons cancel `mousedown`
79
- so a tap never moves focus.
167
+ only when `pointerup.timeStamp − pointerdown.timeStamp ≥ 400` (no `setTimeout`); `pointercancel` and `pointerleave`
168
+ disarm a key, and a `pointerup` without the key's own `pointerdown` sends nothing (before, a cancelled hold stayed
169
+ armed, so a pointer released over `^C` later sent it, DEF-1739). The buttons cancel `mousedown` so a tap never moves
170
+ focus.
80
171
 
81
172
  Colour (spec §6.3; the operator legend, token table and phone mockup are in [`docs/DESIGN.md`](../docs/DESIGN.md)):
82
173
  `mobile.css` uses only the generated `--lanes-*` tokens (legacy names such as `--lanes-bg` stay
@@ -90,13 +181,21 @@ chaining `window.__zjSyncInactiveCursorStyle`, which the pinned `app.js` calls i
90
181
  assigning the server theme wholesale (`SetConfig` is sent on each config-file reload). The handler then runs
91
182
  `applyTerminalBackground`, which writes the dark background inline on `<body>` and `#terminal`; the stylesheet's
92
183
  `!important` `--lanes-surface` rule for those two elements outranks it in either variant. An SRI bump of `app.js`
93
- re-opens this contract: re-read `startWsControl` and `installSoftKeyboardCapture` before accepting it.
184
+ re-opens this contract: re-read `startWsControl`, `installSoftKeyboardCapture`, `initWebSockets` (`sendAnsiKey`) and
185
+ `installMenuDismissHook` before accepting it.
94
186
  `index.html` carries one `theme-color` per colour scheme. Bundle budget (spec §8, `test_web.test_bundle_growth`):
95
- `mobile.css` ≤ 18 982 B and `mobile.js` ≤ 22 174 B; the caps are never raised — the rules sit one per line and the
96
- explanations live here rather than in the shipped files. MEASURED 2026-10-04 with `wc -c` after LNS-31 (plan Task
97
- 31) met Tasks 13/28: `mobile.js` 21 964 B (23 268 B before its comments moved to Code notes below) and `mobile.css`
98
- 18 945 B. A laptop or tablet also fetches `browser.js` (3 641 B) and `browser.css` (1 947 B); `test_web_view` pins
99
- that download at `LAPTOP_TABLET_JS_RATCHET` and `LAPTOP_TABLET_CSS_CAP` (DEF-1569).
187
+ `mobile.css` ≤ 18 982 B and `mobile.js` ≤ 24 258 B; the rules sit one per line and the explanations live here rather
188
+ than in the shipped files. MEASURED 2026-10-04 with `wc -c` after LNS-31 (plan Task 31) met Tasks 13/28: `mobile.js`
189
+ 21 964 B (23 268 B before its comments moved to Code notes below) and `mobile.css` 18 945 B. The JS cap was spec §8's
190
+ 22 174 B until phone shell input v2 (2026-10-07, DEF-1733): the working send path, the text keyboard, paste, Copy and
191
+ Paste, the dock, More and hint fixes grew `mobile.js` to 23 456 B MEASURED after the dead `Write` path, the redundant
192
+ focus/blur `is-typing` listeners and the file's header comment were removed and the new code was written compactly
193
+ (`kbd()`, `aria()`), so the cap rose by exactly 1 282 B to that size (`mobile.css` unchanged). The independent
194
+ review's fixes the same day (the filter's Esc-free frames and deltas, replacement ranges, no paste on a picker, only
195
+ the paste end marker neutralised; DEF-1751..DEF-1755) grew it to 24 258 B MEASURED, and the cap rose by exactly those
196
+ 802 B again. A laptop or tablet also fetches `browser.js` (3 641 B) and `browser.css` (1 947 B); `test_web_view` pins
197
+ that download at `LAPTOP_TABLET_JS_RATCHET` (27 899 B: 25 605 B plus the same 1 492 + 802 B of `mobile.js` growth)
198
+ and `LAPTOP_TABLET_CSS_CAP` (DEF-1569).
100
199
 
101
200
  ## Code notes
102
201
 
@@ -110,26 +209,50 @@ order.
110
209
  (`zellij:mobile-ui` set to `on`); a stored `off` stays.
111
210
  - The `HTMLElement.prototype.focus` guard: only a native tap on an input may open the keyboard; it is installed before
112
211
  upstream's first terminal or modal.
212
+ - `kbd()` / `aria()`: `window.__zjSoftKbdCapture` and an `aria-label` setter, shared to keep the phone budget.
113
213
  - `viewport()`: browser toolbars alone must not mean keyboard mode (`occluded` needs more than 120 px hidden). Zellij's
114
214
  input is in a closed shadow root, so `document.activeElement` is its host; `__zjSoftKbdCapture.isFocused` is read
115
215
  first.
116
- - `buildTypingField()`: upstream's capture input stays in its closed shadow root and becomes the real, tappable field.
216
+ - `setKeyboard()`: also the one place **Type here** gets `is-typing`, driven by upstream's own focus/blur listeners
217
+ (they set `isFocused` and fire `zellij:soft-keyboard-visibility`); the field's own focus/blur listeners were redundant.
218
+ - `buildTypingField()`: upstream's capture input stays in its closed shadow root and becomes the real, tappable field:
219
+ a text input (DEF-1734); `beforeinput` sends a multi-character `insertText` or any `insertReplacementText` whole,
220
+ after one DEL per character of `getTargetRanges()[0]` or else the selection (DEF-1753); `paste` sends one bracketed
221
+ paste (DEF-1735). See Input, keys and clipboard above.
117
222
  - `GLYPHS` / `parseCounts()`: counts from the picker sidebar's OSC 2 pane title (spec §6.4).
223
+ - `build()`, the header: the window capture-phase `pointerdown` listener records `menuWas` before upstream's dismiss
224
+ hook runs, so **More** closes an open menu instead of reopening it (DEF-1738); **Copy** and **Paste** (DEF-1736).
118
225
  - `build()`, the `.zj-mobile-modbar` keys: cancel `mousedown`, not `pointerdown` (upstream's `pointerdown` cancel kills
119
226
  Safari's touch click on the extra keys).
227
+ - `send()`: the terminal socket through `sendFn` (DEF-1733); queued only while `sendFn` is absent, never after a throw.
228
+ A `null` in the queue is a pause: what follows waits until 60 ms after the last frame sent (`sentAt`), and `send()`
229
+ re-arms the one interval to come back (DEF-1751).
230
+ - `paste`: the bracketed paste through `send()`, its end markers neutralised (DEF-1755); nothing on a picker tab, where
231
+ the sidebar reads a paste as a file drop (DEF-1754).
232
+ - `applyLaneFilter()`: the filter's UTF-8 query as deltas against `laneFilterPrev` — a lone Esc, a pause, `/` and the
233
+ text for a new query; DELs and the added text for an edit; a lone Esc and a pause to clear (DEF-1751).
234
+ - `clip()`: calls the clipboard API synchronously inside the tap (Safari's user-gesture rule) and hints the result.
235
+ - `screen()`: xterm's selection, else the visible rows of `term.buffer.active` (the WebGL renderer leaves no DOM text).
236
+ - `hint()`: holds the subtitle for eight ticks of the one interval (DEF-1739).
120
237
  - `buildQuickRow()` (spec §6.5): `n` and `^C` send after a 400 ms hold, measured from event timestamps with no timer;
121
- nothing takes focus.
122
- - `clearLaneFilter()`: the lone `Esc` leaves the sidebar's filter mode.
238
+ a cancelled or left key is disarmed and a release needs the key's own press (DEF-1739); nothing takes focus.
239
+ - `updateLaneFilterBar()`: off a picker tab, forgets the query and drops a filter tail still waiting in the queue,
240
+ sending nothing (the lane has the focus now, DEF-1752); the Esc key's lone `Esc` and pause leave the filter mode.
123
241
  - `lanesTheme()`: the shell's own terminal palette for both variants (spec §6.3, DEF-1413).
242
+ - `refresh()`: leaves the subtitle to a live hint, hides **Panes** (and **Paste**, DEF-1754) on a picker tab so the
243
+ dock's four columns hold its four items (DEF-1737), and flushes the `send()` queue after `updateLaneFilterBar()`, so a
244
+ filter tail is dropped before it could reach a lane.
124
245
  - `connect()`: waits for server state, because an earlier resize throws `InvalidStateError` in 0.45.1. `applyTheme` is
125
246
  re-applied after every `SetConfig` (the client calls the chained `__zjSyncInactiveCursorStyle` hook) and on the theme
126
247
  toggle.
127
248
  - The `zellij:soft-keyboard-visibility` listener: rotation also shrinks the viewport, so the shell sends
128
249
  `SoftKeyboardVisibilityChanged` itself to correct upstream's height-only keyboard heuristic.
129
- - `awaitingClient` (the one `setInterval`, 200 ms, cleared once attached): upstream installs its window hooks just after
130
- appending its mobile root.
250
+ - `tick()` / `arm()` (the one `setInterval`, 200 ms): polls `connect()` until attached, because upstream installs its
251
+ window hooks just after appending its mobile root; `hint()` and a paused `send()` re-arm it; each tick flushes the
252
+ queue, and it clears itself once attached, with no hint up and no pause waiting.
131
253
 
132
254
  Validate on Safari/WebKit at 440 × 763 (iPhone 17 Pro Max with browser chrome), 440 × 956 (Home Screen),
133
255
  838 × 390 (landscape Safari), and smaller phone widths. Check login, workspace search and switching,
134
- lane selection, pane navigation, font controls, rotation and the visible viewport while the keyboard opens.
256
+ lane selection, pane navigation, font controls, rotation and the visible viewport while the keyboard opens, and the
257
+ input checklist in the [runbook](../docs/RUNBOOK.md) (text keyboard, quick keys, paste, Copy and Paste).
135
258
  Use disposable view sessions and revoke any temporary test token afterward.
@@ -1,4 +1,3 @@
1
- /* Lanes browser shell; see web/README.md, Code notes */
2
1
  function laneShellMode({userAgent, coarse, width, height} = {}) {
3
2
  if (/iPhone|iPod|Android.*Mobile/i.test(userAgent) || (coarse && (width <= 600 || height <= 500))) return 'phone';
4
3
  return coarse ? 'tablet' : 'desktop';
@@ -49,6 +48,8 @@ if (typeof module === 'object') module.exports = {laneShellMode};
49
48
  arrow: '<path d="m9 5 7 7-7 7"/>',
50
49
  check: '<path d="m5 12 4 4L19 6"/>',
51
50
  search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
51
+ copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5h10"/>',
52
+ paste: '<rect x="5" y="5" width="14" height="16" rx="2"/><path d="M9 3h6v4H9z"/>',
52
53
  };
53
54
  const svg = name => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name]}</svg>`;
54
55
  const make = (tag, cls, text) => {
@@ -61,15 +62,16 @@ if (typeof module === 'object') module.exports = {laneShellMode};
61
62
  const button = (label, icon, action) => {
62
63
  const el = make('button', 'lanes-button');
63
64
  el.type = 'button';
64
- el.setAttribute('aria-label', label);
65
+ aria(el, label);
65
66
  el.innerHTML = svg(icon);
66
67
  el.append(make('span', '', label));
67
68
  el.addEventListener('click', action);
68
69
  return el;
69
70
  };
70
- let shell, heading, subtitle, dock, sheet, list, search, count, lanesButton, typingField, quickRow, quickOn;
71
- let laneFilterBar, laneFilterInput, laneFilterPrev = '';
72
- let api, term, previousCards = '', sheetOpen = false, returnFocus;
71
+ let shell, heading, subtitle, dock, sheet, list, search, count, lanesButton, panesButton, pasteKey, typingField, quickRow, quickOn;
72
+ let laneFilterBar, laneFilterInput, laneFilterPrev = '', sentAt = 0;
73
+ let api, term, previousCards = '', sheetOpen = false, returnFocus, menuWas, hintN = 0, timer;
74
+ const queue = [];
73
75
  let fontSize = 16;
74
76
  try {
75
77
  const stored = Number(localStorage.getItem('lanes:font-size'));
@@ -79,6 +81,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
79
81
  const data = () => state()?.data;
80
82
  const nativeClick = selector => document.querySelector(selector)?.click();
81
83
  const resize = () => window.dispatchEvent(new Event('zellij:rendering-resize'));
84
+ const kbd = () => window.__zjSoftKbdCapture, aria = (el, label) => el.setAttribute('aria-label', label);
82
85
 
83
86
  function viewport() {
84
87
  const vv = window.visualViewport;
@@ -87,7 +90,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
87
90
  root.style.setProperty('--lanes-offset-top', `${vv?.offsetTop ?? 0}px`);
88
91
  root.style.setProperty('--lanes-offset-left', `${vv?.offsetLeft ?? 0}px`);
89
92
  const occluded = !!vv && innerHeight - vv.height - vv.offsetTop > 120;
90
- const focused = window.__zjSoftKbdCapture?.isFocused || document.activeElement?.matches('input, textarea');
93
+ const focused = kbd()?.isFocused || document.activeElement?.matches('input, textarea');
91
94
  setKeyboard(occluded && focused);
92
95
  }
93
96
  function setKeyboard(visible) {
@@ -95,10 +98,10 @@ if (typeof module === 'object') module.exports = {laneShellMode};
95
98
  root.classList.toggle('lanes-keyboard', !!visible);
96
99
  resize();
97
100
  }
98
- typingField?.classList.toggle('is-typing', !!window.__zjSoftKbdCapture?.isFocused);
101
+ typingField?.classList.toggle('is-typing', !!kbd()?.isFocused);
99
102
  }
100
103
  function buildTypingField() {
101
- const capture = window.__zjSoftKbdCapture?.element;
104
+ const capture = kbd()?.element;
102
105
  const host = document.getElementById('zj-mobile-capture-host');
103
106
  if (!capture || !host) return null;
104
107
  const field = make('div', 'lanes-terminal-input');
@@ -109,14 +112,21 @@ if (typeof module === 'object') module.exports = {laneShellMode};
109
112
  host.removeAttribute('aria-hidden');
110
113
  host.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:1;pointer-events:auto;';
111
114
  capture.removeAttribute('aria-hidden');
112
- capture.setAttribute('aria-label', 'Type in terminal');
115
+ aria(capture, 'Type in terminal');
116
+ for (const a of ['type text', 'autocomplete off', 'autocapitalize none']) capture.setAttribute(...a.split(' '));
113
117
  capture.tabIndex = 0;
114
118
  capture.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;box-sizing:border-box;font-size:16px;' +
115
119
  'border:0;border-radius:12px;padding:0;margin:0;opacity:1;background:transparent;color:transparent;' +
116
120
  'caret-color:transparent;outline:none;pointer-events:auto;user-select:text;-webkit-user-select:text;';
117
121
  field.append(label, host);
118
- capture.addEventListener('focus', () => field.classList.add('is-typing'));
119
- capture.addEventListener('blur', () => field.classList.remove('is-typing'));
122
+ capture.addEventListener('beforeinput', ev => {
123
+ const t = ev.data ?? ev.dataTransfer?.getData('text'), r = ev.getTargetRanges?.()[0];
124
+ if (ev.cancelable && t && (ev.inputType == 'insertReplacementText' || ev.inputType == 'insertText' && t.length > 1)) {
125
+ ev.preventDefault();
126
+ send('\x7f'.repeat(r ? r.endOffset - r.startOffset : capture.selectionEnd - capture.selectionStart) + t);
127
+ }
128
+ });
129
+ capture.addEventListener('paste', ev => { ev.preventDefault(); paste(ev.clipboardData.getData('text')); });
120
130
  return field;
121
131
  }
122
132
  function focusTab(tab) {
@@ -153,7 +163,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
153
163
  }
154
164
  function openSheet() {
155
165
  returnFocus = document.activeElement;
156
- window.__zjSoftKbdCapture?.element.blur();
166
+ kbd()?.element.blur();
157
167
  document.activeElement?.blur();
158
168
  sheetOpen = true;
159
169
  sheet.hidden = false;
@@ -188,7 +198,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
188
198
  const active = item.name === activeName;
189
199
  const card = make('button', `lanes-workspace${active ? ' is-current' : ''}`);
190
200
  card.type = 'button';
191
- card.setAttribute('aria-label', `${item.name}${active ? ', current workspace' : ''}`);
201
+ aria(card, `${item.name}${active ? ', current workspace' : ''}`);
192
202
  if (active) card.setAttribute('aria-current', 'true');
193
203
  const monogram = make('span', 'lanes-monogram', Array.from(item.name)[0]?.toUpperCase() || 'L');
194
204
  const copy = make('span', 'lanes-workspace-copy');
@@ -211,22 +221,26 @@ if (typeof module === 'object') module.exports = {laneShellMode};
211
221
  const brand = make('img', 'lanes-brand');
212
222
  brand.src = '/lanes-assets/icon.svg'; brand.alt = 'Lanes';
213
223
  const context = make('button', 'lanes-context');
214
- context.type = 'button'; context.setAttribute('aria-label', 'Choose workspace');
224
+ context.type = 'button'; aria(context, 'Choose workspace');
215
225
  heading = make('strong', '', 'Lanes'); subtitle = make('span', '', 'Connecting…');
216
226
  context.append(heading, subtitle); context.addEventListener('click', openSheet);
217
- const more = button('More options', 'more', () => nativeClick('.zj-hamburger'));
218
- more.classList.add('lanes-more');
219
- header.append(brand, context, more);
220
- dock = make('nav', 'lanes-dock'); dock.setAttribute('aria-label', 'Workspace controls');
227
+ addEventListener('pointerdown', () => { menuWas = document.querySelector('.zj-mobile-menu.zj-open'); }, true);
228
+ const more = button('More options', 'more', () => menuWas ? menuWas = null : nativeClick('.zj-hamburger'));
229
+ const copyKey = button('Copy screen', 'copy', () => clip(c => c.writeText(screen()), () => hint('Copied')));
230
+ pasteKey = button('Paste', 'paste', () => clip(c => c.readText(), t => paste(t) && hint('Pasted')));
231
+ for (const b of [copyKey, pasteKey, more]) b.classList.add('lanes-more');
232
+ for (const b of [copyKey, pasteKey]) b.addEventListener('mousedown', ev => ev.preventDefault());
233
+ header.append(brand, context, copyKey, pasteKey, more);
234
+ dock = make('nav', 'lanes-dock'); aria(dock, 'Workspace controls');
221
235
  const spaces = button('Workspaces', 'workspaces', openSheet);
222
236
  lanesButton = button('Lanes', 'lanes', () => {
223
237
  const picker = pickerTab((currentTab()?.name || '').replace(/ ☰$/, ''));
224
238
  if (picker) focusTab(picker); else openSheet();
225
239
  });
226
- const panes = button('Panes', 'panes', () => nativeClick('.zj-pane'));
240
+ panesButton = button('Panes', 'panes', () => nativeClick('.zj-pane'));
227
241
  typingField = buildTypingField();
228
242
  laneFilterBar = buildLaneFilterBar();
229
- dock.append(spaces, lanesButton, panes, laneFilterBar, typingField);
243
+ dock.append(spaces, lanesButton, panesButton, laneFilterBar, typingField);
230
244
  sheet = make('div', 'lanes-sheet'); sheet.hidden = true;
231
245
  sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-labelledby', 'lanes-sheet-title');
232
246
  const panel = make('section', 'lanes-sheet-panel');
@@ -240,7 +254,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
240
254
  searchWrap.innerHTML = svg('search');
241
255
  search = make('input'); search.type = 'search'; search.placeholder = 'Find a workspace';
242
256
  search.autocomplete = 'off'; search.spellcheck = false;
243
- search.setAttribute('aria-label', 'Find a workspace');
257
+ aria(search, 'Find a workspace');
244
258
  search.addEventListener('input', renderCards); searchWrap.append(search);
245
259
  list = make('div', 'lanes-workspaces');
246
260
  const sessionButton = make('button', 'lanes-session-link', 'Switch session');
@@ -266,51 +280,71 @@ if (typeof module === 'object') module.exports = {laneShellMode};
266
280
  installFontControls();
267
281
  resize();
268
282
  }
269
- function sendToTerminal(bytes) {
270
- try { window.__zjSendControl?.({type: 'Write', bytes}); } catch (_) {}
283
+ function send(...frames) {
284
+ const f = kbd()?.sendFn;
285
+ queue.push(...frames);
286
+ if (f) try {
287
+ while (queue.length) {
288
+ if (queue[0] !== null) { f(queue.shift()); sentAt = Date.now(); }
289
+ else if (Date.now() - sentAt < 60) return arm();
290
+ else queue.shift();
291
+ }
292
+ } catch (_) { queue.length = 0; }
293
+ }
294
+ const paste = t => currentTab()?.name.endsWith(' ☰') ? hint('Open a lane to paste') :
295
+ (send('\x1b[200~' + t.replace(/\x1b+(?=\[201~)/g, '').replace(/\r?\n/g, '\r') + '\x1b[201~'), 1);
296
+ function applyLaneFilter(value) {
297
+ const q = [...new TextDecoder().decode(new TextEncoder().encode(value).filter(b => b > 31 && b != 127))];
298
+ const p = [...laneFilterPrev], s = q.join('');
299
+ if (s === laneFilterPrev) return;
300
+ let i = 0;
301
+ while (i < p.length && p[i] === q[i]) i++;
302
+ laneFilterPrev = s;
303
+ if (!s) send('\x1b', null);
304
+ else if (!p.length) send('\x1b', null, '/', s);
305
+ else send('\x7f'.repeat(p.length - i) + q.slice(i).join(''));
306
+ }
307
+ async function clip(f, ok) {
308
+ try { ok(await f(navigator.clipboard)); } catch (_) { hint('Clipboard blocked'); }
271
309
  }
272
- function applyLaneFilter(query) {
273
- const bytes = [...new TextEncoder().encode(query)].filter(b => b >= 0x20 && b !== 0x7f);
274
- sendToTerminal([0x1b, 0x2f, ...bytes]);
310
+ function screen() {
311
+ if (term.hasSelection()) return term.getSelection();
312
+ const b = term.buffer.active, rows = [];
313
+ for (let i = 0; i < term.rows; i++) rows.push(b.getLine(b.viewportY + i)?.translateToString(true));
314
+ return rows.join('\n').trimEnd();
275
315
  }
316
+ function hint(s) { text(subtitle, s); hintN = 8; arm(); }
276
317
  function buildQuickRow() {
277
318
  const row = make('div', 'lanes-quick');
278
319
  row.hidden = true;
279
- for (const [label, bytes, name, hold] of [['⏎', [13], 'Enter'], ['y', [121]], ['n', [110], 0, 1], ['Esc', [27]],
280
- ['^C', [3], 'Control C', 1], ['↑', [27, 91, 65], 'Up'], ['↓', [27, 91, 66], 'Down']]) {
281
- const key = make('button', hold ? 'is-destructive' : '', label);
320
+ for (const [label, key, name, hold] of [['⏎', '\r', 'Enter'], ['y'], ['n', 0, 0, 1], ['Esc', '\x1b'],
321
+ ['^C', '\x03', 'Control C', 1], ['↑', '\x1b[A', 'Up'], ['↓', '\x1b[B', 'Down']]) {
322
+ const b = make('button', hold ? 'is-destructive' : '', label);
282
323
  let down = -1;
283
- key.type = 'button'; if (name) key.setAttribute('aria-label', name);
284
- key.addEventListener('mousedown', ev => ev.preventDefault());
285
- key.addEventListener('pointerdown', ev => { down = ev.timeStamp; });
286
- key.addEventListener('pointerup', ev => {
287
- if (!hold || (down >= 0 && ev.timeStamp - down >= 400)) sendToTerminal(bytes); else text(subtitle, 'Hold to send');
324
+ b.type = 'button'; if (name) aria(b, name);
325
+ b.addEventListener('mousedown', ev => ev.preventDefault());
326
+ b.addEventListener('pointerdown', ev => { down = ev.timeStamp; });
327
+ for (const t of ['pointercancel', 'pointerleave']) b.addEventListener(t, () => { down = -1; });
328
+ b.addEventListener('pointerup', ev => {
329
+ if (down >= 0) !hold || ev.timeStamp - down >= 400 ? send(key || label) : hint('Hold to send');
288
330
  down = -1;
289
331
  });
290
- row.append(key);
332
+ row.append(b);
291
333
  }
292
334
  return row;
293
335
  }
294
- function clearLaneFilter() {
295
- sendToTerminal([0x1b]);
296
- laneFilterPrev = '';
297
- if (laneFilterInput) laneFilterInput.value = '';
298
- }
299
336
  function buildLaneFilterBar() {
300
337
  const bar = make('div', 'lanes-filter-bar');
301
- bar.setAttribute('aria-label', 'Filter lanes');
338
+ aria(bar, 'Filter lanes');
302
339
  bar.hidden = true;
303
340
  bar.innerHTML = svg('search');
304
341
  laneFilterInput = make('input');
305
342
  laneFilterInput.type = 'search'; laneFilterInput.placeholder = 'Filter lanes';
306
343
  laneFilterInput.autocomplete = 'off'; laneFilterInput.spellcheck = false;
307
- laneFilterInput.setAttribute('aria-label', 'Filter lanes in this workspace');
308
- laneFilterInput.addEventListener('input', () => {
309
- const q = laneFilterInput.value;
310
- if (q !== laneFilterPrev) { applyLaneFilter(q); laneFilterPrev = q; }
311
- });
344
+ aria(laneFilterInput, 'Filter lanes in this workspace');
345
+ laneFilterInput.addEventListener('input', () => applyLaneFilter(laneFilterInput.value));
312
346
  laneFilterInput.addEventListener('keydown', ev => {
313
- if (ev.key === 'Escape') { ev.preventDefault(); clearLaneFilter(); laneFilterInput.blur(); }
347
+ if (ev.key === 'Escape') { ev.preventDefault(); send('\x1b', null); laneFilterPrev = laneFilterInput.value = ''; laneFilterInput.blur(); }
314
348
  if (ev.key === 'Enter') { ev.preventDefault(); laneFilterInput.blur(); }
315
349
  });
316
350
  bar.append(laneFilterInput);
@@ -319,7 +353,10 @@ if (typeof module === 'object') module.exports = {laneShellMode};
319
353
  function updateLaneFilterBar(isPicker) {
320
354
  if (!laneFilterBar) return;
321
355
  laneFilterBar.hidden = !isPicker;
322
- if (!isPicker && laneFilterPrev) clearLaneFilter();
356
+ if (isPicker) return;
357
+ const wait = queue.indexOf(null);
358
+ if (wait >= 0) queue.length = wait;
359
+ laneFilterPrev = laneFilterInput.value = '';
323
360
  }
324
361
  function applyFont() {
325
362
  term.options.fontSize = fontSize;
@@ -343,8 +380,8 @@ if (typeof module === 'object') module.exports = {laneShellMode};
343
380
  const row = make('div', 'lanes-font-controls');
344
381
  row.append(make('span', '', 'Text size'));
345
382
  const smaller = make('button', '', 'A−'), larger = make('button', '', 'A+');
346
- smaller.setAttribute('aria-label', 'Smaller terminal text');
347
- larger.setAttribute('aria-label', 'Larger terminal text');
383
+ aria(smaller, 'Smaller terminal text');
384
+ aria(larger, 'Larger terminal text');
348
385
  const value = make('output', '', `${fontSize} px`); value.id = 'lanes-font-value';
349
386
  smaller.addEventListener('click', () => {fontSize = Math.max(14, fontSize - 1); applyFont();});
350
387
  larger.addEventListener('click', () => {fontSize = Math.min(24, fontSize + 1); applyFont();});
@@ -363,11 +400,13 @@ if (typeof module === 'object') module.exports = {laneShellMode};
363
400
  const counts = parseCounts(pickerTitle(name));
364
401
  const chips = countParts(counts).map(([glyph, n]) => glyph + n).join(' ');
365
402
  text(heading, name);
366
- text(subtitle, picker ? chips || 'Choose a lane to continue' : pane?.title || 'Your workspace');
403
+ if (!hintN) text(subtitle, picker ? chips || 'Choose a lane to continue' : pane?.title || 'Your workspace');
367
404
  lanesButton.setAttribute('aria-pressed', String(!!picker));
405
+ panesButton.hidden = pasteKey.hidden = !!picker;
368
406
  const needs = counts ? counts.approval + counts.input : 0;
369
407
  if (needs) lanesButton.dataset.count = String(needs); else delete lanesButton.dataset.count;
370
408
  updateLaneFilterBar(picker);
409
+ send();
371
410
  const quick = active && !picker;
372
411
  if (quick !== quickOn) {
373
412
  quickOn = quick;
@@ -379,7 +418,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
379
418
  renderCards();
380
419
  }
381
420
  function connect() {
382
- if (api || !window.__zjMobileUi || !window.term || !window.__zjSoftKbdCapture?.element) return;
421
+ if (api || !window.__zjMobileUi || !window.term || !kbd()?.element) return;
383
422
  if (!window.__zjMobileUi.getState()?.data?.tabs?.length) return;
384
423
  api = window.__zjMobileUi; term = window.term;
385
424
  build(); applyFont(); applyTheme();
@@ -396,7 +435,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
396
435
  const token = document.getElementById('token');
397
436
  if (!token || token.dataset.lanes) return;
398
437
  token.dataset.lanes = 'true';
399
- token.autocomplete = 'current-password'; token.setAttribute('aria-label', 'Login token');
438
+ token.autocomplete = 'current-password'; aria(token, 'Login token');
400
439
  token.closest('.security-modal-content').querySelector('h3').textContent = 'Connect to Lanes';
401
440
  token.closest('.security-modal-content').querySelector('.submit-btn').textContent = 'Connect';
402
441
  }
@@ -412,6 +451,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
412
451
  });
413
452
  document.addEventListener('focusout', () => requestAnimationFrame(viewport));
414
453
  new MutationObserver(() => {styleLogin(); connect();}).observe(document.body, {childList: true});
415
- const awaitingClient = setInterval(() => {connect(); if (api) clearInterval(awaitingClient);}, 200);
416
- styleLogin(); connect();
454
+ function tick() { connect(); send(); if (hintN && !--hintN) refresh(); if (api && !hintN && queue[0] !== null) timer = clearInterval(timer); }
455
+ function arm() { timer ||= setInterval(tick, 200); }
456
+ arm(); styleLogin(); connect();
417
457
  })();
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@vibelord/lanes",
3
- "version": "1.1.0",
4
- "commit": "3d79500ae78bdd95a81cc9ef59f1cdcef1cde5cc",
5
- "digest": "sha256:690fa7bb4e929589101ca519afea682f3084ca977b3244d1567e653f9aa172ba",
3
+ "version": "1.1.1",
4
+ "commit": "34ec2d7ba9a5f13e72c812d3e2fce116e170e260",
5
+ "digest": "sha256:c10719aaa2279205460136ed3589f349d1e22dce32ddfb77584586d68ae1b3a4",
6
6
  "executables": [
7
7
  "bin/lanes",
8
8
  "client/install.sh",