@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 +1 -1
- package/payload/lanes/README.md +2 -0
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/docs/ARCHITECTURE.md +7 -2
- package/payload/lanes/docs/RUNBOOK.md +53 -12
- package/payload/lanes/docs/USERGUIDE.md +16 -5
- package/payload/lanes/web/README.md +143 -20
- package/payload/lanes/web/mobile.js +95 -55
- package/payload/manifest.json +3 -3
package/package.json
CHANGED
package/payload/lanes/README.md
CHANGED
|
@@ -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
|
package/payload/lanes/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.1.
|
|
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
|
|
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
|
|
958
|
-
without opening the keyboard. `n` and `^C`
|
|
959
|
-
"Hold to send"
|
|
960
|
-
sends its text as UTF-8, so a filter like `ø`
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
|
843
|
-
the keyboard; `n` and `^C` need a 400 ms hold (a short tap says "Hold to send"
|
|
844
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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`);
|
|
79
|
-
|
|
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`
|
|
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` ≤
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
- `
|
|
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
|
-
- `
|
|
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
|
-
- `
|
|
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
|
|
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 =
|
|
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', !!
|
|
101
|
+
typingField?.classList.toggle('is-typing', !!kbd()?.isFocused);
|
|
99
102
|
}
|
|
100
103
|
function buildTypingField() {
|
|
101
|
-
const capture =
|
|
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
|
|
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('
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
218
|
-
more
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
240
|
+
panesButton = button('Panes', 'panes', () => nativeClick('.zj-pane'));
|
|
227
241
|
typingField = buildTypingField();
|
|
228
242
|
laneFilterBar = buildLaneFilterBar();
|
|
229
|
-
dock.append(spaces, lanesButton,
|
|
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
|
|
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
|
|
270
|
-
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
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,
|
|
280
|
-
['^C',
|
|
281
|
-
const
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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(
|
|
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
|
|
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
|
|
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();
|
|
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 (
|
|
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
|
|
347
|
-
larger
|
|
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 || !
|
|
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
|
|
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
|
-
|
|
416
|
-
|
|
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
|
})();
|
package/payload/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibelord/lanes",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"commit": "
|
|
5
|
-
"digest": "sha256:
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"commit": "34ec2d7ba9a5f13e72c812d3e2fce116e170e260",
|
|
5
|
+
"digest": "sha256:c10719aaa2279205460136ed3589f349d1e22dce32ddfb77584586d68ae1b3a4",
|
|
6
6
|
"executables": [
|
|
7
7
|
"bin/lanes",
|
|
8
8
|
"client/install.sh",
|