mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
421482e121 | ||
|
|
a898253089 | ||
|
|
d2a1d14eac | ||
|
|
4bb333e9bc | ||
|
|
fcec77131c | ||
|
|
4ad283c647 | ||
|
|
dbaf328c0a | ||
|
|
78bfe53dc7 | ||
|
|
7debebb7b3 | ||
|
|
64298101b0 | ||
|
|
45ca9c347b | ||
|
|
e702f3c151 | ||
|
|
aecada8c56 | ||
|
|
adeb22d7b2 | ||
|
|
20c2561a45 | ||
|
|
2b20288ca0 | ||
|
|
9209922ea4 | ||
|
|
2e25bfa9e0 | ||
|
|
3104e9945b | ||
|
|
21ae48a5c8 | ||
|
|
661fe3dc13 | ||
|
|
284f86b740 | ||
|
|
c481bf4f47 | ||
|
|
c6e13e4fcf | ||
|
|
969f273fec | ||
|
|
8392854619 | ||
|
|
d044406f8f | ||
|
|
218b03ceb7 | ||
|
|
8ce2e5acbb | ||
|
|
4bfe239083 | ||
|
|
9f9bdbb671 | ||
|
|
a07c663ca9 | ||
|
|
4b2e6a7c83 | ||
|
|
17eea3c230 | ||
|
|
c101b70678 | ||
|
|
b9ce850a2c | ||
|
|
fbab0ffbf8 | ||
|
|
e6eb3dd849 | ||
|
|
c54867d9f0 | ||
|
|
0746cffe6d | ||
|
|
6cbaf3f7b6 | ||
|
|
be7328c8eb | ||
|
|
1202e9baa2 | ||
|
|
a24b548389 | ||
|
|
6af38abc76 | ||
|
|
55b526bc1e | ||
|
|
4558536676 | ||
|
|
b965c3d346 | ||
|
|
ec5e4aa3e1 | ||
|
|
7990249e2d | ||
|
|
6d72b38db4 | ||
|
|
50e0d22def | ||
|
|
d7f6047529 | ||
|
|
02387b5b16 | ||
|
|
409fd658f2 | ||
|
|
5650be5200 | ||
|
|
00440c02e1 | ||
|
|
caf5248e22 | ||
|
|
5da25f782b | ||
|
|
66da91f78b | ||
|
|
dfb9f32e23 | ||
|
|
c848e7cf27 | ||
|
|
6c5b4a7a25 | ||
|
|
be04d3e5e0 | ||
|
|
dd01ea9927 | ||
|
|
39d87e363f | ||
|
|
b7fafb1c16 | ||
|
|
d2e72143e8 | ||
|
|
21beacf700 | ||
|
|
0d1b91188d | ||
|
|
fa9d5879b3 | ||
|
|
f1e5b82ecc | ||
|
|
c206d10e3a | ||
|
|
5a58d272ea | ||
|
|
6bb16fdad6 | ||
|
|
d331db1141 | ||
|
|
dbff114dda | ||
|
|
65e8271fbe | ||
|
|
b4618bb853 | ||
|
|
de1b48a63f | ||
|
|
6fedbd1b09 | ||
|
|
83d0caa209 | ||
|
|
13b2989886 | ||
|
|
88447e6c2b | ||
|
|
6adf750c50 | ||
|
|
1a04a75c3d | ||
|
|
4341d3dca8 | ||
|
|
fca7acd05f | ||
|
|
59eb509d47 | ||
|
|
526d396492 | ||
|
|
bcdccd14c4 | ||
|
|
9e032bdc3e | ||
|
|
497711a05e | ||
|
|
fe9b209f67 | ||
|
|
fbd69e62aa | ||
|
|
a54ad81684 | ||
|
|
0ec17633ba | ||
|
|
5e3dbf2057 | ||
|
|
d1bbb4cc26 | ||
|
|
e2f56dc077 | ||
|
|
ead3d34411 | ||
|
|
f1537a7887 |
@@ -445,6 +445,20 @@ count against the same 16, not 16 of each. An abandoned request no longer holds
|
|||||||
slot, because the routes release the waiter when the client disconnects, but a
|
slot, because the routes release the waiter when the client disconnects, but a
|
||||||
client that opens many concurrent waits against one session will still hit the cap.
|
client that opens many concurrent waits against one session will still hit the cap.
|
||||||
|
|
||||||
|
## Terminal capture (`GET /api/v1/sessions/:id/terminal`)
|
||||||
|
|
||||||
|
What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
|
||||||
|
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
|
||||||
|
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's
|
||||||
|
geometry was read. The capture runs synchronous tmux calls on the server; the
|
||||||
|
`Server-Timing` header reports `capture`, `prepare` and `total`.
|
||||||
|
|
||||||
|
| Query | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. |
|
||||||
|
| `tail=<bytes>` | Keep the newest `<bytes>` of the result (`truncationReason: 'tail'` when it cut). |
|
||||||
|
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
|
||||||
|
|
||||||
## Session lineage (`parentSessionId`)
|
## Session lineage (`parentSessionId`)
|
||||||
|
|
||||||
A create request may name the session that spawned it, which the web UI draws as a
|
A create request may name the session that spawned it, which the web UI draws as a
|
||||||
@@ -470,6 +484,32 @@ also pure decoration: it confers no permission, and a child is unaffected by its
|
|||||||
parent exiting. It appears on session state as `parentSessionId` (absent when
|
parent exiting. It appears on session state as `parentSessionId` (absent when
|
||||||
unresolved) and survives a server restart.
|
unresolved) and survives a server restart.
|
||||||
|
|
||||||
|
## Session model (`displayModel`)
|
||||||
|
|
||||||
|
Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a
|
||||||
|
session runs as far as the server knows it, for the web UI's session headers:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"displayModel": { "model": "qwen3.8-27b", "source": "screen" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`source` is where it came from, strongest first:
|
||||||
|
|
||||||
|
| `source` | Meaning |
|
||||||
|
| ----------------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| `custom-endpoint` | The session is pointed at a Custom Model Endpoint Profile; its `modelId` answers, whatever the CLI prints. |
|
||||||
|
| `statusline` | Claude's statusLine exporter reported it (`model.display_name`); follows an in-session `/model`. |
|
||||||
|
| `screen` | Read off the CLI's own footer (`capabilities.modelDetect`, today dsh and codex); follows a switch. |
|
||||||
|
| `config` | What the CLI's own config pins for the session (`capabilities.modelDetect.configResolver`, today dsh-TUI's route), while its screen names none. |
|
||||||
|
| `launch` | What the session was launched with (`--model`, the app-wide default, `<cli>Config.model`); nothing has reported since. |
|
||||||
|
|
||||||
|
Between `statusline` and `screen` the newest report wins. The field is absent when no
|
||||||
|
model is known (a shell, a CLI that reports none and was launched without one). `model`
|
||||||
|
is display text from a pane or a CLI report: control characters are stripped and it is at
|
||||||
|
most 64 characters, but treat it as untrusted text. A `statusline` or `screen` value is
|
||||||
|
persisted and restored after a server restart until the next report replaces it; a
|
||||||
|
`config` value is read again at every pane start, attach and relaunch instead.
|
||||||
|
|
||||||
## Approvals Inbox
|
## Approvals Inbox
|
||||||
|
|
||||||
Cross-session queue of prompts waiting on a human (permission dialogs,
|
Cross-session queue of prompts waiting on a human (permission dialogs,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -49,6 +49,8 @@ interface CliEntry {
|
|||||||
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? }
|
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? }
|
||||||
// — how this CLI's pane shows work, work it started in the background, and a turn
|
// — how this CLI's pane shows work, work it started in the background, and a turn
|
||||||
// that ended waiting for workers it will resume from
|
// that ended waiting for workers it will resume from
|
||||||
|
// .modelDetect?: { screenLine, screenLines? }
|
||||||
|
// (where this CLI's own chrome names the model it runs: SessionState.displayModel)
|
||||||
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -57,10 +59,14 @@ interface CliEntry {
|
|||||||
|
|
||||||
### Regexes that come from config
|
### Regexes that come from config
|
||||||
|
|
||||||
Four capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`. All four go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
Five capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine` and `capabilities.modelDetect.screenLine`. All five go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||||
|
|
||||||
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
||||||
|
|
||||||
|
`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 4, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?(<model>)`, three rows), and codex's ` <model> <effort> · ` footer on its last row. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. ⚠️ dsh-TUI's first field is the model only while its status bar's model field is on; switched off, it is the next field: the reasoning effort (` medium · <cwd>`), the session mode, or the folder name. So a captured field is not taken when it is one of the CLI's declared `modelDetect.rejectWords` (single tokens, compared ignoring case; dsh lists every effort id its adapters offer and the shipped mode ids) or the session's own working-directory basename (the shared reader's rule, for every CLI). Anything else the pattern captures is the model, so the official `deepseek-chat` / `deepseek-reasoner` ids are read.
|
||||||
|
|
||||||
|
`modelDetect.configResolver` names a READER in `src/model-config-resolvers.ts` (a name, never code in config, like a launcher profile) that resolves the model the CLI's own config pins for one session, for while its screen names none (the `config` source of `displayModel`, ranked below any report from the running CLI). It runs at every pane start, attach and relaunch, with the session's own launch config and env, and must be read-only, bounded (probe before read, no synchronous filesystem call) and return the model id alone. The one stock reader, `deepseek-route` (`src/deepseek-route-config.ts`), resolves dsh-TUI's route the way dsh composes it for the session's profile under the session's `DSH_HOME`: the last of `profiles/<profile>/cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying `config` for the `dsh-tui` row counts, and only when it names both `provider` and `model`. Anything in doubt answers nothing: a half-pinned route, a profile without dsh-TUI, an unreadable, oversized or symlinked-out layer, a file beyond its narrow YAML subset.
|
||||||
|
|
||||||
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
|
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
|
||||||
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
|
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
|
||||||
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
|
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
|
||||||
|
|||||||
@@ -201,6 +201,18 @@ not try to set one. Configure it where the harness does: `~/.dsh/settings.yaml`
|
|||||||
plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the
|
plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the
|
||||||
profile. That is also how you point dsh at a local or third-party provider.
|
profile. That is also how you point dsh at a local or third-party provider.
|
||||||
|
|
||||||
|
Codeman does READ the route, for display only: a session header names the model
|
||||||
|
the TUI's status line draws, and while it draws none (the status bar's model
|
||||||
|
field switched off, or not painted yet) the model the session's route config
|
||||||
|
pins (`src/deepseek-route-config.ts`). That is dsh-TUI's own rule: the last of
|
||||||
|
`profiles/<profile>/cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying
|
||||||
|
`config` for the `dsh-tui` row, and only when it names BOTH `provider` and
|
||||||
|
`model`; a half-pinned route is dropped whole by the TUI and shows nothing here.
|
||||||
|
`settings.yaml`'s `agent-default-model` is the headless default and is not read.
|
||||||
|
The reader never writes, follows no symlink out of the dsh home, and returns the
|
||||||
|
model id alone. The TUI can still reject a pinned route against its provider's
|
||||||
|
model catalog at startup; the status line, when on, then shows what it chose.
|
||||||
|
|
||||||
**Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides`
|
**Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides`
|
||||||
(so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
|
(so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
|
||||||
all flow through). Provider keys with *other* names are deliberately not: a dsh
|
all flow through). Provider keys with *other* names are deliberately not: a dsh
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
**Author**: Claude (session with Tim), 2026-09-15
|
**Author**: Claude (session with Tim), 2026-09-15
|
||||||
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
||||||
|
|
||||||
|
> **Update (tile grid, PR 1):** Pane B is now a `TerminalTile`
|
||||||
|
> (`terminal-tile.js`) and is no longer as plain as this spec describes: it
|
||||||
|
> reconnects after a drop, delivers input exactly once, has clickable paths
|
||||||
|
> and image paste, sizes its PTY without a floor and adopts `zc` columns, and
|
||||||
|
> the app-level terminal shortcuts follow the focused pane. Ctrl+W no longer
|
||||||
|
> closes anything (Close Session has no default key).
|
||||||
|
> See `docs/tile-grid-plan.md` and `architecture-invariants#split-pane-sessions`.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
Codeman's terminal area shows exactly one active session (pane) at a time —
|
Codeman's terminal area shows exactly one active session (pane) at a time —
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -189,7 +189,7 @@ followed by a wait races, and reports the previous turn's state.
|
|||||||
## Lineage
|
## Lineage
|
||||||
|
|
||||||
A create request can name the session that spawned it, through a body field or a header, and
|
A create request can name the session that spawned it, through a body field or a header, and
|
||||||
the dashboard then draws a lineage line from parent to child. The skill sets it automatically.
|
the dashboard then draws a lineage arc from parent to child. The skill sets it automatically.
|
||||||
|
|
||||||
It is resolved rather than trusted: an unresolvable parent is dropped silently rather than
|
It is resolved rather than trusted: an unresolvable parent is dropped silently rather than
|
||||||
failing the spawn, because a cosmetic field must never break a worker.
|
failing the spawn, because a cosmetic field must never break a worker.
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
|
|||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
| ------------------------------- | --------------------------------------------------------------- |
|
| ------------------------------- | --------------------------------------------------------------- |
|
||||||
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
|
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
|
||||||
| `Ctrl+W` | Kill the active session. |
|
|
||||||
| `Ctrl+Tab` | Next session. |
|
| `Ctrl+Tab` | Next session. |
|
||||||
| `Alt+[` / `Alt+]` | Previous / next tab. |
|
| `Alt+[` / `Alt+]` | Previous / next tab. |
|
||||||
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
|
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
|
||||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
|
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
|
||||||
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
|
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
|
||||||
|
|
||||||
|
`Ctrl+W` is not a Codeman shortcut: it goes to the terminal, where shells and agent CLIs
|
||||||
|
use it to delete the previous word. **Close Session** has no key by default; close a session
|
||||||
|
from its tab, or bind a key to it in App Settings → Shortcuts.
|
||||||
|
|
||||||
## Terminal
|
## Terminal
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
@@ -36,6 +39,22 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
|
|||||||
|
|
||||||
Anything you copy is cleaned on the way to the clipboard: each line loses the padding spaces a full-screen program paints across the rest of the row. Leading indentation is left exactly as it is, so indented code, a `git log` message body and `git diff` context lines paste back the way they looked on screen. An `Alt+drag` rectangular selection is copied exactly as it looks, so its columns stay lined up.
|
Anything you copy is cleaned on the way to the clipboard: each line loses the padding spaces a full-screen program paints across the rest of the row. Leading indentation is left exactly as it is, so indented code, a `git log` message body and `git diff` context lines paste back the way they looked on screen. An `Alt+drag` rectangular selection is copied exactly as it looks, so its columns stay lined up.
|
||||||
|
|
||||||
|
## Tile grid
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| --------------------------- | ------------------------------------------------------------ |
|
||||||
|
| `Ctrl+Shift+G` | Open or close the tile grid (needs the Tiles setting on). |
|
||||||
|
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
|
||||||
|
| `Ctrl+Shift+Arrows` | Move the focused tile one place: into an empty slot, or swap. |
|
||||||
|
| Drag a tile's header | Move the tile: onto another tile they swap, onto an empty slot it moves there. |
|
||||||
|
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
|
||||||
|
| `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. |
|
||||||
|
| Right-click the Tiles button | Choose how many tiles: 2, 4 or 6 (remembered). |
|
||||||
|
|
||||||
|
While the grid is open, `Ctrl+Tab` and `Alt+[` / `Alt+]` cycle through the tiles, and the
|
||||||
|
terminal shortcuts above act on the focused tile. **Remove Focused Tile** has no key by
|
||||||
|
default. See [Tile Grid](Tile-Grid).
|
||||||
|
|
||||||
## Everything else
|
## Everything else
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ enough to fix a typo an agent introduced while you are away from your desk.
|
|||||||
enforces it.
|
enforces it.
|
||||||
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
|
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
|
||||||
- The desktop home tab rail, which needs a wide window.
|
- The desktop home tab rail, which needs a wide window.
|
||||||
- Lineage lines, which are a desktop overlay.
|
- Lineage arcs, which are a desktop overlay.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ the tmux server or rebooting the machine.
|
|||||||
| To do this | Do that |
|
| To do this | Do that |
|
||||||
| ------------------------- | ------------------------------------------------------------------- |
|
| ------------------------- | ------------------------------------------------------------------- |
|
||||||
| Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. |
|
| Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. |
|
||||||
| Close one session | `Ctrl+W`, or the tab's close control. |
|
| Close one session | The tab's close control (`Ctrl+W` is delete-word in the terminal). |
|
||||||
| Stop the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. |
|
| Stop the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. |
|
||||||
| Stop everything | `tmux -L codeman kill-server`. |
|
| Stop everything | `tmux -L codeman kill-server`. |
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
|||||||
Chips for every optional header control, with a live preview of the resulting header:
|
Chips for every optional header control, with a live preview of the resulting header:
|
||||||
|
|
||||||
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
||||||
Manager, Attachments, File Viewer, Multi-monitor, Split, Plan Usage, Lifecycle Log, Monitor,
|
Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
|
||||||
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
||||||
Ultracode Windows, Cron.
|
Ultracode Windows, Cron.
|
||||||
|
|
||||||
@@ -71,7 +71,9 @@ window; off lists every file by its full path.
|
|||||||
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
||||||
New header controls never appear on phones. Split is desktop-only regardless of this
|
New header controls never appear on phones. Split is desktop-only regardless of this
|
||||||
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
||||||
resizable panes plus their divider have nowhere to go.
|
resizable panes plus their divider have nowhere to go. **Tiles** is desktop-only the same
|
||||||
|
way; it also enables the `Ctrl+Shift+G` grid toggle on this device. See
|
||||||
|
[Tile Grid](Tile-Grid).
|
||||||
|
|
||||||
This section also holds background-agent tracking, including whether to track agents for
|
This section also holds background-agent tracking, including whether to track agents for
|
||||||
every session or only the active tab.
|
every session or only the active tab.
|
||||||
@@ -89,7 +91,7 @@ every session or only the active tab.
|
|||||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
|
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
|
||||||
| Tall Tabs | Taller tab strip. |
|
| Tall Tabs | Taller tab strip. |
|
||||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||||
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
|
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
|
||||||
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
||||||
| Overview Home Screen | The phone home screen. On by default. |
|
| Overview Home Screen | The phone home screen. On by default. |
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ reloading while a permission prompt is blocking does not lose the red tab.
|
|||||||
| Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) |
|
| Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) |
|
||||||
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
|
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
|
||||||
| Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` |
|
| Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` |
|
||||||
| Close | `Ctrl+W` |
|
| Close | The tab's close control (no key by default) |
|
||||||
| Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) |
|
| Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) |
|
||||||
|
|
||||||
Tabs can also be dragged to reorder.
|
Tabs can also be dragged to reorder.
|
||||||
@@ -84,18 +84,13 @@ title is derived locally from the prompt's first sentence; no text leaves the ma
|
|||||||
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
|
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
|
||||||
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
|
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
|
||||||
|
|
||||||
### Lineage lines
|
### Lineage arcs
|
||||||
|
|
||||||
When one session spawns another (an agent starting a worker through the API), Codeman draws
|
When one session spawns another (an agent starting a worker through the API), Codeman draws
|
||||||
lines from the parent to each child, in the parent's colour, routed through the gaps between
|
a coloured arc under the strip connecting parent to child, with one colour per child. It is
|
||||||
tab rows so they never cover a tab or the terminal. Every family is always shown; selecting
|
how a fan-out of eight workers stays readable.
|
||||||
a tab draws its own family thicker and brighter. A dashed branch means that child is
|
|
||||||
working. It is how a fan-out of eight workers stays readable.
|
|
||||||
|
|
||||||
While any tab has spawned another, the strip keeps a little extra room between rows for the
|
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Arcs are
|
||||||
lines, so switching tabs never changes the header height.
|
|
||||||
|
|
||||||
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Lines are
|
|
||||||
skipped for tabs scrolled out of the strip.
|
skipped for tabs scrolled out of the strip.
|
||||||
|
|
||||||
## Header controls
|
## Header controls
|
||||||
@@ -123,6 +118,7 @@ The right side of the header. Almost all of these are off until you enable them
|
|||||||
| Cron ⏰ | Off | Scheduled jobs. |
|
| Cron ⏰ | Off | Scheduled jobs. |
|
||||||
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
|
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
|
||||||
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
|
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
|
||||||
|
| Tiles | Off, desktop only | Up to six live sessions side by side. See [Tile Grid](Tile-Grid). |
|
||||||
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
||||||
| Admin panel | Multi-user only | User administration. |
|
| Admin panel | Multi-user only | User administration. |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Tile Grid
|
||||||
|
|
||||||
|
Watch and drive up to six sessions at once, side by side in one window. Each tile is a
|
||||||
|
full live terminal: it reads, it takes your keystrokes, and it shows at a glance whether
|
||||||
|
its agent is working, idle, or waiting on you.
|
||||||
|
|
||||||
|
The grid is a desktop feature. It needs a window at least about 1180px wide, and it is
|
||||||
|
never offered in a popped-out session window.
|
||||||
|
|
||||||
|
## Turning it on
|
||||||
|
|
||||||
|
**App Settings → Header & Panels → Tiles.** This is a per-device setting, off by default,
|
||||||
|
so turning it on at your desk never puts the button on your phone. It shows a **Tiles**
|
||||||
|
button in the header, beside Split, and enables `Ctrl+Shift+G`.
|
||||||
|
|
||||||
|
## Opening a grid
|
||||||
|
|
||||||
|
- **Tiles button**: one click shows the tiles straight away, as many as you last chose
|
||||||
|
(six until you choose; fewer if the window is too small or you have fewer sessions open).
|
||||||
|
You get the grid you last had, its tiles where they were, topped up with your open
|
||||||
|
sessions in tab order; if there is none, an open split's two first; otherwise your open
|
||||||
|
sessions in tab order, with the session you are on focused. With the grid open, the same
|
||||||
|
button closes it.
|
||||||
|
- **Rest the pointer on the Tiles button** (or tab to it) for a short card that shows the
|
||||||
|
count it opens and what a click and a right-click do.
|
||||||
|
- **Right-click the Tiles button** (or press `Shift+F10` on it) to choose how many tiles:
|
||||||
|
**2**, **4** or **6**, each drawn as its layout. Your choice is remembered on this device
|
||||||
|
and is what the next click opens. With the grid open, picking a count re-forms it: the
|
||||||
|
tile you are in always stays, extra tiles leave from the end, new ones join from your tab
|
||||||
|
order. A count the window is too small for is greyed out, with the reason.
|
||||||
|
- **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does.
|
||||||
|
- **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it. With
|
||||||
|
the grid closed it opens what the Tiles button would show, with that session among them
|
||||||
|
(still the count you chose in total). On macOS use
|
||||||
|
`Cmd`: `Ctrl`+click there opens the tab's rename instead.
|
||||||
|
- **Drag a tab onto a tile** to replace that tile with it (the replaced session keeps
|
||||||
|
running), or onto an empty slot to add it. Dragging a session that is already tiled onto
|
||||||
|
another tile swaps the two.
|
||||||
|
- **"Open group as tiles"** in a tab group's menu, in the vertical tab rail with groups.
|
||||||
|
- **Run**: a session you start from this browser tab's Run button while the grid is open
|
||||||
|
joins it. Sessions started elsewhere (an agent, another device, a cron job) do not.
|
||||||
|
|
||||||
|
The layout follows the tile count: 1x1, 2x1, three side by side on a wide screen (else a
|
||||||
|
2x2 with one empty slot), 2x2, 3x2. The grid holds at most six tiles, fewer when the
|
||||||
|
window is too small for six; the count menu says which limit applies.
|
||||||
|
|
||||||
|
Opening, the tiles fade in one after another and each terminal appears once its history
|
||||||
|
has loaded, rather than scrolling through it. Closing with the button, the tiles stay
|
||||||
|
on screen, dimmed, until the single session behind them has loaded, then fade away. With
|
||||||
|
reduced motion turned on in your system settings, the grid opens and closes at once.
|
||||||
|
|
||||||
|
## A tile
|
||||||
|
|
||||||
|
Each tile has a small header: `● [logo] name · model ......... ⋯ ⤢ ×`
|
||||||
|
|
||||||
|
| Part | What it does |
|
||||||
|
| ------ | ------------------------------------------------------------------------------------------------ |
|
||||||
|
| `●` | The session's state: working, idle, waiting on you, needs you (red, and the tile's border pulses), error, ended. Hover the header for how long. |
|
||||||
|
| logo | Which agent runs in the tile (Claude Code, Codex, DeepSeek, Shell, ...). Hover it for the agent and the model by name. |
|
||||||
|
| name | Double-click to rename the session. |
|
||||||
|
| model | The model the session runs, when Codeman knows it: what the agent itself reports (it follows a `/model` switch), else the model its own config pins (DeepSeek's route, shown "from config"), else the model it was started with. Nothing when unknown. |
|
||||||
|
| `⋯` | The session menu: options, open in a new window, close the session. |
|
||||||
|
| `⤢` | Zoom: the tile fills the grid; press it again (or `Alt+Shift+Enter`) to get the grid back. |
|
||||||
|
| `×` | Remove the tile. The session keeps running; close it from `⋯` if you want it gone. |
|
||||||
|
|
||||||
|
Click a tile to focus it. The focused tile has the accent border, takes your keyboard, and
|
||||||
|
is the session every panel follows: files, git status, respawn and Ralph, subagent windows,
|
||||||
|
voice and image paste. Tabs of tiled sessions carry a small underline.
|
||||||
|
|
||||||
|
Drag the thin lines between tiles to resize columns and rows. A tile never gets smaller than
|
||||||
|
about 60 columns; when the window is too small for all the tiles, the grid shows the focused
|
||||||
|
one on its own until the window is big enough again.
|
||||||
|
|
||||||
|
A tile whose session is not running shows **Not attached** with an **Attach** button. A tile
|
||||||
|
whose agent exited inside its pane says so instead; close that session from `⋯`.
|
||||||
|
|
||||||
|
## Moving tiles
|
||||||
|
|
||||||
|
Drag a tile by its header (anywhere but its buttons) onto another tile and the two trade
|
||||||
|
places. Drop it on an empty slot and it moves there, leaving its old place empty; nothing else
|
||||||
|
moves, so the empty slot can be anywhere in the grid. The dropped tile takes the focus. Press
|
||||||
|
`Escape` or let go anywhere else and nothing changes, not even which tile has the focus: a
|
||||||
|
header focuses its tile when you click it, not when you press it.
|
||||||
|
|
||||||
|
With the keyboard, `Ctrl+Shift+Arrows` moves the focused tile one place left, right, up or
|
||||||
|
down: into the empty slot if that is the place, else trading places with the tile there. It
|
||||||
|
keeps the focus.
|
||||||
|
|
||||||
|
A moved tile takes the size of the place it lands in: column widths and row heights stay
|
||||||
|
where you dragged the dividers. Tiles do not move while one is zoomed. Where everything is,
|
||||||
|
the empty slot included, is saved with the grid and comes back on reload.
|
||||||
|
|
||||||
|
Closing a tile leaves its place empty when the grid keeps its shape (six tiles to five), and a
|
||||||
|
new tile takes the first empty place. When the number of tiles changes the grid's shape (four
|
||||||
|
tiles to five is two columns to three), the tiles keep their places if they still fit, or line
|
||||||
|
up again from the top left. `Alt+Shift+Arrows` and `Ctrl+Tab` never stop on an empty slot.
|
||||||
|
|
||||||
|
## Keys
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| ------------------------ | ---------------------------------------------------------- |
|
||||||
|
| `Ctrl+Shift+G` | Open or close the grid. |
|
||||||
|
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
|
||||||
|
| `Ctrl+Shift+Arrows` | Move the focused tile left, right, up or down. |
|
||||||
|
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
|
||||||
|
| `Ctrl+Tab`, `Alt+[` `]` | Cycle through the tiles. |
|
||||||
|
| `Ctrl+L` | Clear the focused tile. |
|
||||||
|
| `Ctrl` `+` / `Ctrl` `-` | Tile font size (tiles have their own, smaller font). |
|
||||||
|
|
||||||
|
All of them can be rebound in App Settings → Shortcuts, where **Remove Focused Tile** can
|
||||||
|
also get a key. Outside the grid, `Alt+Shift+Arrows`, `Ctrl+Shift+Arrows` and
|
||||||
|
`Alt+Shift+Enter` go to the terminal as usual. While it is open, `Alt+Shift+Arrows` and
|
||||||
|
`Ctrl+Shift+Arrows` in a text field (renaming a tile, the file editor) still select text there;
|
||||||
|
inside a tile they focus and move tiles, so a terminal editor there (nano, micro, emacs) does
|
||||||
|
not get them. With the Tiles setting off, `Ctrl+Shift+G` does nothing.
|
||||||
|
|
||||||
|
## Leaving the grid
|
||||||
|
|
||||||
|
Clicking the tab of a session that is not tiled (or picking it with `Alt+1-9` or the
|
||||||
|
session finder) shows that session on its own, the normal single view. The grid is
|
||||||
|
remembered: the Tiles button or `Ctrl+Shift+G` brings it straight back. Going Home does the
|
||||||
|
same. Narrowing the window below the desktop width also returns to the single view.
|
||||||
|
|
||||||
|
The grid is saved on this device and comes back when you reload the page, with its focus,
|
||||||
|
zoom and column widths. A session that was closed in the meantime is simply left out.
|
||||||
|
|
||||||
|
Split shows the same logo, name and model above both of its panes.
|
||||||
|
|
||||||
|
The grid and Split are never open together: opening the grid turns an open split into two
|
||||||
|
tiles, and Split is unavailable while the grid is open.
|
||||||
|
|
||||||
|
## Read next
|
||||||
|
|
||||||
|
- [The Dashboard](The-Dashboard) - the single view, tabs and the header.
|
||||||
|
- [Keyboard Shortcuts](Keyboard-Shortcuts) - every binding.
|
||||||
|
- [Settings Reference](Settings-Reference) - where the Tiles setting lives.
|
||||||
@@ -22,18 +22,13 @@ transcript: what it was asked to do, what it is doing, and what it returned.
|
|||||||
This is the feature that makes a fan-out legible. Without it, a lead session that spawned
|
This is the feature that makes a fan-out legible. Without it, a lead session that spawned
|
||||||
eight workers looks like a stalled terminal for several minutes.
|
eight workers looks like a stalled terminal for several minutes.
|
||||||
|
|
||||||
## Session lineage lines
|
## Session lineage arcs
|
||||||
|
|
||||||
The tab strip draws lines from every tab to the tabs it spawned. That covers the other
|
The tab strip draws a coloured arc from a parent tab to any tab it spawned, one colour per
|
||||||
direction of fan-out: not subagents inside one session, but whole sessions started by an
|
child. That covers the other direction of fan-out: not subagents inside one session, but
|
||||||
agent through the API.
|
whole sessions started by an agent through the API.
|
||||||
|
|
||||||
The lines form one tree per spawning tab, in that tab's colour, and run only through the
|
Desktop only, on by default, and toggled in **App Settings → Appearance**. Arcs are skipped
|
||||||
gaps between tab rows, so they never cover a tab name or the terminal. Select a tab and its
|
|
||||||
family (the tabs it spawned, or its parent and siblings) is drawn thicker and brighter. A
|
|
||||||
dashed branch means that child is working.
|
|
||||||
|
|
||||||
Desktop only, on by default, and toggled in **App Settings → Appearance**. Lines are skipped
|
|
||||||
for tabs scrolled out of view.
|
for tabs scrolled out of view.
|
||||||
|
|
||||||
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the spawning side.
|
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the spawning side.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
**Using it**
|
**Using it**
|
||||||
|
|
||||||
- [The Dashboard](The-Dashboard)
|
- [The Dashboard](The-Dashboard)
|
||||||
|
- [Tile Grid](Tile-Grid)
|
||||||
- [Agent CLIs](Agent-CLIs)
|
- [Agent CLIs](Agent-CLIs)
|
||||||
- [Custom Model Endpoints](Custom-Model-Endpoints)
|
- [Custom Model Endpoints](Custom-Model-Endpoints)
|
||||||
- [Working With Files](Working-With-Files)
|
- [Working With Files](Working-With-Files)
|
||||||
|
|||||||
@@ -119,3 +119,16 @@ export function compileVersionRegex(source: string): RegExp | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many capture groups a regex source declares (named ones included), or -1 when it
|
||||||
|
* does not compile. Matching the empty string against `source|` always succeeds through
|
||||||
|
* the empty alternative, and the match array then has one slot per group.
|
||||||
|
*/
|
||||||
|
export function countCaptureGroups(source: string): number {
|
||||||
|
try {
|
||||||
|
return (new RegExp(`${source}|`).exec('') as RegExpExecArray).length - 1;
|
||||||
|
} catch {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
|
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
|
||||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
||||||
import type { McpConfigFormat } from './types.js';
|
import type { McpConfigFormat, ModelConfigResolverName } from './types.js';
|
||||||
|
|
||||||
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
||||||
const cliId = z
|
const cliId = z
|
||||||
@@ -370,6 +370,43 @@ const capabilitiesSchema = z
|
|||||||
model: z
|
model: z
|
||||||
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
|
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
|
||||||
.strict(),
|
.strict(),
|
||||||
|
// Same guard as the workDetect patterns: ~/.codeman/clis.json can set it, and it runs
|
||||||
|
// over the foot of a pane capture every time a session settles. Exactly one capture
|
||||||
|
// group (the model), checked here so a pattern without one fails at LOAD time instead
|
||||||
|
// of silently never naming a model.
|
||||||
|
modelDetect: z
|
||||||
|
.object({
|
||||||
|
screenLine: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.refine(
|
||||||
|
(src) => compileVersionRegex(src) !== null && countCaptureGroups(src) === 1,
|
||||||
|
'screenLine must be a regex compileVersionRegex() accepts (at most 200 characters, no nested quantifiers) with exactly one capture group'
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
// Bounded hard, like watchingLines: every row it adds is one more row the agent
|
||||||
|
// itself may be able to write.
|
||||||
|
screenLines: z.number().int().min(1).max(4).optional(),
|
||||||
|
// Single tokens, bounded: each is compared against one captured field.
|
||||||
|
rejectWords: z.array(z.string().min(1).max(40).regex(/^\S+$/)).max(32).optional(),
|
||||||
|
// A NAMED reader (src/model-config-resolvers.ts), never code in config.
|
||||||
|
configResolver: z.enum(['deepseek-route'] as const satisfies readonly ModelConfigResolverName[]).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
// Typos rather than configurations, refused at LOAD time like watchingLines.
|
||||||
|
.refine(
|
||||||
|
(v) => v.screenLine !== undefined || v.configResolver !== undefined,
|
||||||
|
'modelDetect declares nothing to read'
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(v) => v.screenLines === undefined || v.screenLine !== undefined,
|
||||||
|
'screenLines has nothing to bound without a screenLine'
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(v) => v.rejectWords === undefined || v.screenLine !== undefined,
|
||||||
|
'rejectWords has nothing to filter without a screenLine'
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
privilegedParams: z
|
privilegedParams: z
|
||||||
.array(
|
.array(
|
||||||
z
|
z
|
||||||
|
|||||||
@@ -622,6 +622,16 @@ const CODEX: CliEntry = {
|
|||||||
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
|
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
|
||||||
watchingLines: 3,
|
watchingLines: 3,
|
||||||
},
|
},
|
||||||
|
// The footer under the composer, measured on a live 0.147.0 pane:
|
||||||
|
// ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort,
|
||||||
|
// cwd). It is the pane's LAST row, below the composer, so the transcript never
|
||||||
|
// reaches it, and the effort word right after the model is codex's own format: an
|
||||||
|
// open slash-command popup or a bare line of prose does not have that shape. A
|
||||||
|
// footer without an effort word (a model with no reasoning setting) is not read,
|
||||||
|
// and the session keeps its last known or launch model.
|
||||||
|
modelDetect: {
|
||||||
|
screenLine: String.raw`^ {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:none|minimal|low|medium|high|xhigh|max|default) · `,
|
||||||
|
},
|
||||||
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
|
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
|
||||||
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
|
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
|
||||||
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
|
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
|
||||||
@@ -1222,6 +1232,35 @@ const DEEPSEEK: CliEntry = {
|
|||||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||||
// Model is NOT a session field for dsh — it is a profile composition entry.
|
// Model is NOT a session field for dsh — it is a profile composition entry.
|
||||||
model: { source: 'none' },
|
model: { source: 'none' },
|
||||||
|
// So the screen is where the model is known: dsh-TUI resolves the route itself
|
||||||
|
// (profile cordis.yml pin, else the persisted `/model` choice, else its default;
|
||||||
|
// lib/types/modelRoute.js) and its status line draws "the route requests actually
|
||||||
|
// take", model first (StatusLine.js; `statusBar.model` is on by default and forced
|
||||||
|
// on in minimal mode). Measured on dsh-TUI 0.10.0-beta.1: the composer's rounded box
|
||||||
|
// and, on the row right under its bottom border, ` qwen3.8-27b · medium · <cwd>`.
|
||||||
|
// The border anchors it: nothing the agent writes can sit below the composer, and a
|
||||||
|
// suggestion popup there starts with `/` or `+`, never a model id.
|
||||||
|
// ⚠ The first field is the model only while the status bar's model field is on (the
|
||||||
|
// default). Switched off, the first field is the next one (StatusLine.js): tokens per
|
||||||
|
// second (`12 t/s`) and the token count (`1.2k→3.4k`), which the pattern cannot match,
|
||||||
|
// then the reasoning effort (` medium · th-config`, measured live), then the session
|
||||||
|
// mode, then the cwd's basename. So `rejectWords` lists what those can be, from the
|
||||||
|
// dsh 0.1.1-rc.2 / dsh-TUI 0.10.0-beta.1 sources: every effort id (pi-ai's
|
||||||
|
// THINKING_LEVELS and the DeepSeek adapter's off/low/high/max), and the shipped mode
|
||||||
|
// ids. A mode's drawn label (`plan mode`, `full access`, CJK) never matches one token,
|
||||||
|
// and a field equal to the session's folder name is refused by the shared reader.
|
||||||
|
// Known gaps, all off by default: a custom mode id drawn raw, a git branch or a
|
||||||
|
// one-word session title as the first field; and the non-compact layout, whose
|
||||||
|
// left/right justification never ends a field with ` · `, so nothing is read there
|
||||||
|
// and the session shows its route config.
|
||||||
|
modelDetect: {
|
||||||
|
screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`,
|
||||||
|
screenLines: 3,
|
||||||
|
rejectWords: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'default', 'plan', 'full'],
|
||||||
|
// With the status bar's model field off (or before it paints), the route the
|
||||||
|
// session's profile pins, read the way dsh-TUI resolves it: src/deepseek-route-config.ts.
|
||||||
|
configResolver: 'deepseek-route',
|
||||||
|
},
|
||||||
// Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the
|
// Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the
|
||||||
// launcher's own default, `workspace-write`, which already asks. Clamping to
|
// launcher's own default, `workspace-write`, which already asks. Clamping to
|
||||||
// `read-only` instead would break the workspace rather than protect it.
|
// `read-only` instead would break the workspace rather than protect it.
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ export interface CliVariant {
|
|||||||
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
|
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
|
||||||
export type NewlineSequence = 'line-feed' | 'esc-enter';
|
export type NewlineSequence = 'line-feed' | 'esc-enter';
|
||||||
|
|
||||||
|
/** The config readers `capabilities.modelDetect.configResolver` may name (src/model-config-resolvers.ts). */
|
||||||
|
export type ModelConfigResolverName = 'deepseek-route';
|
||||||
|
|
||||||
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
|
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
|
||||||
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
|
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
|
||||||
|
|
||||||
@@ -462,6 +465,41 @@ export interface CliCapabilities {
|
|||||||
statusLineTelemetry: boolean;
|
statusLineTelemetry: boolean;
|
||||||
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
|
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
|
||||||
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
|
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
|
||||||
|
/**
|
||||||
|
* Where this CLI draws the model it is running, so a session header can name it
|
||||||
|
* (`SessionState.displayModel`, src/session-display-model.ts).
|
||||||
|
*
|
||||||
|
* `screenLine` is the source of a regex with exactly ONE capture group, the model. It
|
||||||
|
* runs over the last `screenLines` non-blank rows of the pane capture the idle/working
|
||||||
|
* probe already takes (rows joined with `\n`, so a pattern may span them), which costs no
|
||||||
|
* extra tmux call and re-reads the footer at every turn transition, so an in-session
|
||||||
|
* `/model` switch is followed.
|
||||||
|
*
|
||||||
|
* ⚠ The rows are pane text and the agent writes most of a pane, so a pattern must anchor
|
||||||
|
* on chrome only this CLI draws (the row under its own composer, an effort word in its
|
||||||
|
* own footer format), never on a shape the agent could print in its transcript. Measured
|
||||||
|
* on a live pane per CLI; absent means the CLI's screen is never read for a model and
|
||||||
|
* the session shows its launch model, if any.
|
||||||
|
*
|
||||||
|
* `configResolver` names a reader (src/model-config-resolvers.ts) that resolves the
|
||||||
|
* model the CLI's own config pins, the way that CLI resolves it for the session, for
|
||||||
|
* while the screen names none (its status line switched off, or not drawn yet). Read
|
||||||
|
* once per pane start, attach or relaunch, bounded and read-only; the screen still
|
||||||
|
* wins whenever it names a model. A NAMED reader, like a launcher profile, so the
|
||||||
|
* per-CLI behaviour stays data here and code in one module.
|
||||||
|
*/
|
||||||
|
modelDetect?: {
|
||||||
|
screenLine?: string;
|
||||||
|
screenLines?: number;
|
||||||
|
/**
|
||||||
|
* Words the `screenLine` field can show when it is NOT the model (a footer whose model
|
||||||
|
* field is switched off shows the next field there), compared lower-cased. A field
|
||||||
|
* equal to the session's own working-directory basename is never the model either,
|
||||||
|
* for every CLI; that rule is the shared reader's, not data.
|
||||||
|
*/
|
||||||
|
rejectWords?: string[];
|
||||||
|
configResolver?: ModelConfigResolverName;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* Params a non-granted multi-user owner may not set freely, and what they are forced to.
|
* Params a non-granted multi-user owner may not set freely, and what they are forced to.
|
||||||
* Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
|
* Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The model a DeepSeek Harness (`dsh`) session's TUI is configured to
|
||||||
|
* use, read from its route config, for a session header whose screen names no model
|
||||||
|
* yet (the status bar's model field switched off, or not drawn yet). See
|
||||||
|
* `SessionState.displayModel` (src/session-display-model.ts): the screen still wins
|
||||||
|
* whenever it names a model, since it is what the running TUI actually uses.
|
||||||
|
*
|
||||||
|
* ## How dsh-TUI resolves its route (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1)
|
||||||
|
*
|
||||||
|
* A profile is a stack of loader patch layers over an empty root, in this order
|
||||||
|
* (`@deepseek-ai/dsh` profile-boot): every bundle's patch layer, the profile's own
|
||||||
|
* `$DSH_HOME/profiles/<profile>/cordis.patch.yml`, the home-level
|
||||||
|
* `$DSH_HOME/cordis.patch.yml` (it outranks the profile layer), then `--patch`
|
||||||
|
* overlays (Codeman passes none). A patch targets a row by `id`; one whose `name`
|
||||||
|
* does not match the row's is skipped; every other key REPLACES the row's field
|
||||||
|
* whole (`applyEntryPatches`), so the last layer carrying `config` for the `dsh-tui`
|
||||||
|
* row defines all of it.
|
||||||
|
*
|
||||||
|
* dsh-TUI then takes its model route from that config only when it names BOTH
|
||||||
|
* `provider` and `model` (`lib/types/modelRoute.js`, issue #67). Anything less is
|
||||||
|
* dropped whole and the TUI falls back to the persisted `/model` choice, then to its
|
||||||
|
* own default: neither is in the config, so neither is answered here. The bundle's
|
||||||
|
* own row pins `provider: deepseek-official` alone, by design, so only the two user
|
||||||
|
* layers can pin a route; the bundle layers are not read (they resolve through the dsh
|
||||||
|
* installation, outside the dsh home). `settings.yaml`'s `agent-default-model` is the
|
||||||
|
* HEADLESS default, not the TUI's, and is never read.
|
||||||
|
*
|
||||||
|
* ## Answer nothing rather than a guess
|
||||||
|
*
|
||||||
|
* Every doubt answers null: a profile that does not compose dsh-TUI, a half-pinned
|
||||||
|
* route, a layer that cannot be read (unreadable, a symlink out of the dsh home, too
|
||||||
|
* big, a mount that does not answer), and a file this reader does not fully
|
||||||
|
* understand. The YAML reader below is deliberately narrow (the repo carries no YAML
|
||||||
|
* dependency): a top-level block sequence of patch items, plain keys, single-line
|
||||||
|
* plain or quoted string scalars for the values it needs, and null for anything else
|
||||||
|
* that could change the answer (an anchor, alias or tag such as `!!js` on such a value,
|
||||||
|
* a merge key, a multi-line scalar, flow or block-scalar config, duplicate keys, a
|
||||||
|
* scalar YAML would type as a number, boolean or null, a second document, a nested
|
||||||
|
* row redefining dsh-TUI).
|
||||||
|
*
|
||||||
|
* ## Never block, never write, never leak
|
||||||
|
*
|
||||||
|
* Every path is probed with the bounded `probePathKind()` before it is touched, read
|
||||||
|
* asynchronously with a size cap, and must resolve (realpath) inside the dsh home.
|
||||||
|
* Nothing is written. Only the model id leaves this module: never the provider, a
|
||||||
|
* base URL, a key or any other config value.
|
||||||
|
*
|
||||||
|
* Tests: `test/deepseek-route-config.test.ts`.
|
||||||
|
*
|
||||||
|
* @module deepseek-route-config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { isAbsolute, join, resolve, sep } from 'node:path';
|
||||||
|
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||||
|
import {
|
||||||
|
deepSeekProfileFromManifest,
|
||||||
|
isProfileDirName,
|
||||||
|
resolveDefaultDeepSeekProfile,
|
||||||
|
type DeepSeekProfile,
|
||||||
|
} from './utils/deepseek-cli-resolver.js';
|
||||||
|
|
||||||
|
/** The dsh-TUI bundle a profile must compose for its route to be read here. */
|
||||||
|
export const DSH_TUI_PACKAGE = '@deepseek-harness-tui/dsh-tui';
|
||||||
|
/** The loader row dsh-TUI's own config lives on. */
|
||||||
|
export const DSH_TUI_ROW_ID = 'dsh-tui';
|
||||||
|
/** Largest file read: a patch layer is a few dozen lines. */
|
||||||
|
export const MAX_ROUTE_FILE_BYTES = 64 * 1024;
|
||||||
|
/** A profile name as the launch accepts it (the `path-segment` token pattern). */
|
||||||
|
const PROFILE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
||||||
|
/** How many profile directories the default-profile inventory looks at. */
|
||||||
|
const MAX_PROFILES = 64;
|
||||||
|
|
||||||
|
/** What one patch item does to the dsh-TUI row. */
|
||||||
|
export interface DshTuiRowPatch {
|
||||||
|
/** `name` on the patch; a mismatch makes dsh skip it. */
|
||||||
|
name?: string;
|
||||||
|
/** `disabled` on the patch, when present. */
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* `config` on the patch, when present: the provider and model it names (absent when
|
||||||
|
* it does not name one), or `{}` for a config that is empty or not a mapping.
|
||||||
|
*/
|
||||||
|
config?: { provider?: string; model?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown inside the parser for anything it does not fully understand. */
|
||||||
|
class Ambiguous extends Error {}
|
||||||
|
|
||||||
|
/** A nested line naming the dsh-TUI row: a group's config can re-define the row through it. */
|
||||||
|
const ROW_ID_LINE = /^(?:-\s+)?id:\s*(['"]?)dsh-tui\1\s*$/;
|
||||||
|
|
||||||
|
const KEY_LINE = /^([A-Za-z_][\w-]*):(?:\s+(.*))?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip a line's comment (`#` at line start or after whitespace, outside quotes) and
|
||||||
|
* trailing blanks. Throws on an unterminated quote: a multi-line flow scalar is
|
||||||
|
* beyond this reader.
|
||||||
|
*/
|
||||||
|
function stripComment(line: string): string {
|
||||||
|
let quote: '"' | "'" | null = null;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const c = line[i];
|
||||||
|
if (quote === "'") {
|
||||||
|
if (c === "'") {
|
||||||
|
if (line[i + 1] === "'") i++;
|
||||||
|
else quote = null;
|
||||||
|
}
|
||||||
|
} else if (quote === '"') {
|
||||||
|
if (c === '\\') i++;
|
||||||
|
else if (c === '"') quote = null;
|
||||||
|
} else if (c === "'" || c === '"') {
|
||||||
|
// A quote opens a scalar only at its start; inside a plain scalar it is a character.
|
||||||
|
const prev = line.slice(0, i).trimEnd();
|
||||||
|
if (prev === '' || /[:\-[{,]$/.test(prev)) quote = c;
|
||||||
|
} else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) {
|
||||||
|
return line.slice(0, i).trimEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (quote) throw new Ambiguous('unterminated quote');
|
||||||
|
return line.trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Indentation of a line; a tab in it is refused (YAML forbids tabs there). */
|
||||||
|
function indentOf(line: string): number {
|
||||||
|
const m = /^[ \t]*/.exec(line)![0];
|
||||||
|
if (m.includes('\t')) throw new Ambiguous('tab indentation');
|
||||||
|
return m.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single-line scalar as a string: plain, or single/double-quoted. Throws on anything
|
||||||
|
* that is not plainly a string (a tag, an anchor, an alias, a flow collection, a
|
||||||
|
* block scalar, or a plain scalar YAML would type as null, a boolean or a number).
|
||||||
|
*/
|
||||||
|
function stringScalar(raw: string): string {
|
||||||
|
const v = raw.trim();
|
||||||
|
if (v.startsWith("'")) {
|
||||||
|
const m = /^'((?:[^']|'')*)'$/.exec(v);
|
||||||
|
if (!m) throw new Ambiguous('quoted scalar');
|
||||||
|
return m[1].replace(/''/g, "'");
|
||||||
|
}
|
||||||
|
if (v.startsWith('"')) {
|
||||||
|
const m = /^"((?:[^"\\]|\\["\\/])*)"$/.exec(v);
|
||||||
|
if (!m) throw new Ambiguous('quoted scalar');
|
||||||
|
return m[1].replace(/\\(["\\/])/g, '$1');
|
||||||
|
}
|
||||||
|
if (v === '' || /^[!&*[\]{}|>%@`,?:-]/.test(v)) throw new Ambiguous('not a plain string');
|
||||||
|
if (/^(?:~|null|Null|NULL|true|True|TRUE|false|False|FALSE)$/.test(v)) throw new Ambiguous('typed scalar');
|
||||||
|
if (
|
||||||
|
/^[-+]?(?:\.\d+|\d[\d_]*(?:\.\d*)?)(?:[eE][-+]?\d+)?$|^0[xob][0-9a-fA-F_]+$|^[-+]?\.(?:inf|Inf|INF)$|^\.(?:nan|NaN|NAN)$/.test(
|
||||||
|
v
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Ambiguous('numeric scalar');
|
||||||
|
}
|
||||||
|
if (/\s#|:\s/.test(v)) throw new Ambiguous('plain scalar with an indicator');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `true`/`false` as YAML spells them, or a throw. */
|
||||||
|
function boolScalar(raw: string): boolean {
|
||||||
|
const v = raw.trim();
|
||||||
|
if (/^(?:true|True|TRUE)$/.test(v)) return true;
|
||||||
|
if (/^(?:false|False|FALSE)$/.test(v)) return false;
|
||||||
|
throw new Ambiguous('not a boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Line {
|
||||||
|
indent: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The direct keys of a block mapping whose lines all sit at `indent` or deeper, each
|
||||||
|
* with its inline value and the lines nested under it. Throws on a line that is not a
|
||||||
|
* key at the mapping's indent, and on a duplicate key (js-yaml refuses those, so dsh
|
||||||
|
* would not boot).
|
||||||
|
*/
|
||||||
|
function mappingKeys(lines: Line[], indent: number): Map<string, { inline: string | undefined; nested: Line[] }> {
|
||||||
|
const keys = new Map<string, { inline: string | undefined; nested: Line[] }>();
|
||||||
|
let current: { inline: string | undefined; nested: Line[] } | null = null;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.indent > indent) {
|
||||||
|
if (!current) throw new Ambiguous('nested line with no key');
|
||||||
|
current.nested.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.indent < indent) throw new Ambiguous('dedent inside a mapping');
|
||||||
|
const m = KEY_LINE.exec(line.text);
|
||||||
|
if (!m) throw new Ambiguous(`not a key: ${line.text.slice(0, 20)}`);
|
||||||
|
if (keys.has(m[1])) throw new Ambiguous('duplicate key');
|
||||||
|
current = { inline: m[2] === undefined || m[2] === '' ? undefined : m[2], nested: [] };
|
||||||
|
keys.set(m[1], current);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an item this reader cannot follow is certainly about another row: a plain
|
||||||
|
* `id:` at the item's indent naming a row other than dsh-TUI, no `insert:` and no
|
||||||
|
* mention of the dsh-TUI row anywhere in it.
|
||||||
|
*/
|
||||||
|
function isUnrelatedItem(item: Line[]): boolean {
|
||||||
|
const indent = item[0].indent;
|
||||||
|
const top = item.filter((l) => l.indent === indent);
|
||||||
|
if (top.some((l) => /^insert\s*:/.test(l.text))) return false;
|
||||||
|
const ids = top.map((l) => KEY_LINE.exec(l.text)).filter((m) => m?.[1] === 'id');
|
||||||
|
if (ids.length !== 1 || ids[0]![2] === undefined) return false;
|
||||||
|
try {
|
||||||
|
return stringScalar(ids[0]![2]) !== DSH_TUI_ROW_ID;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `config` of a dsh-TUI patch: its provider and model, if it names them. */
|
||||||
|
function configOf(entry: { inline: string | undefined; nested: Line[] }): { provider?: string; model?: string } {
|
||||||
|
if (entry.inline !== undefined) {
|
||||||
|
if (entry.nested.length) throw new Ambiguous('config with both an inline value and nested lines');
|
||||||
|
const v = entry.inline.trim();
|
||||||
|
// An empty flow mapping or a null names no route; anything else inline (a tag, a
|
||||||
|
// non-empty flow mapping, a block scalar) is beyond this reader.
|
||||||
|
if (v === '{}' || /^(?:~|null|Null|NULL)$/.test(v)) return {};
|
||||||
|
throw new Ambiguous('inline config');
|
||||||
|
}
|
||||||
|
if (!entry.nested.length) return {};
|
||||||
|
const keys = mappingKeys(entry.nested, entry.nested[0].indent);
|
||||||
|
const out: { provider?: string; model?: string } = {};
|
||||||
|
for (const field of ['provider', 'model'] as const) {
|
||||||
|
const value = keys.get(field);
|
||||||
|
if (!value) continue;
|
||||||
|
if (value.nested.length || value.inline === undefined) throw new Ambiguous(`${field} is not a single-line scalar`);
|
||||||
|
out[field] = stringScalar(value.inline);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a cordis patch-list file (a top-level YAML array of loader patches) does to the
|
||||||
|
* dsh-TUI row, in order. An empty list when the file does not touch it. Null when the
|
||||||
|
* file is beyond this reader's subset, or when it could re-insert the row.
|
||||||
|
*
|
||||||
|
* @param text the file's content
|
||||||
|
*/
|
||||||
|
export function parseDshTuiPatches(text: string): DshTuiRowPatch[] | null {
|
||||||
|
try {
|
||||||
|
const lines: Line[] = [];
|
||||||
|
let sawContent = false;
|
||||||
|
for (const rawLine of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
|
||||||
|
const stripped = stripComment(rawLine);
|
||||||
|
if (stripped.trim() === '') continue;
|
||||||
|
const indent = indentOf(stripped);
|
||||||
|
const body = stripped.slice(indent);
|
||||||
|
if (indent === 0 && body === '---') {
|
||||||
|
if (sawContent) throw new Ambiguous('a second document');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sawContent = true;
|
||||||
|
lines.push({ indent, text: body });
|
||||||
|
}
|
||||||
|
if (lines.length === 0) return [];
|
||||||
|
if (lines.length === 1 && lines[0].indent === 0 && lines[0].text === '[]') return [];
|
||||||
|
|
||||||
|
// Split the top-level block sequence into items.
|
||||||
|
const items: Line[][] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.indent === 0) {
|
||||||
|
const m = /^-(?:(\s+)(.*))?$/.exec(line.text);
|
||||||
|
if (!m) throw new Ambiguous('not a top-level sequence');
|
||||||
|
const item: Line[] = [];
|
||||||
|
// `- key: value`: the key sits at its real column, which its siblings below share.
|
||||||
|
if (m[2] !== undefined && m[2] !== '') item.push({ indent: 1 + m[1].length, text: m[2] });
|
||||||
|
items.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (items.length === 0) throw new Ambiguous('indented content before the first item');
|
||||||
|
items[items.length - 1].push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patches: DshTuiRowPatch[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.length === 0) throw new Ambiguous('empty item');
|
||||||
|
// The first key's column is the item's indent; every key shares it.
|
||||||
|
let keys: ReturnType<typeof mappingKeys>;
|
||||||
|
try {
|
||||||
|
keys = mappingKeys(item, item[0].indent);
|
||||||
|
} catch (err) {
|
||||||
|
// An item this reader cannot follow is harmless only when it provably is about
|
||||||
|
// another row: its own id names one, and no line in it names the dsh-TUI row.
|
||||||
|
if (!(err instanceof Ambiguous) || !isUnrelatedItem(item)) throw err;
|
||||||
|
if (item.slice(1).some((l) => ROW_ID_LINE.test(l.text))) throw new Ambiguous('a nested dsh-tui row');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = keys.get('id');
|
||||||
|
if (keys.has('insert')) {
|
||||||
|
// An insert that could bring a second dsh-TUI row is beyond this reader.
|
||||||
|
const body = item.map((l) => l.text).join('\n');
|
||||||
|
if (body.includes(DSH_TUI_ROW_ID)) throw new Ambiguous('insert mentioning the dsh-tui row');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!id) continue; // dsh warns and skips a non-insert patch without an id
|
||||||
|
if (id.nested.length || id.inline === undefined) throw new Ambiguous('id is not a scalar');
|
||||||
|
if (stringScalar(id.inline) !== DSH_TUI_ROW_ID) {
|
||||||
|
// Another row; but a group row's config is a list of rows, and one of them could
|
||||||
|
// be a second dsh-TUI row (dsh indexes nested group entries by id too).
|
||||||
|
if (item.slice(1).some((l) => ROW_ID_LINE.test(l.text))) throw new Ambiguous('a nested dsh-tui row');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const patch: DshTuiRowPatch = {};
|
||||||
|
const name = keys.get('name');
|
||||||
|
if (name) {
|
||||||
|
if (name.nested.length || name.inline === undefined) throw new Ambiguous('name is not a scalar');
|
||||||
|
patch.name = stringScalar(name.inline);
|
||||||
|
}
|
||||||
|
const disabled = keys.get('disabled');
|
||||||
|
if (disabled) {
|
||||||
|
if (disabled.nested.length || disabled.inline === undefined) throw new Ambiguous('disabled is not a scalar');
|
||||||
|
patch.disabled = boolScalar(disabled.inline);
|
||||||
|
}
|
||||||
|
const config = keys.get('config');
|
||||||
|
if (config) patch.config = configOf(config);
|
||||||
|
patches.push(patch);
|
||||||
|
}
|
||||||
|
return patches;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Ambiguous) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model dsh-TUI's route config pins, given what the user layers do to its row in
|
||||||
|
* application order (profile layer first, then the home layer). Null unless the last
|
||||||
|
* `config` that applies names both a provider and a model, and the row is not
|
||||||
|
* disabled. Pure.
|
||||||
|
*
|
||||||
|
* @param layers each layer's patches for the row, or null for a layer that could not be read
|
||||||
|
*/
|
||||||
|
export function resolveDshTuiRouteModel(layers: Array<DshTuiRowPatch[] | null>): string | null {
|
||||||
|
let config: { provider?: string; model?: string } | undefined;
|
||||||
|
let disabled = false;
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer === null) return null;
|
||||||
|
for (const patch of layer) {
|
||||||
|
if (patch.name !== undefined && patch.name !== DSH_TUI_PACKAGE) continue;
|
||||||
|
if (patch.disabled !== undefined) disabled = patch.disabled;
|
||||||
|
if (patch.config !== undefined) config = patch.config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (disabled || !config?.provider || !config.model) return null;
|
||||||
|
return config.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file under the dsh home, read only if it provably is one; see {@link readHomeFile}. */
|
||||||
|
type FileRead = { state: 'absent' } | { state: 'read'; text: string } | { state: 'refused' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read `path`, which must resolve inside `realHome`, bounded: probed first (a mount
|
||||||
|
* that does not answer is refused, never waited on), its real path checked against
|
||||||
|
* the dsh home (a symlink out of it is refused), size-capped.
|
||||||
|
*/
|
||||||
|
async function readHomeFile(path: string, realHome: string): Promise<FileRead> {
|
||||||
|
const kind = await probePathKind(path);
|
||||||
|
if (kind === 'absent') return { state: 'absent' };
|
||||||
|
if (kind !== 'file') return { state: 'refused' };
|
||||||
|
try {
|
||||||
|
const real = await fs.realpath(path);
|
||||||
|
if (!real.startsWith(realHome + sep)) return { state: 'refused' };
|
||||||
|
const stat = await fs.stat(real);
|
||||||
|
if (!stat.isFile() || stat.size > MAX_ROUTE_FILE_BYTES) return { state: 'refused' };
|
||||||
|
return { state: 'read', text: await fs.readFile(real, 'utf8') };
|
||||||
|
} catch {
|
||||||
|
return { state: 'refused' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dsh home a session runs against: its own `DSH_HOME` (already clamped for a
|
||||||
|
* non-granted owner), else the server's, else `~/.dsh`, as the `dsh` wrapper's
|
||||||
|
* `${DSH_HOME:-...}` resolves it. Null for a relative value, which names no place.
|
||||||
|
*/
|
||||||
|
export function effectiveDshHome(env: (key: string) => string | undefined): string | null {
|
||||||
|
const value = env('DSH_HOME')?.trim();
|
||||||
|
if (!value) return join(homedir(), '.dsh');
|
||||||
|
return isAbsolute(value) ? resolve(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profiles under a dsh home, read with the same bounded rules as the route files.
|
||||||
|
* Used to name the profile a session boots when it named none, the way the launch's
|
||||||
|
* `launcherDefaultTarget` does (resolveDefaultDeepSeekProfile).
|
||||||
|
*/
|
||||||
|
async function listProfilesBounded(home: string): Promise<DeepSeekProfile[] | null> {
|
||||||
|
const profilesDir = join(home, 'profiles');
|
||||||
|
if ((await probePathKind(home)) !== 'directory' || (await probePathKind(profilesDir)) !== 'directory') return null;
|
||||||
|
let realHome: string;
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
realHome = await fs.realpath(home);
|
||||||
|
names = (await fs.readdir(profilesDir, { withFileTypes: true }))
|
||||||
|
.filter((e) => e.isDirectory() && isProfileDirName(e.name) && PROFILE_NAME.test(e.name))
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.slice(0, MAX_PROFILES);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const profiles: DeepSeekProfile[] = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const manifest = await readHomeFile(join(profilesDir, name, 'package.json'), realHome);
|
||||||
|
if (manifest.state !== 'read') continue;
|
||||||
|
const profile = deepSeekProfileFromManifest(name, manifest.text);
|
||||||
|
if (profile) profiles.push(profile);
|
||||||
|
}
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the reader needs to know about one session. */
|
||||||
|
export interface DeepSeekRouteContext {
|
||||||
|
/** The session's `deepSeekConfig.profile`, if any. */
|
||||||
|
profile?: unknown;
|
||||||
|
/** The session's dsh home (see {@link effectiveDshHome}). */
|
||||||
|
home: string | null;
|
||||||
|
/** The server's own dsh home, which names the default profile (as the launch does). */
|
||||||
|
serverHome: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model the session's dsh-TUI route config pins, or null when it pins none or the
|
||||||
|
* answer is in any doubt. Read-only and bounded; see the module comment.
|
||||||
|
*/
|
||||||
|
export async function readDeepSeekRouteModel(ctx: DeepSeekRouteContext): Promise<string | null> {
|
||||||
|
const { home } = ctx;
|
||||||
|
if (!home) return null;
|
||||||
|
// An invalid name reads as unset at launch, so the default applies there too.
|
||||||
|
let profile = typeof ctx.profile === 'string' && PROFILE_NAME.test(ctx.profile) ? ctx.profile : null;
|
||||||
|
if (!profile) {
|
||||||
|
if (!ctx.serverHome) return null;
|
||||||
|
const listed = await listProfilesBounded(ctx.serverHome);
|
||||||
|
profile = listed ? resolveDefaultDeepSeekProfile(listed) : null;
|
||||||
|
if (!profile) return null;
|
||||||
|
}
|
||||||
|
if ((await probePathKind(home)) !== 'directory') return null;
|
||||||
|
let realHome: string;
|
||||||
|
try {
|
||||||
|
realHome = await fs.realpath(home);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const profileDir = join(home, 'profiles', profile);
|
||||||
|
const manifest = await readHomeFile(join(profileDir, 'package.json'), realHome);
|
||||||
|
if (manifest.state !== 'read') return null;
|
||||||
|
if (!deepSeekProfileFromManifest(profile, manifest.text)?.bundles.includes(DSH_TUI_PACKAGE)) return null;
|
||||||
|
|
||||||
|
const layers: Array<DshTuiRowPatch[] | null> = [];
|
||||||
|
for (const file of [join(profileDir, 'cordis.patch.yml'), join(home, 'cordis.patch.yml')]) {
|
||||||
|
const read = await readHomeFile(file, realHome);
|
||||||
|
if (read.state === 'refused') return null;
|
||||||
|
layers.push(read.state === 'absent' ? [] : parseDshTuiPatches(read.text));
|
||||||
|
}
|
||||||
|
return resolveDshTuiRouteModel(layers);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The config readers a CLI's registry entry may name for the model its
|
||||||
|
* session runs (`capabilities.modelDetect.configResolver`): the per-CLI behaviour lives
|
||||||
|
* here, keyed by name, so no code branches on a CLI id (like the launcher profiles in
|
||||||
|
* config/cli-registry/profiles.ts).
|
||||||
|
*
|
||||||
|
* A reader answers the model the CLI's own config pins for one session, or null when
|
||||||
|
* it pins none or the answer is in any doubt. It must be read-only, bounded (no
|
||||||
|
* synchronous filesystem call, nothing that can wait on a dead mount) and must return
|
||||||
|
* the model id alone, never another config value.
|
||||||
|
*
|
||||||
|
* @module model-config-resolvers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ModelConfigResolverName } from './config/cli-registry/types.js';
|
||||||
|
import { effectiveDshHome, readDeepSeekRouteModel } from './deepseek-route-config.js';
|
||||||
|
|
||||||
|
/** What a reader gets to know about the session. */
|
||||||
|
export interface ModelConfigContext {
|
||||||
|
/** The session's own launch config for its CLI (its `<Mode>Config`), if any. */
|
||||||
|
config: Record<string, unknown> | undefined;
|
||||||
|
/** The environment the session's CLI runs with (its own overrides, then the server's). */
|
||||||
|
env: (key: string) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOLVERS: Record<ModelConfigResolverName, (ctx: ModelConfigContext) => Promise<string | null>> = {
|
||||||
|
// dsh-TUI's route: the session's profile (else the one the launch boots, which the
|
||||||
|
// launch names from the server's own dsh home) read under the session's dsh home.
|
||||||
|
'deepseek-route': (ctx) =>
|
||||||
|
readDeepSeekRouteModel({
|
||||||
|
profile: ctx.config?.profile,
|
||||||
|
home: effectiveDshHome(ctx.env),
|
||||||
|
serverHome: effectiveDshHome((key) => process.env[key]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model the named reader resolves for a session, or null.
|
||||||
|
*
|
||||||
|
* @param name a `configResolver` from the registry (schema-checked at load)
|
||||||
|
* @param ctx what the reader may know about the session
|
||||||
|
*/
|
||||||
|
export async function resolveConfigModel(
|
||||||
|
name: ModelConfigResolverName,
|
||||||
|
ctx: ModelConfigContext
|
||||||
|
): Promise<string | null> {
|
||||||
|
const resolver = RESOLVERS[name];
|
||||||
|
return resolver ? resolver(ctx) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Which model a session is running, as far as the server can know it
|
||||||
|
* (`SessionState.displayModel`, shown in the tile grid's and split pane's headers).
|
||||||
|
*
|
||||||
|
* Pure: the session feeds it what it has and publishes the answer through `toState()`.
|
||||||
|
*
|
||||||
|
* ## Sources, strongest first
|
||||||
|
*
|
||||||
|
* 1. **custom-endpoint**: a session pointed at a Custom Model Endpoint Profile is answered
|
||||||
|
* by that endpoint's `modelId`, whatever alias the CLI itself prints.
|
||||||
|
* 2. **statusline / screen**: what the running CLI REPORTS, newest report wins. Claude's
|
||||||
|
* statusLine exporter posts `model.display_name` on every render (it follows an
|
||||||
|
* in-session `/model`); a CLI whose registry entry declares
|
||||||
|
* `capabilities.modelDetect` has its footer read off the pane capture the idle/working
|
||||||
|
* probe already takes.
|
||||||
|
* 3. **config**: the model the CLI's own config pins for this session, read by the
|
||||||
|
* reader its registry entry names (`capabilities.modelDetect.configResolver`, e.g. the
|
||||||
|
* dsh-TUI route: src/deepseek-route-config.ts), for while the screen names none. Not
|
||||||
|
* a report from the running CLI, so any report outranks it.
|
||||||
|
* 4. **launch**: the model the session was launched with (claude's `--model` or the
|
||||||
|
* app-wide default it was created with; another CLI's `<cli>Config.model`). What was
|
||||||
|
* asked for, not what was reported, so it only shows when nothing reported.
|
||||||
|
*
|
||||||
|
* Nothing known means no field at all: the header shows the harness logo alone, never a
|
||||||
|
* placeholder or a guess.
|
||||||
|
*
|
||||||
|
* ## Untrusted text
|
||||||
|
*
|
||||||
|
* A screen-read model is pane text, and a statusline payload is a POST body: both are
|
||||||
|
* stripped of escape sequences and control characters, whitespace-collapsed and capped
|
||||||
|
* here, and the browser renders the result with `textContent`.
|
||||||
|
*
|
||||||
|
* Tests: `test/session-display-model.test.ts`.
|
||||||
|
*
|
||||||
|
* @module session-display-model
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DisplayModel, DisplayModelSource } from './types/session.js';
|
||||||
|
import { stripAnsi } from './utils/index.js';
|
||||||
|
import { getCli } from './config/cli-registry/index.js';
|
||||||
|
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
|
||||||
|
|
||||||
|
/** Longest model name published (the header truncates long before this). */
|
||||||
|
export const MAX_DISPLAY_MODEL_CHARS = 64;
|
||||||
|
|
||||||
|
/** A report from the running CLI itself: the sources a restart may restore. */
|
||||||
|
export type ReportedModelSource = Extract<DisplayModelSource, 'statusline' | 'screen'>;
|
||||||
|
|
||||||
|
export interface ReportedModel {
|
||||||
|
model: string;
|
||||||
|
source: ReportedModelSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A model name fit to publish, or undefined when nothing printable is left.
|
||||||
|
*
|
||||||
|
* @param raw anything; only a string can yield a name
|
||||||
|
*/
|
||||||
|
export function sanitizeModelName(raw: unknown): string | undefined {
|
||||||
|
if (typeof raw !== 'string') return undefined;
|
||||||
|
const clean = stripAnsi(raw).replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!clean) return undefined;
|
||||||
|
return clean.slice(0, MAX_DISPLAY_MODEL_CHARS).trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a footer field can show that is never the model. */
|
||||||
|
export interface ScreenModelRejects {
|
||||||
|
/** The CLI's declared non-model words (`capabilities.modelDetect.rejectWords`), lower-cased compare. */
|
||||||
|
rejectWords?: readonly string[];
|
||||||
|
/** The session's working-directory basename: a footer field equal to it is the folder, exact compare. */
|
||||||
|
cwdBasename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model a pane's own chrome shows, read with the CLI's `modelDetect` pattern.
|
||||||
|
*
|
||||||
|
* Only the last `tailRows` non-blank rows are searched (joined with `\n`, so a pattern
|
||||||
|
* can anchor on the row above), which keeps the search below the transcript: the
|
||||||
|
* pattern itself must still anchor on chrome only that CLI draws.
|
||||||
|
*
|
||||||
|
* A footer whose model field is switched off shows its NEXT field where the model
|
||||||
|
* was, so the captured field is not taken when it is one of the CLI's declared
|
||||||
|
* non-model words (an effort level, a mode) or the session's own folder name, which a
|
||||||
|
* footer field equal to is the folder, never the model, whatever the CLI. Anything
|
||||||
|
* else the pattern captures is read as the model.
|
||||||
|
*
|
||||||
|
* @param paneText a plain `capture-pane -p` frame, or null when it could not be read
|
||||||
|
* @param pattern compiled through `compileVersionRegex()`, capture group 1 = the model
|
||||||
|
* @param tailRows how many non-blank rows from the bottom the pattern sees
|
||||||
|
* @param rejects fields that are never the model (see {@link ScreenModelRejects})
|
||||||
|
* @returns the model, or undefined when the frame shows none
|
||||||
|
*/
|
||||||
|
export function readScreenModel(
|
||||||
|
paneText: string | null | undefined,
|
||||||
|
pattern: RegExp,
|
||||||
|
tailRows: number = 1,
|
||||||
|
rejects: ScreenModelRejects = {}
|
||||||
|
): string | undefined {
|
||||||
|
if (!paneText) return undefined;
|
||||||
|
const rows = stripAnsi(paneText)
|
||||||
|
.split('\n')
|
||||||
|
.map((row) => row.trimEnd())
|
||||||
|
.filter((row) => row !== '');
|
||||||
|
const window = rows.slice(-Math.max(1, Math.min(tailRows, 8))).join('\n');
|
||||||
|
// compileVersionRegex() never sets `g`, but a pattern from elsewhere might, and a
|
||||||
|
// stale lastIndex would make the same frame match every other call.
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
const match = pattern.exec(window);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const field = match[1] ?? '';
|
||||||
|
if (rejects.cwdBasename && field === rejects.cwdBasename) return undefined;
|
||||||
|
if (rejects.rejectWords?.some((word) => word.toLowerCase() === field.toLowerCase())) return undefined;
|
||||||
|
return sanitizeModelName(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model a session was launched with, read the way its spawn reads it: where the
|
||||||
|
* model param lives is registry data (`capabilities.model` names the param, the entry's
|
||||||
|
* `legacyConfigField` the `<Mode>Config` object holding it, or the option bag itself for
|
||||||
|
* claude), never a branch on the CLI id. A CLI whose model is not a launch param (shell,
|
||||||
|
* dsh) has none.
|
||||||
|
*
|
||||||
|
* @param mode the session's CLI id
|
||||||
|
* @param bag the session's launch option bag (`model`, `codexConfig`, ...)
|
||||||
|
*/
|
||||||
|
export function launchModelFor(mode: string, bag: Record<string, unknown>): string | undefined {
|
||||||
|
const entry = getCli(mode);
|
||||||
|
const model = entry?.capabilities.model;
|
||||||
|
if (!entry || !model || model.source === 'none') return undefined;
|
||||||
|
const param = model.param ?? 'model';
|
||||||
|
const key = entry.launch.legacyConfigAliases?.[param] ?? param;
|
||||||
|
const value = legacyConfigForMode(mode, bag)?.[key];
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The persisted `displayModel` of a previous run, when it was a report from the CLI
|
||||||
|
* itself: a restart shows it until the next report replaces it. A custom-endpoint or
|
||||||
|
* launch answer is not restored, since the session derives those again by itself.
|
||||||
|
*/
|
||||||
|
export function restoredReportedModel(saved: unknown): ReportedModel | undefined {
|
||||||
|
if (!saved || typeof saved !== 'object') return undefined;
|
||||||
|
const { model, source } = saved as { model?: unknown; source?: unknown };
|
||||||
|
if (source !== 'statusline' && source !== 'screen') return undefined;
|
||||||
|
const name = sanitizeModelName(model);
|
||||||
|
return name ? { model: name, source } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model a session header shows, and where it came from.
|
||||||
|
*
|
||||||
|
* @param input.customModelId the custom endpoint's model, when the session is pointed at one
|
||||||
|
* @param input.reported the newest report from the CLI itself
|
||||||
|
* @param input.configModel the model the CLI's config pins for the session
|
||||||
|
* @param input.launchModel the model the session was launched with
|
||||||
|
*/
|
||||||
|
export function resolveDisplayModel(input: {
|
||||||
|
customModelId?: string;
|
||||||
|
reported?: ReportedModel | null;
|
||||||
|
configModel?: string | null;
|
||||||
|
launchModel?: string;
|
||||||
|
}): DisplayModel | undefined {
|
||||||
|
const custom = sanitizeModelName(input.customModelId);
|
||||||
|
if (custom) return { model: custom, source: 'custom-endpoint' };
|
||||||
|
const reported = input.reported ? sanitizeModelName(input.reported.model) : undefined;
|
||||||
|
if (reported && input.reported) return { model: reported, source: input.reported.source };
|
||||||
|
const config = sanitizeModelName(input.configModel);
|
||||||
|
if (config) return { model: config, source: 'config' };
|
||||||
|
const launch = sanitizeModelName(input.launchModel);
|
||||||
|
if (launch) return { model: launch, source: 'launch' };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
+162
-1
@@ -29,6 +29,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { basename } from 'node:path';
|
||||||
import { execSync, execFileSync } from 'node:child_process';
|
import { execSync, execFileSync } from 'node:child_process';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as pty from 'node-pty';
|
import * as pty from 'node-pty';
|
||||||
@@ -137,7 +138,18 @@ import {
|
|||||||
sanitizeAttachmentHistory,
|
sanitizeAttachmentHistory,
|
||||||
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
||||||
} from './session-attachment-history.js';
|
} from './session-attachment-history.js';
|
||||||
import type { SessionAttachmentHistoryItem } from './types/session.js';
|
import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js';
|
||||||
|
import { resolveConfigModel } from './model-config-resolvers.js';
|
||||||
|
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
|
||||||
|
import {
|
||||||
|
launchModelFor,
|
||||||
|
readScreenModel,
|
||||||
|
resolveDisplayModel,
|
||||||
|
restoredReportedModel,
|
||||||
|
sanitizeModelName,
|
||||||
|
type ReportedModel,
|
||||||
|
type ReportedModelSource,
|
||||||
|
} from './session-display-model.js';
|
||||||
|
|
||||||
export type { BackgroundTask } from './task-tracker.js';
|
export type { BackgroundTask } from './task-tracker.js';
|
||||||
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
||||||
@@ -550,6 +562,27 @@ export class Session extends EventEmitter {
|
|||||||
private _watchingWindow = WATCHING_TAIL_LINES;
|
private _watchingWindow = WATCHING_TAIL_LINES;
|
||||||
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
|
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
|
||||||
private _awaitingLineRe: RegExp | null | undefined = undefined;
|
private _awaitingLineRe: RegExp | null | undefined = undefined;
|
||||||
|
/**
|
||||||
|
* The newest model the running CLI reported for itself (its statusline, or its own
|
||||||
|
* footer read off the probe's capture), or null when none has. Feeds `displayModel`
|
||||||
|
* (src/session-display-model.ts). Persisted through `toState()` and restored after a
|
||||||
|
* restart, so an idle session keeps naming its model until the next report.
|
||||||
|
*/
|
||||||
|
private _reportedModel: ReportedModel | null = null;
|
||||||
|
/**
|
||||||
|
* The model the CLI's own config pins for this session (`modelDetect.configResolver`),
|
||||||
|
* read at each pane start, attach or relaunch; null when it pins none. Below any
|
||||||
|
* report from the running CLI in `displayModel`. Not persisted: the next start reads it.
|
||||||
|
*/
|
||||||
|
private _configModel: string | null = null;
|
||||||
|
/** Bumped per config read, so a read that lands after a newer one is dropped. */
|
||||||
|
private _configModelGen = 0;
|
||||||
|
/** Lazily compiled `capabilities.modelDetect.screenLine`. See _modelLinePattern(). */
|
||||||
|
private _modelLineRe: RegExp | null | undefined = undefined;
|
||||||
|
/** Resolved with the pattern above: how many rows at the foot of the screen it sees. */
|
||||||
|
private _modelLineRows = 1;
|
||||||
|
/** Resolved with the pattern above: the fields it shows that are never the model. */
|
||||||
|
private _modelRejectWords: readonly string[] = [];
|
||||||
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
||||||
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
||||||
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
||||||
@@ -807,6 +840,8 @@ export class Session extends EventEmitter {
|
|||||||
claudeSessionChain?: string[];
|
claudeSessionChain?: string[];
|
||||||
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
|
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
|
||||||
paneExit?: PaneExit;
|
paneExit?: PaneExit;
|
||||||
|
/** The previous run's `displayModel`; a CLI-reported one is restored (see `displayModel`). */
|
||||||
|
displayModel?: DisplayModel;
|
||||||
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
|
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
|
||||||
discoveredMuxSession?: boolean;
|
discoveredMuxSession?: boolean;
|
||||||
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
||||||
@@ -974,6 +1009,7 @@ export class Session extends EventEmitter {
|
|||||||
// replaces it with a first-hand reading. NOT the stats collector, which a
|
// replaces it with a first-hand reading. NOT the stats collector, which a
|
||||||
// browser panel arms and disarms — see `startPaneExitWatcher`.
|
// browser panel arms and disarms — see `startPaneExitWatcher`.
|
||||||
this.setPaneExit(config.paneExit);
|
this.setPaneExit(config.paneExit);
|
||||||
|
this._reportedModel = restoredReportedModel(config.displayModel) ?? null;
|
||||||
// Never self-parent: a session pointing at itself would draw a zero-length
|
// Never self-parent: a session pointing at itself would draw a zero-length
|
||||||
// lineage arc under its own tab. Only reachable via the recovery path, where
|
// lineage arc under its own tab. Only reachable via the recovery path, where
|
||||||
// both the id and the saved parent come from disk.
|
// both the id and the saved parent come from disk.
|
||||||
@@ -1267,6 +1303,9 @@ export class Session extends EventEmitter {
|
|||||||
} finally {
|
} finally {
|
||||||
this._paneLifecycleOps--;
|
this._paneLifecycleOps--;
|
||||||
this._paneStartedAt = Date.now();
|
this._paneStartedAt = Date.now();
|
||||||
|
// A start, attach or relaunch is when the CLI read its config, so it is when
|
||||||
|
// the model that config pins is read here too.
|
||||||
|
this._refreshConfigModel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1858,6 +1897,7 @@ export class Session extends EventEmitter {
|
|||||||
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
||||||
advisorModel: this._advisorModel,
|
advisorModel: this._advisorModel,
|
||||||
customModel: this.customModel,
|
customModel: this.customModel,
|
||||||
|
displayModel: this.displayModel,
|
||||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||||
// intent before restarting a crash-looped session. Deliberately NOT restored
|
// intent before restarting a crash-looped session. Deliberately NOT restored
|
||||||
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
||||||
@@ -3134,9 +3174,130 @@ export class Session extends EventEmitter {
|
|||||||
this._lastPaneProbeWorking =
|
this._lastPaneProbeWorking =
|
||||||
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
|
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
|
||||||
this._readWatching(text);
|
this._readWatching(text);
|
||||||
|
this._readScreenModel(text);
|
||||||
return this._lastPaneProbeWorking;
|
return this._lastPaneProbeWorking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the model the CLI's own footer names off the same capture, for a CLI whose
|
||||||
|
* registry entry declares `capabilities.modelDetect`.
|
||||||
|
*
|
||||||
|
* Unlike `_readWatching`, a capture that could not be read, or a footer the pattern
|
||||||
|
* does not find (a popup covering it, a footer turned off), KEEPS the last model. The
|
||||||
|
* two are not symmetric: background work ends and its badge must go, while a model does
|
||||||
|
* not stop running because something was drawn over the row that names it.
|
||||||
|
*/
|
||||||
|
private _readScreenModel(paneText: string | null): void {
|
||||||
|
if (paneText === null) return;
|
||||||
|
const pattern = this._modelLinePattern();
|
||||||
|
if (!pattern) return;
|
||||||
|
const model = readScreenModel(paneText, pattern, this._modelLineRows, {
|
||||||
|
rejectWords: this._modelRejectWords,
|
||||||
|
// A footer field equal to the folder this session runs in is the folder, never the
|
||||||
|
// model: the generic half of the rule, for every CLI.
|
||||||
|
cwdBasename: basename(this.workingDir),
|
||||||
|
});
|
||||||
|
if (model) this.noteReportedModel('screen', model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regex reading this CLI's model off its footer, or null for a CLI that declares
|
||||||
|
* none. Compiled once per session through `compileVersionRegex()` (null, never a throw,
|
||||||
|
* for a pattern it refuses), like the working- and watching-line patterns.
|
||||||
|
*/
|
||||||
|
private _modelLinePattern(): RegExp | null {
|
||||||
|
if (this._modelLineRe === undefined) {
|
||||||
|
const detect = getCli(this.mode)?.capabilities.modelDetect;
|
||||||
|
this._modelLineRe = detect?.screenLine ? compileVersionRegex(detect.screenLine) : null;
|
||||||
|
this._modelLineRows = detect?.screenLines ?? 1;
|
||||||
|
this._modelRejectWords = detect?.rejectWords ?? [];
|
||||||
|
}
|
||||||
|
return this._modelLineRe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a model the running CLI reported for itself: its statusline (claude's
|
||||||
|
* exporter, via `POST /api/status-telemetry`) or its own footer. The newest report
|
||||||
|
* wins whatever its source. An empty or unprintable report changes nothing.
|
||||||
|
*
|
||||||
|
* @returns true when the reported model changed (and `displayModelChanged` was emitted)
|
||||||
|
*/
|
||||||
|
noteReportedModel(source: ReportedModelSource, raw: unknown): boolean {
|
||||||
|
const model = sanitizeModelName(raw);
|
||||||
|
if (!model) return false;
|
||||||
|
if (this._reportedModel?.model === model && this._reportedModel.source === source) return false;
|
||||||
|
this._reportedModel = { model, source };
|
||||||
|
// The status does not change with it, so it needs a broadcast (and a persist) of its own.
|
||||||
|
this.emit('displayModelChanged');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model this session runs as far as the server knows, and where that came from:
|
||||||
|
* the custom endpoint's model, else the newest report from the CLI, else the launch
|
||||||
|
* model (src/session-display-model.ts). Undefined when none is known.
|
||||||
|
*/
|
||||||
|
get displayModel(): DisplayModel | undefined {
|
||||||
|
return resolveDisplayModel({
|
||||||
|
customModelId: this._customModel?.modelId,
|
||||||
|
reported: this._reportedModel,
|
||||||
|
configModel: this._configModel,
|
||||||
|
launchModel: launchModelFor(this.mode, this._launchOptionBag()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same option bag the spawn reads its launch params from: `model` at the top for
|
||||||
|
* claude (the `--model` or app-wide default it was created with; inert for every other
|
||||||
|
* CLI, which is why it is not handed over for them), each other CLI's own
|
||||||
|
* `<Mode>Config`. Where a param lives is registry data (`legacyConfigForMode`).
|
||||||
|
*/
|
||||||
|
private _launchOptionBag(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
||||||
|
openCodeConfig: this._openCodeConfig,
|
||||||
|
codexConfig: this._codexConfig,
|
||||||
|
geminiConfig: this._geminiConfig,
|
||||||
|
antigravityConfig: this._antigravityConfig,
|
||||||
|
piConfig: this._piConfig,
|
||||||
|
grokConfig: this._grokConfig,
|
||||||
|
deepSeekConfig: this._deepSeekConfig,
|
||||||
|
ompConfig: this._ompConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the model this session's CLI config pins, with the reader its registry entry
|
||||||
|
* names (`capabilities.modelDetect.configResolver`), and announce a change. Async and
|
||||||
|
* bounded (the reader probes before it reads); a read that lands after a newer one,
|
||||||
|
* or after the session stopped, is dropped. A remote or docker session's CLI reads its
|
||||||
|
* config on another machine or in its container, so nothing local is read for it.
|
||||||
|
*/
|
||||||
|
private _refreshConfigModel(): void {
|
||||||
|
const name = getCli(this.mode)?.capabilities.modelDetect?.configResolver;
|
||||||
|
if (!name || this._remote || this._docker) return;
|
||||||
|
const gen = ++this._configModelGen;
|
||||||
|
const overrides = this._envOverrides;
|
||||||
|
resolveConfigModel(name, {
|
||||||
|
config: legacyConfigForMode(this.mode, this._launchOptionBag()),
|
||||||
|
// The session's own env first (already clamped for a non-granted owner), then the
|
||||||
|
// server's: what the pane's CLI inherits.
|
||||||
|
env: (key) => overrides?.[key] ?? process.env[key],
|
||||||
|
}).then(
|
||||||
|
(model) => {
|
||||||
|
if (gen !== this._configModelGen || this._isStopped) return;
|
||||||
|
// Sanitized where it is published (resolveDisplayModel), like every source.
|
||||||
|
const next = model || null;
|
||||||
|
if (next === this._configModel) return;
|
||||||
|
this._configModel = next;
|
||||||
|
this.emit('displayModelChanged');
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
/* A reader answers null on doubt and never throws; a throw changes nothing. */
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the background-work chip off the same capture the working probe just took.
|
* Read the background-work chip off the same capture the working probe just took.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -641,6 +641,24 @@ export interface CustomModelSelection {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a session's {@link DisplayModel} came from (src/session-display-model.ts):
|
||||||
|
* - `custom-endpoint`: the Custom Model Endpoint Profile's model, which wins.
|
||||||
|
* - `statusline`: the CLI reported it (claude's statusLine exporter), follows a switch.
|
||||||
|
* - `screen`: read off the CLI's own footer (`capabilities.modelDetect`), follows a switch.
|
||||||
|
* - `config`: what the CLI's own config pins for this session
|
||||||
|
* (`capabilities.modelDetect.configResolver`), while its screen names none.
|
||||||
|
* - `launch`: what the session was launched with; nothing has reported since.
|
||||||
|
*/
|
||||||
|
export type DisplayModelSource = 'custom-endpoint' | 'statusline' | 'screen' | 'config' | 'launch';
|
||||||
|
|
||||||
|
/** The model a session runs as far as the server knows, for a session header. */
|
||||||
|
export interface DisplayModel {
|
||||||
|
/** Display text: sanitized (no control characters) and at most 64 characters. */
|
||||||
|
model: string;
|
||||||
|
source: DisplayModelSource;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The full custom-model selection a session keeps: the public selection plus the
|
* The full custom-model selection a session keeps: the public selection plus the
|
||||||
* bookkeeping `Session.setCustomModel()` needs to UNDO it later without guessing what
|
* bookkeeping `Session.setCustomModel()` needs to UNDO it later without guessing what
|
||||||
@@ -847,6 +865,16 @@ export interface SessionState {
|
|||||||
* written) is {@link CustomModelBookkeeping}, persisted disk-only like `__envOverrides`.
|
* written) is {@link CustomModelBookkeeping}, persisted disk-only like `__envOverrides`.
|
||||||
*/
|
*/
|
||||||
customModel?: CustomModelSelection;
|
customModel?: CustomModelSelection;
|
||||||
|
/**
|
||||||
|
* The model this session runs, as far as the server knows it, and where that came from
|
||||||
|
* (src/session-display-model.ts): the custom endpoint's model, else the newest report
|
||||||
|
* from the CLI itself (statusline or its own footer), else the model its config pins,
|
||||||
|
* else the launch model. Absent when
|
||||||
|
* none is known; a session header then shows the harness alone. Untrusted display text
|
||||||
|
* (pane-derived for `screen`): render it as text. Persisted, and a `statusline`/`screen`
|
||||||
|
* value is restored after a restart until the next report replaces it.
|
||||||
|
*/
|
||||||
|
displayModel?: DisplayModel;
|
||||||
/** Sanitized per-session attachment history. */
|
/** Sanitized per-session attachment history. */
|
||||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ const STOCK_NON_INTERACTIVE_PROFILES = new Map<string, DeepSeekProfileKind>([
|
|||||||
/** Profile directory names that are not profiles. */
|
/** Profile directory names that are not profiles. */
|
||||||
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
|
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
|
||||||
|
|
||||||
|
/** Whether a directory under `$DSH_HOME/profiles` can be a profile at all (not `node_modules`, not hidden). */
|
||||||
|
export function isProfileDirName(name: string): boolean {
|
||||||
|
return !NON_PROFILE_DIRS.has(name) && !name.startsWith('.');
|
||||||
|
}
|
||||||
|
|
||||||
function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
||||||
const haystack = [name, ...bundles].join(' ');
|
const haystack = [name, ...bundles].join(' ');
|
||||||
// Order matters: a profile that composes BOTH a web app and a tui bundle is a
|
// Order matters: a profile that composes BOTH a web app and a tui bundle is a
|
||||||
@@ -174,7 +179,19 @@ function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
|||||||
*/
|
*/
|
||||||
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
|
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(join(profilesDir, name, 'package.json'), 'utf-8');
|
return deepSeekProfileFromManifest(name, readFileSync(join(profilesDir, name, 'package.json'), 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A profile from its directory name and the text of its `package.json`, or null when
|
||||||
|
* that text is not JSON. Pure, so a caller with its own (bounded, async) reads gets the
|
||||||
|
* same classification as the inventory below.
|
||||||
|
*/
|
||||||
|
export function deepSeekProfileFromManifest(name: string, raw: string): DeepSeekProfile | null {
|
||||||
|
try {
|
||||||
const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } };
|
const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } };
|
||||||
const rawBundles = parsed?.dsh?.profile?.bundles;
|
const rawBundles = parsed?.dsh?.profile?.bundles;
|
||||||
const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : [];
|
const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : [];
|
||||||
@@ -198,7 +215,7 @@ export function listDeepSeekProfiles(): DeepSeekProfile[] {
|
|||||||
let entries: string[];
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
entries = readdirSync(profilesDir, { withFileTypes: true })
|
entries = readdirSync(profilesDir, { withFileTypes: true })
|
||||||
.filter((e) => e.isDirectory() && !NON_PROFILE_DIRS.has(e.name) && !e.name.startsWith('.'))
|
.filter((e) => e.isDirectory() && isProfileDirName(e.name))
|
||||||
.map((e) => e.name);
|
.map((e) => e.name);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
+430
-124
@@ -381,8 +381,17 @@ function applyPaneExitBadge(tab, paneExit) {
|
|||||||
tab.classList.toggle('tab-agent-exited', !!label);
|
tab.classList.toggle('tab-agent-exited', !!label);
|
||||||
// The tab's aria-label overrides its contents for the accessible name, and the
|
// The tab's aria-label overrides its contents for the accessible name, and the
|
||||||
// badge is aria-hidden like its siblings, so the exit has to ride the label.
|
// badge is aria-hidden like its siblings, so the exit has to ride the label.
|
||||||
|
// Compared with the last English label set (data-aria-source, seeded by the
|
||||||
|
// full render too), never the attribute: in zh-CN the translator rewrites it,
|
||||||
|
// and writing English back on every pass would have it translate again.
|
||||||
const name = tab.querySelector('.tab-name')?.dataset?.fullName;
|
const name = tab.querySelector('.tab-name')?.dataset?.fullName;
|
||||||
if (name) tab.setAttribute('aria-label', paneExitAriaLabel(name, label));
|
if (name) {
|
||||||
|
const aria = paneExitAriaLabel(name, label);
|
||||||
|
if (tab.dataset.ariaSource !== aria) {
|
||||||
|
tab.dataset.ariaSource = aria;
|
||||||
|
tab.setAttribute('aria-label', aria);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!label) {
|
if (!label) {
|
||||||
existing?.remove();
|
existing?.remove();
|
||||||
return;
|
return;
|
||||||
@@ -391,16 +400,19 @@ function applyPaneExitBadge(tab, paneExit) {
|
|||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
badge.className = 'tab-exited-badge';
|
badge.className = 'tab-exited-badge';
|
||||||
badge.setAttribute('aria-hidden', 'true');
|
badge.setAttribute('aria-hidden', 'true');
|
||||||
// Generated status text, like the status pills: it carries data-i18n-skip
|
// Translated like any other text (i18n.js has "exited" and its exit-code
|
||||||
// rather than a dictionary entry. Without it the translator would rewrite
|
// forms). The comparison below is with the last English label (data-label),
|
||||||
// the badge and the next render pass would rewrite it back, because the
|
// never the DOM, which holds the translation in zh-CN: a DOM compare would
|
||||||
// comparison below is against the English string.
|
// write the English back on every pass for the translator to redo.
|
||||||
badge.setAttribute('data-i18n-skip', '');
|
badge.dataset.label = label;
|
||||||
badge.textContent = label;
|
badge.textContent = label;
|
||||||
tab.querySelector('.tab-name')?.insertAdjacentElement('afterend', badge);
|
tab.querySelector('.tab-name')?.insertAdjacentElement('afterend', badge);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (existing.textContent !== label) existing.textContent = label;
|
if (existing.dataset.label !== label) {
|
||||||
|
existing.dataset.label = label;
|
||||||
|
existing.textContent = label;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SHORTCUTS = [
|
const DEFAULT_SHORTCUTS = [
|
||||||
@@ -419,7 +431,14 @@ const DEFAULT_SHORTCUTS = [
|
|||||||
id: 'close-session',
|
id: 'close-session',
|
||||||
group: 'Session',
|
group: 'Session',
|
||||||
label: 'Close Session',
|
label: 'Close Session',
|
||||||
bindings: [{ modifiers: ['ctrl'], key: 'w' }],
|
// ⚠️ No default key. This used to be Ctrl+W, which is "delete the previous
|
||||||
|
// word" in every shell, readline prompt and agent CLI, so muscle memory
|
||||||
|
// killed the session (tmux and the CLI, with no confirm) mid-sentence, and
|
||||||
|
// with the split open it was not even the pane being typed in. Ctrl+W now
|
||||||
|
// reaches the terminal like any other key. Closing stays on the tab's close
|
||||||
|
// control and menu (with their confirm), and anyone who wants a key binds
|
||||||
|
// one in App Settings → Shortcuts.
|
||||||
|
bindings: [],
|
||||||
action: 'killActiveSession',
|
action: 'killActiveSession',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -518,6 +537,106 @@ const DEFAULT_SHORTCUTS = [
|
|||||||
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
|
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
|
||||||
action: 'toggleSessionSidebar',
|
action: 'toggleSessionSidebar',
|
||||||
},
|
},
|
||||||
|
// Tile grid (tile-grid.js). Dispatched by tileShortcutFor()/runTileShortcut()
|
||||||
|
// and deliberately absent from SHORTCUT_ACTIONS: each applies only in some
|
||||||
|
// states (the focus chords only while the grid is open), and outside them the
|
||||||
|
// chord must reach the terminal untouched. Every xterm key handler swallows a
|
||||||
|
// chord that applies, so it never reaches a PTY. Defaults: Ctrl+Shift+G makes
|
||||||
|
// xterm emit nothing (a shifted Ctrl letter) and overrides only the browser's
|
||||||
|
// find-previous; Alt+Shift+Arrows are bound by no CLI Codeman runs. The
|
||||||
|
// arrow chords (focus, move) never apply in a text field, where shifted
|
||||||
|
// arrows select (tileShortcutFor).
|
||||||
|
{
|
||||||
|
id: 'toggle-tile-grid',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Toggle Tile Grid',
|
||||||
|
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'G', code: 'KeyG' }],
|
||||||
|
action: 'toggleTileGrid',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'focus-tile-left',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Focus Tile Left',
|
||||||
|
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowLeft' }],
|
||||||
|
action: 'focusTileLeft',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'focus-tile-right',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Focus Tile Right',
|
||||||
|
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowRight' }],
|
||||||
|
action: 'focusTileRight',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'focus-tile-up',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Focus Tile Up',
|
||||||
|
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowUp' }],
|
||||||
|
action: 'focusTileUp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'focus-tile-down',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Focus Tile Down',
|
||||||
|
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowDown' }],
|
||||||
|
action: 'focusTileDown',
|
||||||
|
},
|
||||||
|
// Move the focused tile: it trades places with the neighbour the focus
|
||||||
|
// chords would pick. Ctrl+Shift+Arrows because every other two-modifier
|
||||||
|
// arrow chord is taken: Ctrl+Alt+Arrows switch workspaces (GNOME, Xfce, and
|
||||||
|
// rotate the screen on some Windows graphics drivers), Ctrl+Alt+Shift+Arrows
|
||||||
|
// move a window to another workspace (GNOME, Cinnamon, Xfce), Super chords
|
||||||
|
// belong to the desktop, Alt+Arrows are the browser's back and forward, and
|
||||||
|
// Alt+Shift+Arrows focus tiles. No browser, GNOME, KDE or macOS default and
|
||||||
|
// no Claude Code default uses Ctrl+Shift+Arrows (it parallels Ctrl+Shift+{ }
|
||||||
|
// for moving tabs); what it costs is word selection, so the chords skip a
|
||||||
|
// text field (tileShortcutFor) and give up only a terminal editor's
|
||||||
|
// word selection (nano, micro, emacs) inside a tile while the grid is open.
|
||||||
|
{
|
||||||
|
id: 'move-tile-left',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Move Tile Left',
|
||||||
|
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowLeft' }],
|
||||||
|
action: 'moveTileLeft',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'move-tile-right',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Move Tile Right',
|
||||||
|
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowRight' }],
|
||||||
|
action: 'moveTileRight',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'move-tile-up',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Move Tile Up',
|
||||||
|
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowUp' }],
|
||||||
|
action: 'moveTileUp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'move-tile-down',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Move Tile Down',
|
||||||
|
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowDown' }],
|
||||||
|
action: 'moveTileDown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zoom-tile',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Zoom Focused Tile',
|
||||||
|
// Toggles. Alt+Shift+Enter would otherwise reach the CLI as ESC CR (Alt+Enter
|
||||||
|
// is a newline in Claude Code); it is swallowed only while the grid is open.
|
||||||
|
bindings: [{ modifiers: ['alt', 'shift'], key: 'Enter' }],
|
||||||
|
action: 'zoomTile',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'remove-tile',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Remove Focused Tile',
|
||||||
|
// Unbound by default; the session keeps running either way.
|
||||||
|
bindings: [],
|
||||||
|
action: 'removeTile',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'previous-next-session',
|
id: 'previous-next-session',
|
||||||
group: 'Session',
|
group: 'Session',
|
||||||
@@ -867,6 +986,11 @@ class CodemanApp {
|
|||||||
// writes when the freshly computed descriptor is identical (COD-136).
|
// writes when the freshly computed descriptor is identical (COD-136).
|
||||||
this._lastIndicatorDescriptor = null;
|
this._lastIndicatorDescriptor = null;
|
||||||
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
|
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
|
||||||
|
// Terminal sockets OTHER than the primary one (`this._ws`), keyed by the
|
||||||
|
// session they are bound to: the split pane's second terminal registers its
|
||||||
|
// socket here so its input rides the same exactly-once queue. Values are
|
||||||
|
// `{ ws, lastRecvAt }` handles owned by that terminal (see _inputSocketFor).
|
||||||
|
this._extraInputSockets = new Map();
|
||||||
this._persistReliableTimer = null;
|
this._persistReliableTimer = null;
|
||||||
this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead
|
this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead
|
||||||
this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog
|
this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog
|
||||||
@@ -1185,6 +1309,11 @@ class CodemanApp {
|
|||||||
try {
|
try {
|
||||||
this._webglLongTaskObserver = new PerformanceObserver((list) => {
|
this._webglLongTaskObserver = new PerformanceObserver((list) => {
|
||||||
if (!this._webglAddon) return;
|
if (!this._webglAddon) return;
|
||||||
|
// ⚠️ The observer sees EVERY long task on the page. While the tile grid
|
||||||
|
// owns the terminal the main terminal is parked and draws nothing; the
|
||||||
|
// long tasks are tile renders and replays (DOM renderers), and counting
|
||||||
|
// them would write the sticky 7-day WebGL disable for no WebGL reason.
|
||||||
|
if (this._tilesOwnTerminal?.()) return;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (now - installedAt < WEBGL_FALLBACK.GRACE_MS) return;
|
if (now - installedAt < WEBGL_FALLBACK.GRACE_MS) return;
|
||||||
if (evaluateWebGLLongTaskTrip(recent, list.getEntries(), now)) {
|
if (evaluateWebGLLongTaskTrip(recent, list.getEntries(), now)) {
|
||||||
@@ -1288,6 +1417,12 @@ class CodemanApp {
|
|||||||
this._tabLayoutDragKeydown(e);
|
this._tabLayoutDragKeydown(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// So does the Tiles count menu: it closes alone and gives the keyboard
|
||||||
|
// back to the Tiles button (tile-grid.js).
|
||||||
|
if (this._tileCountMenu) {
|
||||||
|
this.closeTileCountMenu({ refocus: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.closeAllPanels();
|
this.closeAllPanels();
|
||||||
this.closeHelp();
|
this.closeHelp();
|
||||||
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
||||||
@@ -1345,6 +1480,15 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tile grid chords: only where they apply (tile-grid.js tileShortcutFor),
|
||||||
|
// so outside the grid Alt+Shift+Arrows still reach the terminal.
|
||||||
|
const tileShortcut = this.tileShortcutFor?.(e);
|
||||||
|
if (tileShortcut) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.runTileShortcut(tileShortcut);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Match against the shortcut registry so user rebinds and per-shortcut
|
// Match against the shortcut registry so user rebinds and per-shortcut
|
||||||
// disables (App Settings → Shortcuts) take effect. Every dispatchable
|
// disables (App Settings → Shortcuts) take effect. Every dispatchable
|
||||||
// binding requires Ctrl/Cmd/Alt (capture enforces the same), so plain
|
// binding requires Ctrl/Cmd/Alt (capture enforces the same), so plain
|
||||||
@@ -1378,18 +1522,31 @@ class CodemanApp {
|
|||||||
// SSE Connection
|
// SSE Connection
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session id the SSE filter names for `sessionId`: itself, or while the
|
||||||
|
* tile grid owns the terminal the grid's fixed filter (TILE_GRID_SSE_FILTER,
|
||||||
|
* constants.js), which no session matches. Both places that set the filter
|
||||||
|
* ask here: the live re-subscribe below and the connect URL (connectSSE),
|
||||||
|
* which an SSE reconnect rebuilds with the grid still open.
|
||||||
|
*/
|
||||||
|
_sseFilterSessionId(sessionId) {
|
||||||
|
if (this._tilesOwnTerminal?.()) return window.CodemanTileGrid?.TILE_GRID_SSE_FILTER || sessionId;
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST a live subscription update so the server filters terminal events
|
* POST a live subscription update so the server filters terminal events
|
||||||
* to the given session(s) for this client. Fire-and-forget — failures
|
* to the given session(s) for this client. Fire-and-forget: failures
|
||||||
* are non-fatal because we'll still get every event we don't want
|
* are non-fatal because we'll still get every event we don't want
|
||||||
* (just at higher cost), and the next reconnect carries the filter via
|
* (just at higher cost), and the next reconnect carries the filter via
|
||||||
* the SSE query string.
|
* the SSE query string.
|
||||||
*/
|
*/
|
||||||
_updateSseSubscription(sessionId) {
|
_updateSseSubscription(sessionId) {
|
||||||
try {
|
try {
|
||||||
|
const filterId = this._sseFilterSessionId(sessionId);
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
clientId: this._clientId,
|
clientId: this._clientId,
|
||||||
sessions: sessionId ? [sessionId] : null,
|
sessions: filterId ? [filterId] : null,
|
||||||
});
|
});
|
||||||
fetch('/api/events/subscribe', {
|
fetch('/api/events/subscribe', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1454,7 +1611,9 @@ class CodemanApp {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this._retireUrlSession();
|
this._retireUrlSession();
|
||||||
this.selectSession(id, { auto: true });
|
// Following a link is navigation: a tiled id focuses its tile, any other
|
||||||
|
// leaves the tile grid for the single view (the grid is remembered).
|
||||||
|
this.selectSession(id, { auto: true, leaveTiles: true });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1593,6 +1752,9 @@ class CodemanApp {
|
|||||||
* Full re-renders re-apply the class from this.detachedSessions. */
|
* Full re-renders re-apply the class from this.detachedSessions. */
|
||||||
_markDetached(id, on) {
|
_markDetached(id, on) {
|
||||||
if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id);
|
if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id);
|
||||||
|
// A popped-out session's window owns its PTY size now, so it leaves the
|
||||||
|
// tile grid (one place per session in this browser tab).
|
||||||
|
if (on && this._tileGrid?.has(id)) this.removeTile(id);
|
||||||
const container = this.$('sessionTabs');
|
const container = this.$('sessionTabs');
|
||||||
const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`);
|
const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||||
if (tab) tab.classList.toggle('detached', on);
|
if (tab) tab.classList.toggle('detached', on);
|
||||||
@@ -1780,7 +1942,8 @@ class CodemanApp {
|
|||||||
// session we're rendering. Lifecycle/metadata events are sent globally
|
// session we're rendering. Lifecycle/metadata events are sent globally
|
||||||
// regardless of filter (server side).
|
// regardless of filter (server side).
|
||||||
const _sseParams = new URLSearchParams({ clientId: this._clientId });
|
const _sseParams = new URLSearchParams({ clientId: this._clientId });
|
||||||
if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId);
|
const _sseFilterId = this._sseFilterSessionId(this.activeSessionId);
|
||||||
|
if (_sseFilterId) _sseParams.set('sessions', _sseFilterId);
|
||||||
this.eventSource = new EventSource(CodemanBase.url(`/api/events?${_sseParams.toString()}`));
|
this.eventSource = new EventSource(CodemanBase.url(`/api/events?${_sseParams.toString()}`));
|
||||||
|
|
||||||
// Store all event listeners for cleanup on reconnect.
|
// Store all event listeners for cleanup on reconnect.
|
||||||
@@ -2190,6 +2353,10 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_onSessionTerminal(data) {
|
_onSessionTerminal(data) {
|
||||||
|
// Tile grid open: the main terminal is parked and its socket closed, so the
|
||||||
|
// SSE fallback would write the focused tile's output into a hidden xterm.
|
||||||
|
// The tiles carry their own output over their own sockets.
|
||||||
|
if (this._tilesOwnTerminal?.()) return;
|
||||||
if (data.id === this.activeSessionId) {
|
if (data.id === this.activeSessionId) {
|
||||||
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
|
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
|
||||||
|
|
||||||
@@ -2239,6 +2406,8 @@ class CodemanApp {
|
|||||||
*/
|
*/
|
||||||
_scheduleDroppedOutputRecovery(sessionId, attempt = 0, queuedBytes) {
|
_scheduleDroppedOutputRecovery(sessionId, attempt = 0, queuedBytes) {
|
||||||
if (!sessionId || this._clientDropRecoveryTimer) return;
|
if (!sessionId || this._clientDropRecoveryTimer) return;
|
||||||
|
// Nothing of the main terminal's to recover while the tile grid owns it.
|
||||||
|
if (this._tilesOwnTerminal?.()) return;
|
||||||
// Behind the debounce guard: one line per window, not per dropped frame.
|
// Behind the debounce guard: one line per window, not per dropped frame.
|
||||||
if (Number.isFinite(queuedBytes)) _crashDiag.log(`TERMINAL DROP: ${(queuedBytes / 1024).toFixed(0)}KB queued`);
|
if (Number.isFinite(queuedBytes)) _crashDiag.log(`TERMINAL DROP: ${(queuedBytes / 1024).toFixed(0)}KB queued`);
|
||||||
this._clientDropRecoveryTimer = setTimeout(async () => {
|
this._clientDropRecoveryTimer = setTimeout(async () => {
|
||||||
@@ -2916,6 +3085,10 @@ class CodemanApp {
|
|||||||
async _onSessionNeedsRefresh(event = {}) {
|
async _onSessionNeedsRefresh(event = {}) {
|
||||||
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
||||||
// so reload the buffer to recover from any display corruption.
|
// so reload the buffer to recover from any display corruption.
|
||||||
|
// Tile grid open: the main terminal is parked, so this would fetch a capture
|
||||||
|
// for a hidden xterm. Each tile refreshes itself through the grid's queue.
|
||||||
|
// `false`, never undefined: the drop recovery reads the result.
|
||||||
|
if (this._tilesOwnTerminal?.()) return false;
|
||||||
const sessionId = this.activeSessionId;
|
const sessionId = this.activeSessionId;
|
||||||
if (event?.id && event.id !== sessionId) return false;
|
if (event?.id && event.id !== sessionId) return false;
|
||||||
if (!sessionId || !this.terminal) return false;
|
if (!sessionId || !this.terminal) return false;
|
||||||
@@ -3018,6 +3191,8 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _onSessionClearTerminal(data) {
|
async _onSessionClearTerminal(data) {
|
||||||
|
// The tiles get the clear over their own sockets; the parked main terminal must not refetch.
|
||||||
|
if (this._tilesOwnTerminal?.()) return;
|
||||||
if (data.id === this.activeSessionId) {
|
if (data.id === this.activeSessionId) {
|
||||||
// Skip if selectSession is already loading the buffer — clearTerminal arriving
|
// Skip if selectSession is already loading the buffer — clearTerminal arriving
|
||||||
// during buffer load would clear the terminal mid-write, causing visible flicker
|
// during buffer load would clear the terminal mid-write, causing visible flicker
|
||||||
@@ -3063,14 +3238,15 @@ class CodemanApp {
|
|||||||
_onSessionCompletion(data) {
|
_onSessionCompletion(data) {
|
||||||
this.totalCost += data.cost || 0;
|
this.totalCost += data.cost || 0;
|
||||||
this.updateCost();
|
this.updateCost();
|
||||||
if (data.id === this.activeSessionId) {
|
// Not into the parked main terminal while the tile grid owns the screen.
|
||||||
|
if (data.id === this.activeSessionId && !this._tilesOwnTerminal?.()) {
|
||||||
this.terminal.writeln('');
|
this.terminal.writeln('');
|
||||||
this.terminal.writeln(`\x1b[1;32m Done (Cost: $${(data.cost || 0).toFixed(4)})\x1b[0m`);
|
this.terminal.writeln(`\x1b[1;32m Done (Cost: $${(data.cost || 0).toFixed(4)})\x1b[0m`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_onSessionError(data) {
|
_onSessionError(data) {
|
||||||
if (data.id === this.activeSessionId) {
|
if (data.id === this.activeSessionId && !this._tilesOwnTerminal?.()) {
|
||||||
this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`);
|
this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`);
|
||||||
}
|
}
|
||||||
this._notifySession(data.id, 'critical', 'session-error', 'Session Error', data.error || 'Unknown error');
|
this._notifySession(data.id, 'critical', 'session-error', 'Session Error', data.error || 'Unknown error');
|
||||||
@@ -3397,7 +3573,7 @@ class CodemanApp {
|
|||||||
} else if (msg.t === 'ia') {
|
} else if (msg.t === 'ia') {
|
||||||
// Input ACK — the server applied (or deduped) this seq; drop it from
|
// Input ACK — the server applied (or deduped) this seq; drop it from
|
||||||
// the durable queue so it can never be re-delivered/lost.
|
// the durable queue so it can never be re-delivered/lost.
|
||||||
this._onWsInputAck(msg.seq, msg);
|
this._onWsInputAck(msg.seq, msg, sessionId);
|
||||||
} else if (msg.t === 'zc') {
|
} else if (msg.t === 'zc') {
|
||||||
// Resize confirm — the geometry the PTY actually holds, which is not
|
// Resize confirm — the geometry the PTY actually holds, which is not
|
||||||
// always the one this client asked for (issue #464).
|
// always the one this client asked for (issue #464).
|
||||||
@@ -3579,6 +3755,49 @@ class CodemanApp {
|
|||||||
this._reliableSend(sessionId, input, useMux);
|
this._reliableSend(sessionId, input, useMux);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OPEN terminal socket bound to `sessionId`, or null: the primary socket
|
||||||
|
* first, then one registered by a second terminal (the split pane). The
|
||||||
|
* delivery layer below asks this instead of reading `this._ws` directly, so a
|
||||||
|
* second terminal's input gets the same exactly-once queue, ACKs and
|
||||||
|
* half-open detection as the primary's.
|
||||||
|
*
|
||||||
|
* Returns `{ ws, lastRecvAt }`: `lastRecvAt` is the socket's last received
|
||||||
|
* frame, which `_redeliverSweep` reads to tell a dead socket from a slow ACK.
|
||||||
|
*/
|
||||||
|
_inputSocketFor(sessionId) {
|
||||||
|
if (!sessionId) return null;
|
||||||
|
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
||||||
|
return { ws: this._ws, lastRecvAt: this._wsLastRecvAt };
|
||||||
|
}
|
||||||
|
const handle = this._extraInputSockets?.get(sessionId);
|
||||||
|
if (handle && handle.ws && handle.ws.readyState === WebSocket.OPEN) return handle;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a second terminal's socket for `sessionId` (call from its onopen).
|
||||||
|
* `handle` is `{ ws, lastRecvAt }`, owned by the caller, which must bump
|
||||||
|
* `handle.lastRecvAt` on every received frame. Stamped here so a socket that
|
||||||
|
* just opened does not look silent to the redelivery sweep, which would
|
||||||
|
* otherwise force-close it as half-open on the first stale record.
|
||||||
|
*/
|
||||||
|
_registerInputSocket(sessionId, handle) {
|
||||||
|
if (!sessionId || !handle) return;
|
||||||
|
handle.lastRecvAt = Date.now();
|
||||||
|
if (!this._extraInputSockets) this._extraInputSockets = new Map();
|
||||||
|
this._extraInputSockets.set(sessionId, handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a registration, but only the one `handle` made: a replacement socket
|
||||||
|
* registers its own handle before the old one's close lands, and that late
|
||||||
|
* close must not unregister the socket that replaced it.
|
||||||
|
*/
|
||||||
|
_unregisterInputSocket(sessionId, handle) {
|
||||||
|
if (this._extraInputSockets?.get(sessionId) === handle) this._extraInputSockets.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fire-and-forget input for EPHEMERAL, loss-tolerant streams (e.g. wheel-scroll
|
* Fire-and-forget input for EPHEMERAL, loss-tolerant streams (e.g. wheel-scroll
|
||||||
* reports). Unlike _sendInputAsync, this never enters the durable seq/ACK queue,
|
* reports). Unlike _sendInputAsync, this never enters the durable seq/ACK queue,
|
||||||
@@ -3590,9 +3809,10 @@ class CodemanApp {
|
|||||||
*/
|
*/
|
||||||
_sendInputEphemeral(sessionId, input) {
|
_sendInputEphemeral(sessionId, input) {
|
||||||
if (!sessionId || !input) return;
|
if (!sessionId || !input) return;
|
||||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
const sock = this._inputSocketFor(sessionId);
|
||||||
|
if (sock) {
|
||||||
try {
|
try {
|
||||||
this._ws.send(JSON.stringify({ t: 'i', d: input }));
|
sock.ws.send(JSON.stringify({ t: 'i', d: input }));
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// socket died mid-send — fall through to a best-effort POST
|
// socket died mid-send — fall through to a best-effort POST
|
||||||
@@ -3646,11 +3866,12 @@ class CodemanApp {
|
|||||||
// over the single ordered stream. They stay pending until the server ACKs
|
// over the single ordered stream. They stay pending until the server ACKs
|
||||||
// them ({t:'ia'}); a frame swallowed by a half-open socket is re-sent after
|
// them ({t:'ia'}); a frame swallowed by a half-open socket is re-sent after
|
||||||
// the sweep force-reconnects (which resets sentAt=0 in _onWsReady).
|
// the sweep force-reconnects (which resets sentAt=0 in _onWsReady).
|
||||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
const sock = this._inputSocketFor(sessionId);
|
||||||
|
if (sock) {
|
||||||
for (const rec of list) {
|
for (const rec of list) {
|
||||||
if (rec.sentAt !== 0) continue;
|
if (rec.sentAt !== 0) continue;
|
||||||
try {
|
try {
|
||||||
this._ws.send(JSON.stringify({ t: 'i', d: rec.data, seq: rec.seq, cid: this._clientId }));
|
sock.ws.send(JSON.stringify({ t: 'i', d: rec.data, seq: rec.seq, cid: this._clientId }));
|
||||||
rec.sentAt = Date.now();
|
rec.sentAt = Date.now();
|
||||||
rec.tries++;
|
rec.tries++;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -3671,7 +3892,7 @@ class CodemanApp {
|
|||||||
if (!cur || cur.length === 0) break;
|
if (!cur || cur.length === 0) break;
|
||||||
// If the WebSocket came back mid-drain, yield to it (the acked stream)
|
// If the WebSocket came back mid-drain, yield to it (the acked stream)
|
||||||
// so we don't redundantly re-POST what onopen is already re-sending.
|
// so we don't redundantly re-POST what onopen is already re-sending.
|
||||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
if (this._inputSocketFor(sessionId)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const rec = cur[0];
|
const rec = cur[0];
|
||||||
@@ -3759,8 +3980,10 @@ class CodemanApp {
|
|||||||
* called a duplicate is the mechanism working as designed — the original did
|
* called a duplicate is the mechanism working as designed — the original did
|
||||||
* land — and re-sending it would type the same thing twice.
|
* land — and re-sending it would type the same thing twice.
|
||||||
*/
|
*/
|
||||||
_onWsInputAck(seq, msg) {
|
_onWsInputAck(seq, msg, sessionId = this._wsSessionId) {
|
||||||
const sessionId = this._wsSessionId;
|
// `sessionId` is the session of the socket the ACK arrived on: `{t:'ia'}`
|
||||||
|
// frames carry none, and with a second terminal's socket in play
|
||||||
|
// `this._wsSessionId` is no longer the only candidate.
|
||||||
if (!sessionId || !Number.isInteger(seq)) return;
|
if (!sessionId || !Number.isInteger(seq)) return;
|
||||||
if (msg && msg.err) {
|
if (msg && msg.err) {
|
||||||
// Refused for good (e.g. over the size limit): retrying cannot help.
|
// Refused for good (e.g. over the size limit): retrying cannot help.
|
||||||
@@ -3805,19 +4028,20 @@ class CodemanApp {
|
|||||||
for (const sessionId of [...this._pendingDeliveries.keys()]) {
|
for (const sessionId of [...this._pendingDeliveries.keys()]) {
|
||||||
const list = this._pendingDeliveries.get(sessionId);
|
const list = this._pendingDeliveries.get(sessionId);
|
||||||
if (!list || list.length === 0) continue;
|
if (!list || list.length === 0) continue;
|
||||||
const isActiveWs =
|
const sock = this._inputSocketFor(sessionId);
|
||||||
this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId;
|
if (sock) {
|
||||||
if (isActiveWs) {
|
|
||||||
const oldest = list[0];
|
const oldest = list[0];
|
||||||
// Only tear the socket down when the oldest unacked frame is stale AND the
|
// Only tear the socket down when the oldest unacked frame is stale AND the
|
||||||
// socket has been silent for the timeout: a connection still delivering
|
// socket has been silent for the timeout: a connection still delivering
|
||||||
// output/ACKs is alive (the ACK is just behind), so force-closing it would
|
// output/ACKs is alive (the ACK is just behind), so force-closing it would
|
||||||
// cause needless WS↔HTTP flapping. A truly half-open socket goes quiet.
|
// cause needless WS↔HTTP flapping. A truly half-open socket goes quiet.
|
||||||
|
// Silence is measured on THIS socket: the primary's last frame says
|
||||||
|
// nothing about a second terminal's connection, and the reverse.
|
||||||
const stale = oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs;
|
const stale = oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs;
|
||||||
const silent = Date.now() - this._wsLastRecvAt > this._reliableAckTimeoutMs;
|
const silent = Date.now() - (sock.lastRecvAt || 0) > this._reliableAckTimeoutMs;
|
||||||
if (stale && silent) {
|
if (stale && silent) {
|
||||||
try {
|
try {
|
||||||
this._ws.close(); // half-open: never recovers on its own — force reconnect
|
sock.ws.close(); // half-open: never recovers on its own — force reconnect
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -3974,9 +4198,12 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// With an active terminal, show its transport (WebSocket vs HTTP fallback).
|
// With an active terminal, show its transport (WebSocket vs HTTP fallback).
|
||||||
|
// While the tile grid owns the terminal the main socket is parked on
|
||||||
|
// purpose, so the state comes from the tiles' sockets: all open is
|
||||||
|
// connected, any still coming back is reconnecting.
|
||||||
if (this.activeSessionId) {
|
if (this.activeSessionId) {
|
||||||
let cls, label, detail;
|
let cls, label, detail;
|
||||||
switch (this._wsState) {
|
switch (this._tilesOwnTerminal?.() ? this._tileGridSocketState() : this._wsState) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
cls = 'connected'; label = 'WS'; detail = 'Terminal connected over WebSocket';
|
cls = 'connected'; label = 'WS'; detail = 'Terminal connected over WebSocket';
|
||||||
break;
|
break;
|
||||||
@@ -4157,8 +4384,11 @@ class CodemanApp {
|
|||||||
this._updateConnectionLossUi();
|
this._updateConnectionLossUi();
|
||||||
this.connectSSE();
|
this.connectSSE();
|
||||||
// The terminal socket does not always come back on its own (planWsReconnect
|
// The terminal socket does not always come back on its own (planWsReconnect
|
||||||
// 'give-up'), so the same button re-arms it.
|
// 'give-up'), so the same button re-arms it. With the tile grid open the
|
||||||
if (this.activeSessionId && this._wsState !== 'connected') {
|
// main socket is parked on purpose: re-arm the tiles' sockets instead.
|
||||||
|
if (this._tilesOwnTerminal?.()) {
|
||||||
|
for (const { tile } of this._tileGrid.tiles.values()) tile.reconnectNow();
|
||||||
|
} else if (this.activeSessionId && this._wsState !== 'connected') {
|
||||||
this._wsReconnectAttempts = 0;
|
this._wsReconnectAttempts = 0;
|
||||||
this._connectWs(this.activeSessionId);
|
this._connectWs(this.activeSessionId);
|
||||||
}
|
}
|
||||||
@@ -4501,8 +4731,16 @@ class CodemanApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tile grid open (tile-grid.js): tiles whose sessions are gone are removed,
|
||||||
|
// the live ones are kept as they are and reconnect now, and the main
|
||||||
|
// terminal stays parked. Before the link below, which may leave the grid.
|
||||||
|
const tilesOpen = this._reconcileTileGrid?.() === true;
|
||||||
|
|
||||||
// A `#session=<id>` link wins over restoring the last active tab.
|
// A `#session=<id>` link wins over restoring the last active tab.
|
||||||
if (this._urlSessionId && this.sessions.has(this._urlSessionId)) {
|
if (this._urlSessionId && this.sessions.has(this._urlSessionId)) {
|
||||||
|
// And over a tile grid stored open: it stays remembered, closed, rather
|
||||||
|
// than reappearing unexplained on the next reload.
|
||||||
|
if (!tilesOpen) this._closeStoredTileGrid?.();
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
this._selectUrlSession();
|
this._selectUrlSession();
|
||||||
return;
|
return;
|
||||||
@@ -4510,6 +4748,10 @@ class CodemanApp {
|
|||||||
// Not listed yet: its wait starts now that the list has loaded, and the
|
// Not listed yet: its wait starts now that the list has loaded, and the
|
||||||
// last active tab is restored meanwhile.
|
// last active tab is restored meanwhile.
|
||||||
if (this._urlSessionId) this._armUrlSessionWait(this._urlSessionId);
|
if (this._urlSessionId) this._armUrlSessionWait(this._urlSessionId);
|
||||||
|
// The grid holds the focused session: nothing below may reconnect or reload
|
||||||
|
// the parked main terminal (its keepTerminal branch would reopen the main
|
||||||
|
// socket onto a session a tile already shows).
|
||||||
|
if (tilesOpen) return;
|
||||||
|
|
||||||
const previousActiveId = this.activeSessionId;
|
const previousActiveId = this.activeSessionId;
|
||||||
if (this.sessionOrder.length === 0) {
|
if (this.sessionOrder.length === 0) {
|
||||||
@@ -4528,8 +4770,17 @@ class CodemanApp {
|
|||||||
// a give-up) nothing else would re-establish it from here.
|
// a give-up) nothing else would re-establish it from here.
|
||||||
if (this._wsSessionId !== restoreId) this._connectWs(restoreId);
|
if (this._wsSessionId !== restoreId) this._connectWs(restoreId);
|
||||||
void this._onSessionNeedsRefresh({ id: restoreId });
|
void this._onSessionNeedsRefresh({ id: restoreId });
|
||||||
|
// The split pane's second terminal reconnects on its own backoff (up to
|
||||||
|
// 10 s between tries); the server is back now, so skip the wait. A no-op
|
||||||
|
// while its socket is open, stopped for good, or destroyed.
|
||||||
|
this._splitPane?.reconnectNow?.();
|
||||||
} else {
|
} else {
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
|
// A tile grid stored open on this device (tile-grid.js) comes back IN
|
||||||
|
// PLACE of the single-view restore below, so the main terminal never
|
||||||
|
// loads (its first select would pull a whole-history capture only to
|
||||||
|
// be parked a moment later).
|
||||||
|
if (this._restoreTileGrid?.()) return;
|
||||||
// `auto`: the app is restoring a session on load, not a human opening
|
// `auto`: the app is restoring a session on load, not a human opening
|
||||||
// one, so a pending idle alert on that tab stays armed until it is
|
// one, so a pending idle alert on that tab stays armed until it is
|
||||||
// actually tapped (see the userInitiated note in selectSession).
|
// actually tapped (see the userInitiated note in selectSession).
|
||||||
@@ -5241,9 +5492,6 @@ class CodemanApp {
|
|||||||
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
|
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
|
||||||
// Alt+N or a push notification could stay parked off-screen.
|
// Alt+N or a push notification could stay parked off-screen.
|
||||||
this._scrollActiveTabIntoView(sessionId);
|
this._scrollActiveTabIntoView(sessionId);
|
||||||
// Lineage lines draw only the SELECTED tab's family (session-lineage.js), so a
|
|
||||||
// selection change is a redraw whenever any lineage exists at all.
|
|
||||||
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -5489,6 +5737,9 @@ class CodemanApp {
|
|||||||
else if (wantIdle && !hasIdle) { tab.classList.add('tab-alert-idle'); tab.classList.remove('tab-alert-action'); }
|
else if (wantIdle && !hasIdle) { tab.classList.add('tab-alert-idle'); tab.classList.remove('tab-alert-action'); }
|
||||||
else if (!alertType && (hasAction || hasIdle)) { tab.classList.remove('tab-alert-action', 'tab-alert-idle'); }
|
else if (!alertType && (hasAction || hasIdle)) { tab.classList.remove('tab-alert-action', 'tab-alert-idle'); }
|
||||||
|
|
||||||
|
// Tile grid membership (tile-grid.js): a tiled session's tab says so.
|
||||||
|
tab.classList.toggle('in-tiles', !!this._tileGrid?.has(id));
|
||||||
|
|
||||||
// Inject tab-number badge if missing (added after initial render)
|
// Inject tab-number badge if missing (added after initial render)
|
||||||
if (!tab.querySelector('.tab-number')) {
|
if (!tab.querySelector('.tab-number')) {
|
||||||
const idx = this.sessionOrder.indexOf(id);
|
const idx = this.sessionOrder.indexOf(id);
|
||||||
@@ -5636,15 +5887,13 @@ class CodemanApp {
|
|||||||
this._refreshHomeSessionsIfVisible?.();
|
this._refreshHomeSessionsIfVisible?.();
|
||||||
// The full-render path already redraws the connection SVG; this incremental
|
// The full-render path already redraws the connection SVG; this incremental
|
||||||
// one does not, and a badge appearing widens a tab and shifts every tab after
|
// one does not, and a badge appearing widens a tab and shifts every tab after
|
||||||
// it, sliding the lineage lines off their anchors. Only pay for it when there
|
// it, sliding the lineage arcs off their anchors. Only pay for it when there
|
||||||
// is something anchored to tab rects: any lineage at all (not just what is
|
// is something anchored to tab rects: lineage arcs, or — in a VERTICAL list
|
||||||
// drawn now: a parentSessionId or a child's working state can arrive on this
|
// (sidebar, where lineage is skipped and the edge count stays 0, or the
|
||||||
// path and change the selected family's tree), or, in a VERTICAL list (the
|
// rail, which can show connectors with zero lineage edges too) — the
|
||||||
// sidebar, where lineage is skipped, or the rail, which can show connectors
|
// subagent/ultracode connectors, whose rows a badge changes the HEIGHT of.
|
||||||
// with zero lineage edges too), the subagent/ultracode connectors, whose
|
// Same widening as the strip-scroll listener in session-lineage.js.
|
||||||
// rows a badge changes the HEIGHT of. updateTabOverflowMode() above has just
|
if (this._lineageEdgeCount > 0 || this._isVerticalTabList()) this.updateConnectionLines();
|
||||||
// refreshed _lineageTotalEdges.
|
|
||||||
if (this._lineageTotalEdges > 0 || this._isVerticalTabList()) this.updateConnectionLines();
|
|
||||||
|
|
||||||
this.applySidebarFilter(this._sidebarFilter);
|
this.applySidebarFilter(this._sidebarFilter);
|
||||||
}
|
}
|
||||||
@@ -5656,10 +5905,6 @@ class CodemanApp {
|
|||||||
const container = this.$('sessionTabs');
|
const container = this.$('sessionTabs');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
// Lineage routing room (session-lineage.js) changes the strip's padding and
|
|
||||||
// row gap, so it is decided before the wrap is measured below.
|
|
||||||
this._syncLineageGutter?.();
|
|
||||||
|
|
||||||
// The sidebar list is a single vertical column with its own scroller —
|
// The sidebar list is a single vertical column with its own scroller —
|
||||||
// there is no row to overflow, and measuring it would fight the CSS.
|
// there is no row to overflow, and measuring it would fight the CSS.
|
||||||
if (this.isSessionSidebarActive()) {
|
if (this.isSessionSidebarActive()) {
|
||||||
@@ -5848,7 +6093,7 @@ class CodemanApp {
|
|||||||
const inlineSessionActions = this.shouldInlineSessionActions();
|
const inlineSessionActions = this.shouldInlineSessionActions();
|
||||||
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`;
|
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`;
|
||||||
|
|
||||||
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}${this._tileGrid?.has(id) ? ' in-tiles' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" data-aria-source="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||||
@@ -5856,7 +6101,7 @@ class CodemanApp {
|
|||||||
<span class="tab-name-row">
|
<span class="tab-name-row">
|
||||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
|
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
|
||||||
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
|
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
|
||||||
${paneExitBadge ? `<span class="tab-exited-badge" data-i18n-skip aria-hidden="true">${escapeHtml(paneExitBadge)}</span>` : ''}
|
${paneExitBadge ? `<span class="tab-exited-badge" data-label="${escapeHtml(paneExitBadge)}" aria-hidden="true">${escapeHtml(paneExitBadge)}</span>` : ''}
|
||||||
${inlineSessionActions ? tabActionsHtml : ''}
|
${inlineSessionActions ? tabActionsHtml : ''}
|
||||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -6272,6 +6517,10 @@ class CodemanApp {
|
|||||||
|
|
||||||
handleSessionTabClick(event, sessionId) {
|
handleSessionTabClick(event, sessionId) {
|
||||||
event?.preventDefault?.();
|
event?.preventDefault?.();
|
||||||
|
// Ctrl/Cmd+click puts the session in the tile grid (opening it if needed)
|
||||||
|
// instead of switching to it; on a window too narrow for the grid it is an
|
||||||
|
// ordinary click.
|
||||||
|
if ((event?.ctrlKey || event?.metaKey) && this.addSessionToTiles?.(sessionId)) return;
|
||||||
// On touch with the keyboard hidden, blur the tapped tab so switching
|
// On touch with the keyboard hidden, blur the tapped tab so switching
|
||||||
// sessions doesn't pop the on-screen keyboard. Focus policy itself lives
|
// sessions doesn't pop the on-screen keyboard. Focus policy itself lives
|
||||||
// in selectSession via _shouldFocusTerminalForTabSwitch().
|
// in selectSession via _shouldFocusTerminalForTabSwitch().
|
||||||
@@ -6748,6 +6997,7 @@ class CodemanApp {
|
|||||||
const index = groups.findIndex((group) => group.id === groupId);
|
const index = groups.findIndex((group) => group.id === groupId);
|
||||||
if (index < 0) return false;
|
if (index < 0) return false;
|
||||||
return this._openTabLayoutMenu(event, `group:${groupId}`, 'Group actions', [
|
return this._openTabLayoutMenu(event, `group:${groupId}`, 'Group actions', [
|
||||||
|
...(this.canOpenTileGrid?.() ? [{ label: 'Open group as tiles', run: () => this.openGroupAsTiles(groupId) }] : []),
|
||||||
{ label: 'Rename group', run: () => this.startTabGroupRename(groupId) },
|
{ label: 'Rename group', run: () => this.startTabGroupRename(groupId) },
|
||||||
...(this._canCreateTabGroup() ? [{ label: 'New group', run: () => this.createTabGroup({ index: index + 1 }) }] : []),
|
...(this._canCreateTabGroup() ? [{ label: 'New group', run: () => this.createTabGroup({ index: index + 1 }) }] : []),
|
||||||
...(index > 0 ? [{ label: 'Move group up', run: () => this.moveTabGroup(groupId, -1) }] : []),
|
...(index > 0 ? [{ label: 'Move group up', run: () => this.moveTabGroup(groupId, -1) }] : []),
|
||||||
@@ -7383,7 +7633,9 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_cleanupPreviousSession(newSessionId) {
|
// `skipSnapshot`: the tile grid opening on the session it parks
|
||||||
|
// (tile-grid.js openTileGrid), whose snapshot closing the grid discards.
|
||||||
|
_cleanupPreviousSession(newSessionId, { skipSnapshot = false } = {}) {
|
||||||
// Snapshot the OUTGOING session's xterm rendered state (viewport + scrollback +
|
// Snapshot the OUTGOING session's xterm rendered state (viewport + scrollback +
|
||||||
// colors/attrs) before the terminal gets cleared/reset. Lets us restore the
|
// colors/attrs) before the terminal gets cleared/reset. Lets us restore the
|
||||||
// exact view on switch-back rather than replaying codex's byte stream, which
|
// exact view on switch-back rather than replaying codex's byte stream, which
|
||||||
@@ -7394,6 +7646,7 @@ class CodemanApp {
|
|||||||
// quota for them. Unknown/undefined mode still snapshots, matching restore.
|
// quota for them. Unknown/undefined mode still snapshots, matching restore.
|
||||||
const outgoingSession = this.activeSessionId ? this.sessions?.get?.(this.activeSessionId) : null;
|
const outgoingSession = this.activeSessionId ? this.sessions?.get?.(this.activeSessionId) : null;
|
||||||
if (
|
if (
|
||||||
|
!skipSnapshot &&
|
||||||
this.activeSessionId &&
|
this.activeSessionId &&
|
||||||
outgoingSession?.mode !== 'shell' &&
|
outgoingSession?.mode !== 'shell' &&
|
||||||
this._serializeAddon &&
|
this._serializeAddon &&
|
||||||
@@ -7610,6 +7863,8 @@ class CodemanApp {
|
|||||||
const sessionId = this.activeSessionId;
|
const sessionId = this.activeSessionId;
|
||||||
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
|
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
|
||||||
if (this.detachedSessions?.has(sessionId)) return;
|
if (this.detachedSessions?.has(sessionId)) return;
|
||||||
|
// The parked main terminal has no history to pull while tiles own the screen.
|
||||||
|
if (this._tilesOwnTerminal?.()) return;
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
// A shell's full capture can be many megabytes, and replaying all of it from
|
// A shell's full capture can be many megabytes, and replaying all of it from
|
||||||
// an ordinary scroll gesture blocks xterm's main thread. So a shell scroll
|
// an ordinary scroll gesture blocks xterm's main thread. So a shell scroll
|
||||||
@@ -7883,6 +8138,20 @@ class CodemanApp {
|
|||||||
if (userInitiated) this.markIdleAlertSeen(sessionId);
|
if (userInitiated) this.markIdleAlertSeen(sessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Tile grid open (tile-grid.js): a tiled session is FOCUSED in its tile and
|
||||||
|
// never loaded into the parked main terminal. Decision 1: only a USER-
|
||||||
|
// initiated pick of a session that is not tiled (or an explicit
|
||||||
|
// `leaveTiles`, a followed link) leaves the grid for the single view, the
|
||||||
|
// grid remembered for one-click return. An app-driven pick (`auto`) never
|
||||||
|
// collapses it.
|
||||||
|
if (this._tileGrid?.open) {
|
||||||
|
if (this._tileGrid.has(sessionId)) return this._selectTiledSession(sessionId, options);
|
||||||
|
if (options?.auto === true && !options?.leaveTiles) return;
|
||||||
|
this.closeTileGrid({ keepStored: true, reselect: false });
|
||||||
|
// The parked terminal still holds what it showed before the grid opened;
|
||||||
|
// with no active id, the switch below snapshots none of it.
|
||||||
|
this.activeSessionId = null;
|
||||||
|
}
|
||||||
if (this.activeSessionId === sessionId && forceReload) {
|
if (this.activeSessionId === sessionId && forceReload) {
|
||||||
this.terminalBufferCache?.delete(sessionId);
|
this.terminalBufferCache?.delete(sessionId);
|
||||||
this._xtermSnapshots?.delete(sessionId);
|
this._xtermSnapshots?.delete(sessionId);
|
||||||
@@ -8458,78 +8727,7 @@ class CodemanApp {
|
|||||||
// Defer secondary panel updates so they don't block the main thread
|
// Defer secondary panel updates so they don't block the main thread
|
||||||
// after terminal content is already visible.
|
// after terminal content is already visible.
|
||||||
const idleCb = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb) => setTimeout(cb, 16);
|
const idleCb = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb) => setTimeout(cb, 16);
|
||||||
idleCb(() => {
|
idleCb(() => this._refreshSessionPanels(sessionId, selectGen));
|
||||||
// Guard against stale generation — user may have switched tabs again
|
|
||||||
if (selectGen !== this._selectGeneration) return;
|
|
||||||
|
|
||||||
// Update respawn banner
|
|
||||||
if (this.respawnStatus[sessionId]) {
|
|
||||||
this.showRespawnBanner();
|
|
||||||
this.updateRespawnBanner(this.respawnStatus[sessionId].state);
|
|
||||||
document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0;
|
|
||||||
this.updateCountdownTimerDisplay();
|
|
||||||
this.updateActionLogDisplay();
|
|
||||||
if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) {
|
|
||||||
this.startCountdownInterval();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.hideRespawnBanner();
|
|
||||||
this.stopCountdownInterval();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update task panel if open
|
|
||||||
const taskPanel = document.getElementById('taskPanel');
|
|
||||||
if (taskPanel && taskPanel.classList.contains('open')) {
|
|
||||||
this.renderTaskPanel();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update ralph state panel for this session
|
|
||||||
const curSession = this.sessions.get(sessionId);
|
|
||||||
if (curSession && (curSession.ralphLoop || curSession.ralphTodos)) {
|
|
||||||
this.updateRalphState(sessionId, {
|
|
||||||
loop: curSession.ralphLoop,
|
|
||||||
todos: curSession.ralphTodos
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.renderRalphStatePanel();
|
|
||||||
|
|
||||||
// Update CLI info bar (mobile - shows Claude version/model)
|
|
||||||
this.updateCliInfoDisplay();
|
|
||||||
|
|
||||||
// Update project insights panel for this session
|
|
||||||
this.renderProjectInsightsPanel();
|
|
||||||
|
|
||||||
// Update subagent window visibility for active session
|
|
||||||
this.updateSubagentWindowVisibility();
|
|
||||||
|
|
||||||
// Load file browser if enabled
|
|
||||||
const settings = this.loadAppSettingsFromStorage();
|
|
||||||
if (settings.showFileBrowser) {
|
|
||||||
const fileBrowserPanel = this.$('fileBrowserPanel');
|
|
||||||
if (fileBrowserPanel) {
|
|
||||||
fileBrowserPanel.classList.add('visible');
|
|
||||||
this.loadFileBrowser(sessionId);
|
|
||||||
// Attach drag listeners if not already attached
|
|
||||||
if (!this.fileBrowserDragListeners) {
|
|
||||||
const header = fileBrowserPanel.querySelector('.file-browser-header');
|
|
||||||
if (header) {
|
|
||||||
const onFirstDrag = () => {
|
|
||||||
if (!fileBrowserPanel.style.left) {
|
|
||||||
const rect = fileBrowserPanel.getBoundingClientRect();
|
|
||||||
fileBrowserPanel.style.left = `${rect.left}px`;
|
|
||||||
fileBrowserPanel.style.top = `${rect.top}px`;
|
|
||||||
fileBrowserPanel.style.right = 'auto';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
header.addEventListener('mousedown', onFirstDrag);
|
|
||||||
header.addEventListener('touchstart', onFirstDrag, { passive: true });
|
|
||||||
this.fileBrowserDragListeners = this.makeWindowDraggable(fileBrowserPanel, header);
|
|
||||||
this.fileBrowserDragListeners._onFirstDrag = onFirstDrag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open WebSocket for low-latency terminal I/O (after buffer load completes)
|
// Open WebSocket for low-latency terminal I/O (after buffer load completes)
|
||||||
this._connectWs(sessionId);
|
this._connectWs(sessionId);
|
||||||
@@ -8652,6 +8850,90 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panels that follow the active session (respawn banner and countdown,
|
||||||
|
* action log, task panel, Ralph state, CLI info, project insights, subagent
|
||||||
|
* window visibility, file browser). Run deferred, after the terminal content
|
||||||
|
* is on screen, by selectSession and by the tile grid's focus change
|
||||||
|
* (tile-grid.js _selectTiledSession), so both share one copy.
|
||||||
|
*
|
||||||
|
* @param {string} sessionId - the session that just became active
|
||||||
|
* @param {number} selectGen - the `_selectGeneration` of that selection; a
|
||||||
|
* newer one (the user switched again) makes this a no-op
|
||||||
|
*/
|
||||||
|
_refreshSessionPanels(sessionId, selectGen) {
|
||||||
|
// A newer selection won: the user switched tabs again.
|
||||||
|
if (selectGen !== this._selectGeneration) return;
|
||||||
|
|
||||||
|
// Update respawn banner
|
||||||
|
if (this.respawnStatus[sessionId]) {
|
||||||
|
this.showRespawnBanner();
|
||||||
|
this.updateRespawnBanner(this.respawnStatus[sessionId].state);
|
||||||
|
document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0;
|
||||||
|
this.updateCountdownTimerDisplay();
|
||||||
|
this.updateActionLogDisplay();
|
||||||
|
if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) {
|
||||||
|
this.startCountdownInterval();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.hideRespawnBanner();
|
||||||
|
this.stopCountdownInterval();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update task panel if open
|
||||||
|
const taskPanel = document.getElementById('taskPanel');
|
||||||
|
if (taskPanel && taskPanel.classList.contains('open')) {
|
||||||
|
this.renderTaskPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ralph state panel for this session
|
||||||
|
const curSession = this.sessions.get(sessionId);
|
||||||
|
if (curSession && (curSession.ralphLoop || curSession.ralphTodos)) {
|
||||||
|
this.updateRalphState(sessionId, {
|
||||||
|
loop: curSession.ralphLoop,
|
||||||
|
todos: curSession.ralphTodos
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.renderRalphStatePanel();
|
||||||
|
|
||||||
|
// Update CLI info bar (mobile - shows Claude version/model)
|
||||||
|
this.updateCliInfoDisplay();
|
||||||
|
|
||||||
|
// Update project insights panel for this session
|
||||||
|
this.renderProjectInsightsPanel();
|
||||||
|
|
||||||
|
// Update subagent window visibility for active session
|
||||||
|
this.updateSubagentWindowVisibility();
|
||||||
|
|
||||||
|
// Load file browser if enabled
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
if (settings.showFileBrowser) {
|
||||||
|
const fileBrowserPanel = this.$('fileBrowserPanel');
|
||||||
|
if (fileBrowserPanel) {
|
||||||
|
fileBrowserPanel.classList.add('visible');
|
||||||
|
this.loadFileBrowser(sessionId);
|
||||||
|
// Attach drag listeners if not already attached
|
||||||
|
if (!this.fileBrowserDragListeners) {
|
||||||
|
const header = fileBrowserPanel.querySelector('.file-browser-header');
|
||||||
|
if (header) {
|
||||||
|
const onFirstDrag = () => {
|
||||||
|
if (!fileBrowserPanel.style.left) {
|
||||||
|
const rect = fileBrowserPanel.getBoundingClientRect();
|
||||||
|
fileBrowserPanel.style.left = `${rect.left}px`;
|
||||||
|
fileBrowserPanel.style.top = `${rect.top}px`;
|
||||||
|
fileBrowserPanel.style.right = 'auto';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
header.addEventListener('mousedown', onFirstDrag);
|
||||||
|
header.addEventListener('touchstart', onFirstDrag, { passive: true });
|
||||||
|
this.fileBrowserDragListeners = this.makeWindowDraggable(fileBrowserPanel, header);
|
||||||
|
this.fileBrowserDragListeners._onFirstDrag = onFirstDrag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
|
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
|
||||||
_cleanupSessionData(sessionId) {
|
_cleanupSessionData(sessionId) {
|
||||||
this.closeTabRailActionMenu?.();
|
this.closeTabRailActionMenu?.();
|
||||||
@@ -8725,12 +9007,24 @@ class CodemanApp {
|
|||||||
// next session or dumped you on the home screen, depending on which path
|
// next session or dumped you on the home screen, depending on which path
|
||||||
// won the race (both outcomes measured on one build, 2026-08-17).
|
// won the race (both outcomes measured on one build, 2026-08-17).
|
||||||
const wasActive = this.activeSessionId === sessionId;
|
const wasActive = this.activeSessionId === sessionId;
|
||||||
|
// Tile grid open: the fallback is the NEIGHBOURING TILE, never the first
|
||||||
|
// sessionOrder entry (often not tiled, which would collapse the grid).
|
||||||
|
// Captured here for the same reason as wasActive: the SSE delete can remove
|
||||||
|
// the tile while the request is still in flight.
|
||||||
|
const grid = this._tileGrid;
|
||||||
|
const tileNeighborId = grid?.has(sessionId) ? window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId) : null;
|
||||||
this._closingSessions.add(sessionId);
|
this._closingSessions.add(sessionId);
|
||||||
try {
|
try {
|
||||||
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
|
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
|
||||||
this._cleanupSessionData(sessionId);
|
this._cleanupSessionData(sessionId);
|
||||||
|
// The last tile leaving closes the grid (no reselect): the pick below runs.
|
||||||
|
if (grid?.has(sessionId)) this.removeTile(sessionId, { refocus: false });
|
||||||
|
|
||||||
if (wasActive) {
|
if (wasActive && grid?.open) {
|
||||||
|
// `auto`: the app chose this tile because the previous one went away.
|
||||||
|
const target = grid.has(tileNeighborId) ? tileNeighborId : grid.ids[0];
|
||||||
|
this._selectTiledSession(target, { auto: true });
|
||||||
|
} else if (wasActive) {
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||||
// Next tab in the user's own order, skipping ids the cleanup has not
|
// Next tab in the user's own order, skipping ids the cleanup has not
|
||||||
@@ -8815,6 +9109,8 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nextSession() {
|
nextSession() {
|
||||||
|
// With the tile grid open, Ctrl+Tab and Alt+[ / Alt+] cycle through the tiles.
|
||||||
|
if (this._cycleTileFocus?.(1)) return;
|
||||||
if (this.sessionOrder.length <= 1) return;
|
if (this.sessionOrder.length <= 1) return;
|
||||||
|
|
||||||
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
|
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
|
||||||
@@ -8823,6 +9119,7 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prevSession() {
|
prevSession() {
|
||||||
|
if (this._cycleTileFocus?.(-1)) return;
|
||||||
if (this.sessionOrder.length <= 1) return;
|
if (this.sessionOrder.length <= 1) return;
|
||||||
|
|
||||||
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
|
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
|
||||||
@@ -8838,6 +9135,8 @@ class CodemanApp {
|
|||||||
// Going Home is choosing something else, so a `#session=<id>` link still
|
// Going Home is choosing something else, so a `#session=<id>` link still
|
||||||
// waiting for its session must not take the screen later.
|
// waiting for its session must not take the screen later.
|
||||||
this._retireUrlSession();
|
this._retireUrlSession();
|
||||||
|
// Home is a choice to leave the grid too; it is remembered for Tiles.
|
||||||
|
this.closeTileGrid?.({ keepStored: true, reselect: false });
|
||||||
// Deselect active session and show welcome screen
|
// Deselect active session and show welcome screen
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||||
@@ -8888,6 +9187,8 @@ class CodemanApp {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this._apiDelete('/api/sessions');
|
await this._apiDelete('/api/sessions');
|
||||||
|
// Every tiled session is gone: nothing left to remember or reselect.
|
||||||
|
this.closeTileGrid?.({ keepStored: false, reselect: false });
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
this.terminalBuffers.clear();
|
this.terminalBuffers.clear();
|
||||||
this.terminalBufferCache.clear();
|
this.terminalBufferCache.clear();
|
||||||
@@ -9084,11 +9385,16 @@ class CodemanApp {
|
|||||||
groups[g].push(shortcut);
|
groups[g].push(shortcut);
|
||||||
}
|
}
|
||||||
const fmtBindings = (s) => {
|
const fmtBindings = (s) => {
|
||||||
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd>${escapeHtml(b)}</kbd>`).join(' / ');
|
// Key names, never translated: "Home" is also a dictionary word (the Home
|
||||||
|
// button), so without the skip zh-CN showed the key as 主页.
|
||||||
|
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd data-i18n-skip>${escapeHtml(b)}</kbd>`).join(' / ');
|
||||||
if (!s.bindings) return '';
|
if (!s.bindings) return '';
|
||||||
|
// An action with no key (Close Session by default) is still listed, so the
|
||||||
|
// overlay says so instead of showing an empty key column.
|
||||||
|
if (s.bindings.length === 0) return '<span class="shortcut-overlay-unbound">not bound</span>';
|
||||||
return s.bindings.map((b) => {
|
return s.bindings.map((b) => {
|
||||||
const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
|
const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
|
||||||
return `<kbd>${escapeHtml(parts.join('+'))}</kbd>`;
|
return `<kbd data-i18n-skip>${escapeHtml(parts.join('+'))}</kbd>`;
|
||||||
}).join(' / ');
|
}).join(' / ');
|
||||||
};
|
};
|
||||||
list.innerHTML = Object.entries(groups).map(([group, items]) =>
|
list.innerHTML = Object.entries(groups).map(([group, items]) =>
|
||||||
|
|||||||
+636
-239
@@ -296,251 +296,139 @@ function computeTabScrollLeft(input) {
|
|||||||
return Math.min(Math.max(Math.round(target), 0), maxScroll);
|
return Math.min(Math.max(Math.round(target), 0), maxScroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session lineage lines: geometry for the lines joining a tab to the tabs it
|
// Session lineage lines — geometry for the arc drawn between a tab and a tab it
|
||||||
// spawned (a worker started through the codeman agent skill, which passes its own
|
// spawned (a worker started through the codeman agent skill, which passes its own
|
||||||
// id as parentSessionId). Pure: the caller measures and appends, this decides.
|
// id as parentSessionId). Pure: the caller measures and appends, this decides.
|
||||||
//
|
//
|
||||||
// ONE TREE PER SPAWNING TAB. Every family is drawn, the selected tab's emphasized
|
// ONE shape, because both endpoints live in the same horizontal strip and the subagent
|
||||||
// (session-lineage.js). Every route starts at the PARENT and ends at one child, so
|
// shape (tab-bottom → window-top) has nothing to aim at: a U-bridge HANGING BELOW the
|
||||||
// a parent's routes share their first stretch exactly: overlaid, they read as one
|
// strip, from the parent's bottom edge to the child's bottom edge, so it reads as a
|
||||||
// trunk with a branch per child, and a dashed (working) route stays in phase with
|
// bracket joining two tabs rather than as a line crossing them. The dip grows with
|
||||||
// its siblings along the shared part.
|
// horizontal distance and with `depth` (the child's index among its siblings), so
|
||||||
|
// several children of one parent nest instead of overprinting.
|
||||||
//
|
//
|
||||||
// ⚠ ROUTES RUN IN THE GAPS, NEVER THROUGH A TAB. This replaced one bezier per
|
// ⚠ A WRAPPED STRIP USED TO GET ITS OWN SHAPE, AND THAT SHAPE WAS THE BUG. When the
|
||||||
// child hanging below the strip, which in a wrapped strip crossed every lower
|
// desktop strip wraps (`tabs-two-rows` / `tabs-auto-wrap`) a parent on row 1 and its
|
||||||
// row's labels and the terminal text (owner screenshot 2026-10-06: a parent on
|
// child on row 2 are ~4px apart vertically, so the old parent-bottom → child-TOP bezier
|
||||||
// row 3 with ten children, "too confusing"). A route now moves horizontally only
|
// had a 4px span to work with and drew a flat horizontal line inside the row gap
|
||||||
// inside a row gap, and vertically only along a tab's own stem (its bottom edge to
|
// (reported as "they connect already, but the lines are straight and not easy visible"),
|
||||||
// the gap right under it) or along the SPINE, a channel left of every row that
|
// and three siblings drew three of them on top of each other. Aiming BOTH ends at the
|
||||||
// joins the gaps of different rows. styles.css reserves that room
|
// tab BOTTOMS and putting the control points below the LOWER row gives the wrapped case
|
||||||
// (`.session-tabs.lineage-tree`: a wider row gap, bottom padding for the last
|
// the same bracket as the flat case: it leaves the parent downward, crosses the lower
|
||||||
// row's gap, and the spine channel on the left of a wrapped strip).
|
// row once, and comes back up under the child. Same formula, no branch.
|
||||||
//
|
//
|
||||||
// Rows come from computeLineageRows() over EVERY tab in the strip, not only the
|
// Returns null when the edge must not be drawn: a missing/degenerate rect, or an
|
||||||
// endpoints: a row's gap sits under its TALLEST tab (the active tab is 2px
|
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
|
||||||
// taller), or siblings in one row would hang their bus at different heights.
|
// scrolled-out tab still HAS a rect — one lying over the logo or the header
|
||||||
//
|
// buttons. Skipping is honest; clamping would point at a tab that isn't there.
|
||||||
// computeLineageTree() returns null when the parent cannot be drawn (missing or
|
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and it has now been mis-tuned in BOTH
|
||||||
// degenerate rect, scrolled out of the strip); a child that cannot be drawn is
|
// directions, so treat these numbers as a corridor rather than a dial to crank:
|
||||||
// left out of `routes`. `.session-tabs` scrolls, so a scrolled-out tab still HAS a
|
// - Too shallow (the first ship, 44px cap): a skill worker is appended to the END of
|
||||||
// rect, lying over the logo or the header buttons. Skipping is honest; clamping
|
// the strip, so a lead-to-worker span is 800-1500px, and a 44px cap over 1300px is
|
||||||
// would point at a tab that is not there.
|
// a 33px sag, a line that reads as STRAIGHT across the terminal (#285).
|
||||||
const LINEAGE_CORNER_RADIUS_PX = 10;
|
// - Too deep (the 104px cap that replaced it): in the wrapped-strip case the cap and
|
||||||
// Families drawn together take separate lanes: gap lines this far apart, spines
|
// the FULL row offset stacked, bowing the bracket ~106px into the terminal text
|
||||||
// LINEAGE_SPINE_STEP_PX apart.
|
// (owner screenshot 2026-08-15, "die Linien machen einen grossen Bogen nach unten").
|
||||||
const LINEAGE_LANE_STEP_PX = 3.5;
|
// The dip is measured from the STRIP'S BOTTOM EDGE (falling back to the lower tab
|
||||||
// Every family is drawn at once, and a 12px row gap only fits this many lanes at
|
// bottom when the strip rect is missing or shorter than its tabs), which buys two
|
||||||
// LINEAGE_LANE_STEP_PX; session-lineage.js cycles families through them.
|
// things at once: the bow needs no per-row offsets stacked on top, and a same-row
|
||||||
const LINEAGE_MAX_LANES = 3;
|
// arc between ROW-1 tabs of a wrapped strip clears row 2's labels instead of being
|
||||||
const LINEAGE_SPINE_INSET_PX = 6;
|
// drawn through them (the retune's own first draft had exactly that regression).
|
||||||
const LINEAGE_SPINE_STEP_PX = 4;
|
const LINEAGE_DIP_BASE_PX = 14;
|
||||||
// The last row has no row below it; with no strip rect to measure, its gap is
|
const LINEAGE_DIP_PER_PX = 0.06;
|
||||||
// taken to be this deep.
|
const LINEAGE_DIP_MIN_PX = 22;
|
||||||
const LINEAGE_LAST_GAP_PX = 12;
|
const LINEAGE_DIP_MAX_PX = 64;
|
||||||
const LINEAGE_ROW_TOLERANCE_PX = 6;
|
// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
|
||||||
|
// apart bled into one thick band instead of reading as three separate lines.
|
||||||
|
const LINEAGE_SIBLING_STEP_PX = 8;
|
||||||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||||||
// How far the vertical rail's track sits in from the rail's left edge. It has to
|
// How far the vertical bracket sits in from the rail's left edge. It has to
|
||||||
// clear the VIEWPORT edge, not just the tabs, or the line reads as a thread pinned
|
// clear the VIEWPORT edge, not just the tabs: the line carries an 11px outer
|
||||||
// to the window frame. The rail reserves the channel itself
|
// glow, so a track at 6px had half of that glow clipped away and the arc read
|
||||||
// (`--lineage-vertical-gutter` on the rail's .session-tabs), and the track is
|
// as a thin thread pinned to the window frame. The rail reserves the channel
|
||||||
// still clamped to stay left of every endpoint.
|
// itself (`--lineage-vertical-gutter` on the rail's .session-tabs), and
|
||||||
|
// computeLineagePath still clamps the track to stay left of both tabs.
|
||||||
const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
|
const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
|
||||||
const LINEAGE_VERTICAL_LANE_STEP_PX = 4;
|
const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
|
||||||
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
|
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
|
||||||
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
|
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
|
||||||
// (session-lineage.js). Every line leaving one tab shares its colour however many
|
// (session-lineage.js). Every arc leaving one tab shares its colour however many
|
||||||
// workers it spawns; a child that spawns in turn gets its own for the lines below it.
|
// workers it spawns; a child that spawns in turn gets its own for the arcs below it.
|
||||||
// The empty FIRST entry means "no override": the CSS then falls back to --session-blue,
|
// The empty FIRST entry means "no override": the CSS then falls back to --session-blue,
|
||||||
// which every skin block tunes for its own background, so a lone family keeps the
|
// which every skin block tunes for its own background, so a lone arc keeps the
|
||||||
// skin-aware blue that shipped in 1.18.2. The fixed entries are deliberately vivid
|
// skin-aware blue that shipped in 1.18.2. The fixed entries are deliberately vivid
|
||||||
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on").
|
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on");
|
||||||
|
// they ride the same double glow as the blue, which is what keeps them legible over
|
||||||
|
// terminal text on every skin.
|
||||||
const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
|
const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
|
||||||
|
|
||||||
/** A rect normalized to numbers with its edges and center, or null if unusable. */
|
function computeLineagePath(input) {
|
||||||
function lineageRect(rect) {
|
const parent = input?.parent;
|
||||||
if (!rect) return null;
|
const child = input?.child;
|
||||||
const left = Number(rect.left);
|
if (!parent || !child) return null;
|
||||||
const top = Number(rect.top);
|
|
||||||
const width = Number(rect.width);
|
|
||||||
const height = Number(rect.height);
|
|
||||||
if (![left, top, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
|
|
||||||
return {
|
|
||||||
left,
|
|
||||||
top,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
right: left + width,
|
|
||||||
bottom: top + height,
|
|
||||||
cx: left + width / 2,
|
|
||||||
cy: top + height / 2,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const pw = Number(parent.width) || 0;
|
||||||
* Group tab rects into the strip's visual rows, top to bottom. A row spans from its
|
const ph = Number(parent.height) || 0;
|
||||||
* highest top to its LOWEST bottom, so a taller tab (the active one) sets the row's
|
const cw = Number(child.width) || 0;
|
||||||
* gap for everyone in it.
|
const ch = Number(child.height) || 0;
|
||||||
*/
|
if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null;
|
||||||
function computeLineageRows(rects) {
|
|
||||||
const rows = [];
|
|
||||||
for (const raw of rects || []) {
|
|
||||||
const r = lineageRect(raw);
|
|
||||||
if (!r) continue;
|
|
||||||
const row = rows.find((candidate) => Math.abs(candidate.top - r.top) <= LINEAGE_ROW_TOLERANCE_PX);
|
|
||||||
if (row) {
|
|
||||||
row.top = Math.min(row.top, r.top);
|
|
||||||
row.bottom = Math.max(row.bottom, r.bottom);
|
|
||||||
} else {
|
|
||||||
rows.push({ top: r.top, bottom: r.bottom });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows.sort((a, b) => a.top - b.top);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An orthogonal polyline as an SVG path, each corner rounded by up to `radius`
|
|
||||||
* (never more than half of either segment, so short stems stay short). Repeated and
|
|
||||||
* collinear points are dropped first, so a degenerate corner draws nothing odd.
|
|
||||||
*/
|
|
||||||
function lineagePolylinePath(points, radius) {
|
|
||||||
const pts = [];
|
|
||||||
for (const p of points) {
|
|
||||||
const prev = pts[pts.length - 1];
|
|
||||||
if (prev && Math.abs(prev[0] - p[0]) < 0.5 && Math.abs(prev[1] - p[1]) < 0.5) continue;
|
|
||||||
const before = pts[pts.length - 2];
|
|
||||||
if (before && prev) {
|
|
||||||
const cross = (prev[0] - before[0]) * (p[1] - prev[1]) - (prev[1] - before[1]) * (p[0] - prev[0]);
|
|
||||||
if (Math.abs(cross) < 0.01) pts.pop();
|
|
||||||
}
|
|
||||||
pts.push(p);
|
|
||||||
}
|
|
||||||
if (pts.length < 2) return null;
|
|
||||||
let d = `M ${r1(pts[0][0])} ${r1(pts[0][1])}`;
|
|
||||||
for (let i = 1; i < pts.length - 1; i++) {
|
|
||||||
const [x0, y0] = pts[i - 1];
|
|
||||||
const [x1, y1] = pts[i];
|
|
||||||
const [x2, y2] = pts[i + 1];
|
|
||||||
const lenIn = Math.hypot(x1 - x0, y1 - y0);
|
|
||||||
const lenOut = Math.hypot(x2 - x1, y2 - y1);
|
|
||||||
const r = Math.min(radius, lenIn / 2, lenOut / 2);
|
|
||||||
if (!(r > 0.5)) {
|
|
||||||
d += ` L ${r1(x1)} ${r1(y1)}`;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const ax = x1 - ((x1 - x0) / lenIn) * r;
|
|
||||||
const ay = y1 - ((y1 - y0) / lenIn) * r;
|
|
||||||
const bx = x1 + ((x2 - x1) / lenOut) * r;
|
|
||||||
const by = y1 + ((y2 - y1) / lenOut) * r;
|
|
||||||
d += ` L ${r1(ax)} ${r1(ay)} Q ${r1(x1)} ${r1(y1)} ${r1(bx)} ${r1(by)}`;
|
|
||||||
}
|
|
||||||
const last = pts[pts.length - 1];
|
|
||||||
return d + ` L ${r1(last[0])} ${r1(last[1])}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Routes from one parent tab to each of its children.
|
|
||||||
*
|
|
||||||
* input: { parent, children: [{ id, rect }], strip?, tabs?, orientation?, lane?,
|
|
||||||
* laneCount?, radius? }. `tabs` is every tab rect in the strip (rows are
|
|
||||||
* derived from it); `lane`/`laneCount` separate families drawn together.
|
|
||||||
* Returns { routes: [{ id, points, d, endX, endY }] } or null.
|
|
||||||
*/
|
|
||||||
function computeLineageTree(input) {
|
|
||||||
const parent = lineageRect(input?.parent);
|
|
||||||
if (!parent) return null;
|
|
||||||
const strip = lineageRect(input?.strip);
|
|
||||||
const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal';
|
const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal';
|
||||||
const laneCount = Math.max(1, Math.floor(Number(input?.laneCount)) || 1);
|
const strip = input?.strip;
|
||||||
const lane = Math.max(0, Math.min(laneCount - 1, Math.floor(Number(input?.lane)) || 0));
|
const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
|
||||||
const radius = Number.isFinite(Number(input?.radius)) ? Math.max(0, Number(input.radius)) : LINEAGE_CORNER_RADIUS_PX;
|
const pLeft = Number(parent.left);
|
||||||
const children = [];
|
const cLeft = Number(child.left);
|
||||||
for (const child of input?.children || []) {
|
const pTop = Number(parent.top);
|
||||||
const rect = lineageRect(child?.rect);
|
const cTop = Number(child.top);
|
||||||
if (rect) children.push({ id: child.id, rect });
|
if (![pLeft, cLeft, pTop, cTop].every(Number.isFinite)) return null;
|
||||||
}
|
|
||||||
|
|
||||||
const tol = LINEAGE_STRIP_TOLERANCE_PX;
|
|
||||||
const inStripY = (r) => !strip || (r.cy >= strip.top - tol && r.cy <= strip.bottom + tol);
|
|
||||||
const inStripX = (r) => !strip || (r.cx >= strip.left - tol && r.cx <= strip.right + tol);
|
|
||||||
const routes = [];
|
|
||||||
|
|
||||||
if (orientation === 'vertical') {
|
if (orientation === 'vertical') {
|
||||||
// The rail: one track down the empty left gutter, shared by every sibling.
|
const py = pTop + ph / 2;
|
||||||
if (!inStripY(parent)) return null;
|
const cy = cTop + ch / 2;
|
||||||
const visible = children.filter((c) => inStripY(c.rect));
|
if (strip && Number(strip.height) > 0) {
|
||||||
if (visible.length === 0) return { routes };
|
const min = Number(strip.top) - LINEAGE_STRIP_TOLERANCE_PX;
|
||||||
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left));
|
const max = Number(strip.top) + Number(strip.height) + LINEAGE_STRIP_TOLERANCE_PX;
|
||||||
const base = strip ? strip.left : minLeft - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
|
if (py < min || py > max || cy < min || cy > max) return null;
|
||||||
const trackX = Math.min(
|
|
||||||
base + LINEAGE_VERTICAL_TRACK_INSET_PX + lane * LINEAGE_VERTICAL_LANE_STEP_PX,
|
|
||||||
minLeft - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX
|
|
||||||
);
|
|
||||||
for (const { id, rect } of visible) {
|
|
||||||
const points = [
|
|
||||||
[parent.left, parent.cy],
|
|
||||||
[trackX, parent.cy],
|
|
||||||
[trackX, rect.cy],
|
|
||||||
[rect.left, rect.cy],
|
|
||||||
];
|
|
||||||
const d = lineagePolylinePath(points, radius);
|
|
||||||
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.left), endY: r1(rect.cy) });
|
|
||||||
}
|
|
||||||
return { routes };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!inStripX(parent) || !inStripY(parent)) return null;
|
const stripLeft =
|
||||||
const visible = children.filter((c) => inStripX(c.rect) && inStripY(c.rect));
|
strip && Number.isFinite(Number(strip.left))
|
||||||
if (visible.length === 0) return { routes };
|
? Number(strip.left)
|
||||||
|
: Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
|
||||||
const rows = computeLineageRows([...(input?.tabs || []), parent, ...visible.map((c) => c.rect)]);
|
const requestedTrack =
|
||||||
const rowOf = (r) => rows.findIndex((row) => r.cy >= row.top - tol && r.cy <= row.bottom + tol);
|
stripLeft + LINEAGE_VERTICAL_TRACK_INSET_PX + depth * LINEAGE_VERTICAL_SIBLING_STEP_PX;
|
||||||
const laneOffset = (lane - (laneCount - 1) / 2) * LINEAGE_LANE_STEP_PX;
|
const trackX = Math.min(requestedTrack, Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX);
|
||||||
// Y of the gap under row i, this family's lane. The offset is clamped so a busy
|
const d = `M ${r1(pLeft)} ${r1(py)} H ${r1(trackX)} V ${r1(cy)} H ${r1(cLeft)}`;
|
||||||
// gap never pushes a lane into the tabs on either side of it.
|
return { d, endX: cLeft, endY: cy, sameRow: false };
|
||||||
const gapUnder = (i) => {
|
|
||||||
const row = rows[i];
|
|
||||||
const next = rows[i + 1];
|
|
||||||
let bottom;
|
|
||||||
if (next) bottom = next.top;
|
|
||||||
else if (strip && strip.bottom > row.bottom + 1) bottom = strip.bottom;
|
|
||||||
else bottom = row.bottom + LINEAGE_LAST_GAP_PX;
|
|
||||||
const half = Math.max(0, (bottom - row.bottom) / 2 - 1);
|
|
||||||
return (row.bottom + bottom) / 2 + Math.max(-half, Math.min(half, laneOffset));
|
|
||||||
};
|
|
||||||
const pRow = rowOf(parent);
|
|
||||||
const gp = gapUnder(pRow);
|
|
||||||
const tabLefts = (input?.tabs || []).map(lineageRect).filter(Boolean).map((r) => r.left);
|
|
||||||
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left), ...tabLefts);
|
|
||||||
const spineBase = strip ? strip.left : minLeft - LINEAGE_SPINE_INSET_PX * 2;
|
|
||||||
const spineX = Math.min(spineBase + LINEAGE_SPINE_INSET_PX + lane * LINEAGE_SPINE_STEP_PX, minLeft - 2);
|
|
||||||
|
|
||||||
for (const { id, rect } of visible) {
|
|
||||||
const cRow = rowOf(rect);
|
|
||||||
const gc = gapUnder(cRow);
|
|
||||||
const points =
|
|
||||||
cRow === pRow
|
|
||||||
? [
|
|
||||||
[parent.cx, parent.bottom],
|
|
||||||
[parent.cx, gp],
|
|
||||||
[rect.cx, gp],
|
|
||||||
[rect.cx, rect.bottom],
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
[parent.cx, parent.bottom],
|
|
||||||
[parent.cx, gp],
|
|
||||||
[spineX, gp],
|
|
||||||
[spineX, gc],
|
|
||||||
[rect.cx, gc],
|
|
||||||
[rect.cx, rect.bottom],
|
|
||||||
];
|
|
||||||
const d = lineagePolylinePath(points, radius);
|
|
||||||
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.cx), endY: r1(rect.bottom) });
|
|
||||||
}
|
}
|
||||||
return { routes };
|
|
||||||
}
|
|
||||||
|
|
||||||
function roundPoints(points) {
|
const px = pLeft + pw / 2;
|
||||||
return points.map(([x, y]) => [r1(x), r1(y)]);
|
const cx = cLeft + cw / 2;
|
||||||
|
if (strip && Number(strip.width) > 0) {
|
||||||
|
const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX;
|
||||||
|
const max = Number(strip.left) + Number(strip.width) + LINEAGE_STRIP_TOLERANCE_PX;
|
||||||
|
if (px < min || px > max || cx < min || cx > max) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pBottom = pTop + ph;
|
||||||
|
const cBottom = cTop + ch;
|
||||||
|
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
|
||||||
|
|
||||||
|
// Both ends anchor on the tab BOTTOM, and the control points hang below the WHOLE
|
||||||
|
// strip, so one formula covers a flat strip, a wrapped pair, and a same-row pair
|
||||||
|
// sitting above further rows (see the corridor note above the constants).
|
||||||
|
const span = Math.abs(cx - px);
|
||||||
|
const stripBottom =
|
||||||
|
strip && Number(strip.height) > 0 && Number.isFinite(Number(strip.top))
|
||||||
|
? Number(strip.top) + Number(strip.height)
|
||||||
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
const baseline = Math.max(pBottom, cBottom, stripBottom);
|
||||||
|
const dip =
|
||||||
|
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
|
||||||
|
depth * LINEAGE_SIBLING_STEP_PX;
|
||||||
|
const yc = baseline + dip;
|
||||||
|
const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`;
|
||||||
|
return { d, endX: cx, endY: cBottom, sameRow };
|
||||||
}
|
}
|
||||||
|
|
||||||
// One decimal is plenty for a screen-space path and keeps the `d` string short.
|
// One decimal is plenty for a screen-space path and keeps the `d` string short.
|
||||||
@@ -1073,13 +961,12 @@ if (typeof window !== 'undefined') {
|
|||||||
plan: planWsReconnect,
|
plan: planWsReconnect,
|
||||||
};
|
};
|
||||||
window.CodemanLineage = {
|
window.CodemanLineage = {
|
||||||
computeTree: computeLineageTree,
|
computePath: computeLineagePath,
|
||||||
computeRows: computeLineageRows,
|
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
|
||||||
CORNER_RADIUS_PX: LINEAGE_CORNER_RADIUS_PX,
|
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
|
||||||
LANE_STEP_PX: LINEAGE_LANE_STEP_PX,
|
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
|
||||||
MAX_LANES: LINEAGE_MAX_LANES,
|
|
||||||
SPINE_INSET_PX: LINEAGE_SPINE_INSET_PX,
|
|
||||||
VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
|
VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
|
||||||
|
VERTICAL_SIBLING_STEP_PX: LINEAGE_VERTICAL_SIBLING_STEP_PX,
|
||||||
COLORS: LINEAGE_COLORS,
|
COLORS: LINEAGE_COLORS,
|
||||||
};
|
};
|
||||||
window.CodemanConnectionLoss = {
|
window.CodemanConnectionLoss = {
|
||||||
@@ -1725,27 +1612,459 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
|
|||||||
const result = [];
|
const result = [];
|
||||||
for (const id of sessionOrder) {
|
for (const id of sessionOrder) {
|
||||||
if (id === excludeId) continue;
|
if (id === excludeId) continue;
|
||||||
// A detached (popped-out) session's own window already yields its PTY
|
// A detached (popped-out) session's own window owns its PTY size (see
|
||||||
// size (see sendResize's detachedElsewhere guard in terminal-ui.js) —
|
// sendResize's detachedElsewhere guard in terminal-ui.js;
|
||||||
// Pane B's SplitTerminalPane._sendResize() has no such check, so letting
|
// TerminalTile._sendResize() stands aside the same way), so Pane B could
|
||||||
// one into the picker put its detached window and Pane B in a fight over
|
// only show it at a size it cannot set.
|
||||||
// the same PTY's dimensions.
|
|
||||||
if (detachedIds?.has?.(id)) continue;
|
if (detachedIds?.has?.(id)) continue;
|
||||||
const session = sessions.get(id);
|
const session = sessions.get(id);
|
||||||
if (!session) continue;
|
if (!session) continue;
|
||||||
// A session with no PTY attached (exited CLI, a crash-looped session
|
// A session with no PTY attached (exited CLI, a crash-looped session
|
||||||
// whose breaker tripped, a restore that failed to re-attach) has nothing
|
// whose breaker tripped, a restore that failed to re-attach) has nothing
|
||||||
// reading its tmux pane. SplitTerminalPane never does selectSession()'s
|
// reading its tmux pane, and the split never does selectSession()'s
|
||||||
// re-attach POST, so its socket would open onto a pane nothing feeds:
|
// re-attach POST: Pane B would open a healthy-looking socket onto a pane
|
||||||
// no terminal events, and Session.write() silently drops every keystroke
|
// that nothing feeds and nothing reads.
|
||||||
// with no ack either way (Pane B sends no `seq`), so the loss is
|
|
||||||
// invisible — the healthy socket never trips the disconnect banner.
|
|
||||||
if (session.pid === null) continue;
|
if (session.pid === null) continue;
|
||||||
result.push({ id, label: session.name || 'Session' });
|
result.push({ id, label: session.name || 'Session' });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tile grid (tile-grid.js) ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Pure layout and state helpers for the tile grid (docs/tile-grid-plan.md):
|
||||||
|
// 1 to TILE_GRID_MAX live sessions side by side, each in its own TerminalTile.
|
||||||
|
// Desktop only, behind the same 1180px gate as the split pane.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard cap on tiles in one grid: the ONE place it is set (owner decision 7 in
|
||||||
|
* docs/tile-grid-plan.md). Six was tested smooth on a real desktop; nine missed
|
||||||
|
* the headless frame bar and is untested on hardware. Everything that limits
|
||||||
|
* the grid reads this, and the layout table still covers up to TILE_LAYOUT_MAX,
|
||||||
|
* so raising the cap is this one line.
|
||||||
|
*/
|
||||||
|
const TILE_GRID_MAX = 6;
|
||||||
|
/** The largest count the layout table covers (3x3). Never a cap by itself. */
|
||||||
|
const TILE_LAYOUT_MAX = 9;
|
||||||
|
// The smallest tile worth showing: about 60 columns and a dozen rows at the
|
||||||
|
// default tile font. Bounds how many tiles a window can hold.
|
||||||
|
const TILE_MIN_W = 480;
|
||||||
|
const TILE_MIN_H = 240;
|
||||||
|
// Three tiles go side by side (3x1) only when each still gets ~600px;
|
||||||
|
// otherwise they take three cells of a 2x2.
|
||||||
|
const TILE_GRID_WIDE_3X1 = 1800;
|
||||||
|
// A tile's xterm keeps this many lines, not DEFAULT_SCROLLBACK: a grid of DOM
|
||||||
|
// renderers at 50k lines each is a real memory cost, and a tile's load is a
|
||||||
|
// bounded 1 MiB window anyway, so more scrollback only fills with live output.
|
||||||
|
const TILE_SCROLLBACK = 10000;
|
||||||
|
// Tiles have their own per-device font size (a tile is a fraction of the screen).
|
||||||
|
const TILE_FONT_SIZE_DEFAULT = 13;
|
||||||
|
// What the page's SSE filter names while tiles own the terminal: a value no
|
||||||
|
// session id takes (ids are UUIDs), so the server, whose filter gates only
|
||||||
|
// session:terminal batches, sends none. The tiles carry their own output over
|
||||||
|
// their own sockets, and the parked main terminal only parsed those frames to
|
||||||
|
// drop them (16 to 18 a second for one busy shell). Every other event still
|
||||||
|
// arrives (test/sse-tile-grid-filter.test.ts pins the server's side of this).
|
||||||
|
const TILE_GRID_SSE_FILTER = 'tile-grid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Columns and rows for `count` tiles, by count (the spec's table), and whether
|
||||||
|
* that layout gives every cell at least the minimum tile size (TILE_MIN_W x
|
||||||
|
* TILE_MIN_H) in a grid area of `width` x `height` px.
|
||||||
|
*
|
||||||
|
* @param {{count: number, width?: number, height?: number}} p
|
||||||
|
* @returns {{cols: number, rows: number, fits: boolean}}
|
||||||
|
*/
|
||||||
|
function computeTileLayout({ count, width = Infinity, height = Infinity }) {
|
||||||
|
const n = Math.min(Math.max(0, Math.floor(Number(count) || 0)), TILE_LAYOUT_MAX);
|
||||||
|
let cols;
|
||||||
|
let rows;
|
||||||
|
if (n === 0) return { cols: 0, rows: 0, fits: true };
|
||||||
|
if (n === 1) { cols = 1; rows = 1; }
|
||||||
|
else if (n === 2) { cols = 2; rows = 1; }
|
||||||
|
else if (n === 3) {
|
||||||
|
if (width >= TILE_GRID_WIDE_3X1) { cols = 3; rows = 1; }
|
||||||
|
else { cols = 2; rows = 2; }
|
||||||
|
}
|
||||||
|
else if (n === 4) { cols = 2; rows = 2; }
|
||||||
|
else if (n <= 6) { cols = 3; rows = 2; }
|
||||||
|
else { cols = 3; rows = 3; }
|
||||||
|
const fits = width / cols >= TILE_MIN_W && height / rows >= TILE_MIN_H;
|
||||||
|
return { cols, rows, fits };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many tiles a grid area can hold: the largest count up to TILE_GRID_MAX
|
||||||
|
* whose layout, and every smaller count's layout, fits. 0 when not even one
|
||||||
|
* tile fits.
|
||||||
|
*
|
||||||
|
* @param {{width: number, height: number}} p
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function tileGridCapacity({ width, height }) {
|
||||||
|
let capacity = 0;
|
||||||
|
for (let n = 1; n <= TILE_GRID_MAX; n++) {
|
||||||
|
if (!computeTileLayout({ count: n, width, height }).fits) break;
|
||||||
|
capacity = n;
|
||||||
|
}
|
||||||
|
return capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored grid (`codeman:tile-grid`, ids only) made safe to apply: unknown,
|
||||||
|
* deleted, detached and duplicate ids are dropped, the list is capped at
|
||||||
|
* TILE_GRID_MAX, `focused` / `zoomed` must name a kept id, and track fractions
|
||||||
|
* must be 1 to 3 finite positive numbers. Anything that is not a v1 object
|
||||||
|
* (or its JSON) gives null.
|
||||||
|
*
|
||||||
|
* The stored `ids` are the grid's CELLS in reading order, `null` for an empty
|
||||||
|
* one (a hole can be any cell). The old packed list (no nulls) reads as cells
|
||||||
|
* with no hole. `ids` comes back packed (the tiles in reading order, what
|
||||||
|
* every list consumer wants) and `cells` keeps the holes: a dropped id (gone,
|
||||||
|
* detached, a duplicate, past the cap) becomes `null` there, never a shift.
|
||||||
|
*
|
||||||
|
* @param {unknown} raw - the parsed value, or the stored JSON string
|
||||||
|
* @param {{has(id: string): boolean}|Iterable<string>} liveSessions - ids that exist now
|
||||||
|
* @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window
|
||||||
|
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], focused: string|null,
|
||||||
|
* zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
|
||||||
|
*/
|
||||||
|
function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
||||||
|
let value = raw;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try { value = JSON.parse(value); } catch { return null; }
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value) || value.v !== 1) return null;
|
||||||
|
const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
|
||||||
|
const ids = [];
|
||||||
|
const cells = [];
|
||||||
|
for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
|
||||||
|
const keep =
|
||||||
|
typeof id === 'string' && id && !ids.includes(id) && live.has(id) && !detachedIds?.has?.(id) &&
|
||||||
|
ids.length < TILE_GRID_MAX;
|
||||||
|
if (keep) ids.push(id);
|
||||||
|
// A malformed entry (not a string, not null) is a hole too.
|
||||||
|
cells.push(keep ? id : null);
|
||||||
|
}
|
||||||
|
const fractions = (fr) => {
|
||||||
|
if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null;
|
||||||
|
return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
v: 1,
|
||||||
|
open: value.open === true && ids.length > 0,
|
||||||
|
ids,
|
||||||
|
cells,
|
||||||
|
focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
|
||||||
|
zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
|
||||||
|
colFr: fractions(value.colFr),
|
||||||
|
rowFr: fractions(value.rowFr),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New track fractions after a divider drag (grid-template `fr` values): the two
|
||||||
|
* tracks either side of divider `index` trade `deltaPx` of size, each kept at
|
||||||
|
* least `minPx` (or half the pair, if the pair cannot give both the minimum).
|
||||||
|
* Every other track keeps its size. Computed from the fractions the drag
|
||||||
|
* STARTED with and the pointer's total travel, so a drag never drifts.
|
||||||
|
*
|
||||||
|
* @param {number[]} fr - the fractions when the drag started
|
||||||
|
* @param {number} index - the divider: between track `index` and `index + 1`
|
||||||
|
* @param {number} deltaPx - pointer travel since the drag started
|
||||||
|
* @param {number} totalPx - the size the tracks share (dividers and padding excluded)
|
||||||
|
* @param {number} minPx - the smallest a track may get
|
||||||
|
* @returns {number[]} new fractions, same length
|
||||||
|
*/
|
||||||
|
function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
|
||||||
|
const out = fr.slice();
|
||||||
|
if (index < 0 || index + 1 >= fr.length || !(totalPx > 0)) return out;
|
||||||
|
const sum = fr.reduce((a, b) => a + b, 0);
|
||||||
|
if (!(sum > 0)) return out;
|
||||||
|
const a = (fr[index] / sum) * totalPx;
|
||||||
|
const b = (fr[index + 1] / sum) * totalPx;
|
||||||
|
const pair = a + b;
|
||||||
|
const lo = Math.min(minPx, pair / 2);
|
||||||
|
const hi = pair - lo;
|
||||||
|
const nextA = Math.min(Math.max(a + (Number(deltaPx) || 0), lo), hi);
|
||||||
|
out[index] = (nextA / totalPx) * sum;
|
||||||
|
out[index + 1] = ((pair - nextA) / totalPx) * sum;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sessions the Tiles button can open (case c of tileGridOpenSet, and the
|
||||||
|
* ones a count fills a grid with), in tab order: live ones only, never a session popped out to
|
||||||
|
* its own window (that window owns its PTY size). A session with no PTY
|
||||||
|
* attached IS offered: its tile shows the Attach overlay.
|
||||||
|
*
|
||||||
|
* @param {Map<string, {name?: string}>} sessions
|
||||||
|
* @param {string[]} sessionOrder
|
||||||
|
* @param {{has(id: string): boolean}} [detachedIds]
|
||||||
|
* @returns {Array<{id: string, label: string}>}
|
||||||
|
*/
|
||||||
|
function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
|
||||||
|
const result = [];
|
||||||
|
for (const id of sessionOrder) {
|
||||||
|
if (detachedIds?.has?.(id)) continue;
|
||||||
|
const session = sessions.get(id);
|
||||||
|
if (!session) continue;
|
||||||
|
result.push({ id, label: session.name || 'Session' });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the Tiles button and Ctrl+Shift+G open, at once and without asking
|
||||||
|
* (owner decision 8). In order:
|
||||||
|
* a. the grid this tab last had (`stored`, already sanitized: live, not
|
||||||
|
* detached, at most the cap), if any of its sessions survive;
|
||||||
|
* b. else an open split's two sessions, Pane A focused;
|
||||||
|
* c. else the open sessions in tab order (buildTilePickerSessions: no
|
||||||
|
* detached ones), up to `limit`, the active session always among them and focused
|
||||||
|
* (when it sits past the limit, the first `limit - 1` others come with it).
|
||||||
|
* Null when there is nothing to open.
|
||||||
|
*
|
||||||
|
* @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null,
|
||||||
|
* split?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
|
||||||
|
* detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
|
||||||
|
* @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null}
|
||||||
|
*/
|
||||||
|
function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, detachedIds, activeId = null, limit }) {
|
||||||
|
if (stored?.ids?.length) {
|
||||||
|
const focus = stored.zoomed || stored.focused;
|
||||||
|
return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] };
|
||||||
|
}
|
||||||
|
const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
|
||||||
|
const pair = (split || []).filter(usable);
|
||||||
|
if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
|
||||||
|
const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX));
|
||||||
|
const all = buildTilePickerSessions(sessions, sessionOrder, detachedIds).map((c) => c.id);
|
||||||
|
if (all.length === 0) return null;
|
||||||
|
let ids = all.slice(0, max);
|
||||||
|
if (all.includes(activeId) && !ids.includes(activeId)) {
|
||||||
|
ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
|
||||||
|
}
|
||||||
|
return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tile counts the Tiles button's right-click menu offers, and the count a
|
||||||
|
* click opens until one is picked (owner decision 10 in docs/tile-grid-plan.md).
|
||||||
|
*/
|
||||||
|
const TILE_GRID_COUNTS = [2, 4, 6];
|
||||||
|
const TILE_GRID_COUNT_DEFAULT = 6;
|
||||||
|
|
||||||
|
/** A remembered tile count made safe: one of TILE_GRID_COUNTS, else the default. */
|
||||||
|
function sanitizeTileCount(raw) {
|
||||||
|
const n = Number(raw);
|
||||||
|
return TILE_GRID_COUNTS.includes(n) ? n : TILE_GRID_COUNT_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `base` (what the grid would open, or what an open grid shows, in its order)
|
||||||
|
* trimmed or filled to `n` tiles: trimmed from the end, the session to focus
|
||||||
|
* (`keepId`) always kept (it takes the last place when it sat past `n`, as in
|
||||||
|
* tileGridOpenSet's case c); filled from `all` (the open sessions in tab order)
|
||||||
|
* with the ones not in it yet. Fewer sessions than `n` give fewer tiles.
|
||||||
|
*
|
||||||
|
* @param {string[]} base
|
||||||
|
* @param {string[]} all
|
||||||
|
* @param {number} n - at most TILE_GRID_MAX
|
||||||
|
* @param {string|null} [keepId]
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function tileGridSetForCount(base, all, n, keepId = null) {
|
||||||
|
const max = Math.max(1, Math.min(Math.floor(Number(n) || 0), TILE_GRID_MAX));
|
||||||
|
const ids = [];
|
||||||
|
for (const id of [...(base || []), ...(all || [])]) {
|
||||||
|
if (typeof id === 'string' && id && !ids.includes(id)) ids.push(id);
|
||||||
|
}
|
||||||
|
// Every `base` id comes before every filler, so a trim never drops a base id
|
||||||
|
// in favour of one.
|
||||||
|
let out = ids.slice(0, max);
|
||||||
|
if (keepId && ids.includes(keepId) && !out.includes(keepId)) out = [...out.slice(0, max - 1), keepId];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which tile takes focus when `id` leaves the grid: the next one in grid
|
||||||
|
* order, else the previous one, else null.
|
||||||
|
*
|
||||||
|
* @param {string[]} ids - the grid's tiles, in reading order
|
||||||
|
* @param {string} id - the tile that is leaving
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function tileNeighbor(ids, id) {
|
||||||
|
const i = ids.indexOf(id);
|
||||||
|
if (i === -1) return ids[0] ?? null;
|
||||||
|
return ids[i + 1] ?? ids[i - 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tile a directional focus chord moves to, in a row-major grid of `cols`
|
||||||
|
* columns whose empty cells are `null` (a hole can be any cell) or simply
|
||||||
|
* missing at the end. Focus never lands on a hole. Left and right go along
|
||||||
|
* the row, past any hole, and never leave it. Up and down take the nearest
|
||||||
|
* row in that direction that has a tile: the tile in the same column, else
|
||||||
|
* the one in the nearest column (the lower column on a tie), so moving down
|
||||||
|
* onto a short or holed last row lands on its nearest tile. Null when there
|
||||||
|
* is no tile in that direction.
|
||||||
|
*
|
||||||
|
* @param {(string|null)[]} cells - the grid's cells in reading order (or its packed tiles)
|
||||||
|
* @param {string} focusedId - the tile the keyboard is in
|
||||||
|
* @param {'left'|'right'|'up'|'down'} direction
|
||||||
|
* @param {number} cols - the layout's column count
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function tileInDirection(cells, focusedId, direction, cols) {
|
||||||
|
const i = focusedId ? cells.indexOf(focusedId) : -1;
|
||||||
|
if (i === -1 || !(cols >= 1)) return null;
|
||||||
|
const rows = Math.ceil(cells.length / cols);
|
||||||
|
const row = Math.floor(i / cols);
|
||||||
|
const col = i % cols;
|
||||||
|
const at = (r, c) => cells[r * cols + c] || null;
|
||||||
|
if (direction === 'left' || direction === 'right') {
|
||||||
|
const step = direction === 'left' ? -1 : 1;
|
||||||
|
for (let c = col + step; c >= 0 && c < cols; c += step) {
|
||||||
|
if (at(row, c)) return at(row, c);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (direction !== 'up' && direction !== 'down') return null;
|
||||||
|
const step = direction === 'up' ? -1 : 1;
|
||||||
|
for (let r = row + step; r >= 0 && r < rows; r += step) {
|
||||||
|
let best = null;
|
||||||
|
let bestDistance = Infinity;
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const id = at(r, c);
|
||||||
|
if (id && Math.abs(c - col) < bestDistance) {
|
||||||
|
best = id;
|
||||||
|
bestDistance = Math.abs(c - col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best) return best;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cell next to cell `index` in that direction (Move Tile: a tile moves
|
||||||
|
* into an empty neighbour cell, or swaps with a tiled one), or -1 at the
|
||||||
|
* edge. Adjacent only: a move never jumps over a cell.
|
||||||
|
*
|
||||||
|
* @param {number} index - the cell, in reading order
|
||||||
|
* @param {'left'|'right'|'up'|'down'} direction
|
||||||
|
* @param {number} cols - the layout's column count
|
||||||
|
* @param {number} cellCount - cols x rows
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function tileCellInDirection(index, direction, cols, cellCount) {
|
||||||
|
if (!(cols >= 1) || index < 0 || index >= cellCount) return -1;
|
||||||
|
const col = index % cols;
|
||||||
|
let j = -1;
|
||||||
|
if (direction === 'left') j = col > 0 ? index - 1 : -1;
|
||||||
|
else if (direction === 'right') j = col < cols - 1 ? index + 1 : -1;
|
||||||
|
else if (direction === 'up') j = index - cols;
|
||||||
|
else if (direction === 'down') j = index + cols;
|
||||||
|
return j >= 0 && j < cellCount ? j : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grid's cells after its shape changed (or to fill one for the first
|
||||||
|
* time): `cols` x `rows` cells, `null` for an empty one. The same shape keeps
|
||||||
|
* every cell as it is. A new shape keeps each tile at its row and column when
|
||||||
|
* every tile still fits there (2x2 growing to 3x2: the four tiles stay put),
|
||||||
|
* and otherwise packs the tiles in reading order from the first cell, holes
|
||||||
|
* collapsed (positions do not map between shapes). `oldCols` 0 (no layout
|
||||||
|
* yet) always packs.
|
||||||
|
*
|
||||||
|
* @param {(string|null)[]} cells - the current cells, laid out `oldCols` wide
|
||||||
|
* @param {number} oldCols - the column count they were laid out with
|
||||||
|
* @param {number} cols
|
||||||
|
* @param {number} rows
|
||||||
|
* @returns {(string|null)[]}
|
||||||
|
*/
|
||||||
|
function fitTileCells(cells, oldCols, cols, rows) {
|
||||||
|
const size = Math.max(0, cols * rows);
|
||||||
|
if (oldCols === cols && cells.length === size) return cells.slice();
|
||||||
|
const out = new Array(size).fill(null);
|
||||||
|
const placed = cells.map((id, k) => (id ? { id, row: Math.floor(k / oldCols), col: k % oldCols } : null));
|
||||||
|
const keep = oldCols >= 1 && placed.every((p) => !p || (p.row < rows && p.col < cols));
|
||||||
|
if (keep) {
|
||||||
|
for (const p of placed) if (p) out[p.row * cols + p.col] = p.id;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
cells.filter(Boolean).slice(0, size).forEach((id, k) => {
|
||||||
|
out[k] = id;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many columns a grid of `length` cells was laid out with: stored cells
|
||||||
|
* carry no shape of their own, and the layout table gives each cell count one
|
||||||
|
* shape (computeTileLayout: 1x1, 2x1, 3x1, 2x2, 3x2, 3x3). 0 for any other
|
||||||
|
* length (fitTileCells then packs).
|
||||||
|
*
|
||||||
|
* @param {number} length
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function tileCellCols(length) {
|
||||||
|
// Every cell count is some count's shape on a wide grid area (2x2, the
|
||||||
|
// narrow 3-tile shape, is also the 4-tile one).
|
||||||
|
for (let n = 1; n <= TILE_LAYOUT_MAX; n++) {
|
||||||
|
const { cols, rows } = computeTileLayout({ count: n });
|
||||||
|
if (cols * rows === length) return cols;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cells of a grid re-formed to another set of tiles (a count picked in the
|
||||||
|
* Tiles menu, or the Tiles button bringing back a remembered grid with more or
|
||||||
|
* fewer tiles): the tiles in `keep` stay in their cells and every other cell
|
||||||
|
* empties, then the cell model's shape rule (fitTileCells: each tile keeps its
|
||||||
|
* row and column when all fit, else they pack in reading order), then the
|
||||||
|
* tiles in `add` fill the empty cells in reading order, holes first.
|
||||||
|
*
|
||||||
|
* @param {(string|null)[]} cells - the cells now, laid out `oldCols` wide
|
||||||
|
* @param {number} oldCols
|
||||||
|
* @param {string[]} keep - tiles that stay
|
||||||
|
* @param {string[]} add - tiles that join, in the order they fill
|
||||||
|
* @param {number} cols - the new shape
|
||||||
|
* @param {number} rows
|
||||||
|
* @returns {(string|null)[]}
|
||||||
|
*/
|
||||||
|
function reformTileCells(cells, oldCols, keep, add, cols, rows) {
|
||||||
|
const kept = (cells || []).map((id) => (id && keep.includes(id) ? id : null));
|
||||||
|
const out = fitTileCells(kept, oldCols, cols, rows);
|
||||||
|
for (const id of add) {
|
||||||
|
if (!id || out.includes(id)) continue;
|
||||||
|
const k = out.indexOf(null);
|
||||||
|
// Not for a shape made for the count; a full grid takes no more.
|
||||||
|
if (k === -1) break;
|
||||||
|
out[k] = id;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tile Ctrl+Tab / Alt+] (delta 1) or Alt+[ (delta -1) moves to while the
|
||||||
|
* grid is open: tiles cycle in reading order and wrap.
|
||||||
|
*
|
||||||
|
* @param {string[]} ids
|
||||||
|
* @param {string} focusedId
|
||||||
|
* @param {number} delta - +1 or -1
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function cycleTile(ids, focusedId, delta) {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const i = ids.indexOf(focusedId);
|
||||||
|
if (i === -1) return ids[0];
|
||||||
|
return ids[(i + delta + ids.length) % ids.length];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Renderer liveness ──────────────────────────────────────────────────────
|
// ── Renderer liveness ──────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to
|
// iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to
|
||||||
@@ -1982,7 +2301,59 @@ function sessionIdFromFragment(hash) {
|
|||||||
return id && id.trim() ? id.trim() : null;
|
return id && id.trim() ? id.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Longest model name a session header shows (the server caps it as well). */
|
||||||
|
const SESSION_MODEL_MAX_CHARS = 64;
|
||||||
|
/** A CLI registry id (src/config/cli-registry/schema.ts); anything else is not a class name. */
|
||||||
|
const CLI_ID_PATTERN = /^[a-z][a-z0-9-]{0,23}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a session's header says about its harness: the CLI id (the
|
||||||
|
* `run-mode-dot <id>` logo class), the registry's label for it, the model the
|
||||||
|
* session runs when the server knows it (`SessionState.displayModel`), and the
|
||||||
|
* tooltip naming both.
|
||||||
|
*
|
||||||
|
* The id is data: the label comes from the injected CLI catalog and falls back
|
||||||
|
* to the id, so a CLI added through clis.json still gets a name. The model is
|
||||||
|
* untrusted text (read off a pane, or a CLI's own report): control characters
|
||||||
|
* are dropped and the length capped here too, and callers render it with
|
||||||
|
* textContent. The tooltip says where a model that is not the CLI's own report
|
||||||
|
* came from, so it never claims more than the server knows: one the session
|
||||||
|
* was launched with may have been switched since, one read from the CLI's
|
||||||
|
* config is what it is configured to run, and a custom endpoint's model is the
|
||||||
|
* endpoint's, whatever the CLI calls it.
|
||||||
|
*
|
||||||
|
* @param {object} session - a session from app.sessions
|
||||||
|
* @param {Array<{id: string, label?: string}>} [catalog] - window.__codemanCliCatalog
|
||||||
|
* @returns {{id: string, label: string, model: string, title: string}}
|
||||||
|
*/
|
||||||
|
function describeSessionHarness(session, catalog) {
|
||||||
|
const id = typeof session?.mode === 'string' && CLI_ID_PATTERN.test(session.mode) ? session.mode : '';
|
||||||
|
const entry = id && Array.isArray(catalog) ? catalog.find((cli) => cli?.id === id) : null;
|
||||||
|
const label = (typeof entry?.label === 'string' && entry.label.trim()) || id;
|
||||||
|
const raw = session?.displayModel?.model;
|
||||||
|
const model =
|
||||||
|
typeof raw === 'string'
|
||||||
|
? raw
|
||||||
|
.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
|
||||||
|
.trim()
|
||||||
|
.slice(0, SESSION_MODEL_MAX_CHARS)
|
||||||
|
: '';
|
||||||
|
const source = session?.displayModel?.source;
|
||||||
|
const qualifier = !model
|
||||||
|
? ''
|
||||||
|
: source === 'launch'
|
||||||
|
? ' (set at launch)'
|
||||||
|
: source === 'custom-endpoint'
|
||||||
|
? ' (custom endpoint)'
|
||||||
|
: source === 'config'
|
||||||
|
? ' (from config)'
|
||||||
|
: '';
|
||||||
|
const title = [label, model].filter(Boolean).join(' \u00B7 ') + qualifier;
|
||||||
|
return { id, label, model, title };
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
|
||||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||||
window.CodemanTerminalLines = { terminalLogicalLine };
|
window.CodemanTerminalLines = { terminalLogicalLine };
|
||||||
@@ -1992,6 +2363,32 @@ if (typeof window !== 'undefined') {
|
|||||||
buildSplitPickerSessions,
|
buildSplitPickerSessions,
|
||||||
SPLIT_PANE_MIN_WIDTH,
|
SPLIT_PANE_MIN_WIDTH,
|
||||||
};
|
};
|
||||||
|
window.CodemanTileGrid = {
|
||||||
|
computeTileLayout,
|
||||||
|
tileGridCapacity,
|
||||||
|
sanitizeTileGridState,
|
||||||
|
buildTilePickerSessions,
|
||||||
|
dragTrackFractions,
|
||||||
|
tileNeighbor,
|
||||||
|
tileInDirection,
|
||||||
|
tileCellInDirection,
|
||||||
|
fitTileCells,
|
||||||
|
cycleTile,
|
||||||
|
tileGridOpenSet,
|
||||||
|
sanitizeTileCount,
|
||||||
|
tileGridSetForCount,
|
||||||
|
tileCellCols,
|
||||||
|
reformTileCells,
|
||||||
|
TILE_GRID_COUNTS,
|
||||||
|
TILE_GRID_COUNT_DEFAULT,
|
||||||
|
TILE_GRID_MAX,
|
||||||
|
TILE_LAYOUT_MAX,
|
||||||
|
TILE_MIN_W,
|
||||||
|
TILE_MIN_H,
|
||||||
|
TILE_SCROLLBACK,
|
||||||
|
TILE_FONT_SIZE_DEFAULT,
|
||||||
|
TILE_GRID_SSE_FILTER,
|
||||||
|
};
|
||||||
window.CodemanRenderLiveness = { shouldKickRenderer, RENDER_STALL_MS, RENDER_LIVENESS_POLL_MS };
|
window.CodemanRenderLiveness = { shouldKickRenderer, RENDER_STALL_MS, RENDER_LIVENESS_POLL_MS };
|
||||||
window.CodemanFetchDeadline = {
|
window.CodemanFetchDeadline = {
|
||||||
terminalFetchDeadlineMs,
|
terminalFetchDeadlineMs,
|
||||||
|
|||||||
@@ -96,6 +96,80 @@
|
|||||||
'Split: close the second session': '分屏:关闭第二个会话',
|
'Split: close the second session': '分屏:关闭第二个会话',
|
||||||
'Close split': '关闭分屏',
|
'Close split': '关闭分屏',
|
||||||
'No other sessions to split with': '没有其他可用于分屏的会话',
|
'No other sessions to split with': '没有其他可用于分屏的会话',
|
||||||
|
// Tile grid (tile-grid.js, docs/tile-grid-plan.md). 平铺 is the feature (the
|
||||||
|
// button, the setting, the grid), 窗格 one tile in it. Key names stay as
|
||||||
|
// they are; Click / Right-click are mouse actions, Arrows the arrow keys.
|
||||||
|
// Counts, exit codes and durations are patterns in translateDynamic.
|
||||||
|
Tiles: '平铺',
|
||||||
|
Split: '分屏',
|
||||||
|
'Tiled sessions': '平铺的会话',
|
||||||
|
'Tiles: show several sessions side by side (right-click for how many)':
|
||||||
|
'平铺:并排显示多个会话(右键单击可选择窗格数量)',
|
||||||
|
'Tiles: back to a single session (right-click for how many tiles)': '平铺:返回单个会话(右键单击可选择窗格数量)',
|
||||||
|
'How many tiles': '窗格数量',
|
||||||
|
// The Tiles button's hover card (the count and the fits note are patterns).
|
||||||
|
'Click: open the grid': '单击:打开平铺网格',
|
||||||
|
'Click: close the grid': '单击:关闭平铺网格',
|
||||||
|
'Right-click: choose 2, 4 or 6 tiles': '右键单击:选择 2、4 或 6 个窗格',
|
||||||
|
'Shift+F10: the same menu from the keyboard': 'Shift+F10:用键盘打开同一菜单',
|
||||||
|
'Split: unavailable while tiles are open': '分屏:平铺打开时不可用',
|
||||||
|
'Toggle Tile Grid': '切换平铺网格',
|
||||||
|
'Focus Tile Left': '聚焦左侧窗格',
|
||||||
|
'Focus Tile Right': '聚焦右侧窗格',
|
||||||
|
'Focus Tile Up': '聚焦上方窗格',
|
||||||
|
'Focus Tile Down': '聚焦下方窗格',
|
||||||
|
'Focus Tile Left / Right / Up / Down': '聚焦左侧 / 右侧 / 上方 / 下方窗格',
|
||||||
|
'Move Tile Left': '向左移动窗格',
|
||||||
|
'Move Tile Right': '向右移动窗格',
|
||||||
|
'Move Tile Up': '向上移动窗格',
|
||||||
|
'Move Tile Down': '向下移动窗格',
|
||||||
|
'Move Tile Left / Right / Up / Down': '向左 / 右 / 上 / 下移动窗格',
|
||||||
|
Drag: '拖动',
|
||||||
|
"a tile's header": '窗格的标题栏',
|
||||||
|
'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)',
|
||||||
|
'Zoom Focused Tile': '放大聚焦的窗格',
|
||||||
|
'Remove Focused Tile': '移除聚焦的窗格',
|
||||||
|
'Add the Session to the Tile Grid': '将该会话加入平铺网格',
|
||||||
|
'Choose How Many Tiles (2, 4 or 6)': '选择窗格数量(2、4 或 6)',
|
||||||
|
'a tab': '标签页',
|
||||||
|
'the Tiles button': '平铺按钮',
|
||||||
|
Click: '单击',
|
||||||
|
'Right-click': '右键单击',
|
||||||
|
Arrows: '方向键',
|
||||||
|
'not bound': '未绑定',
|
||||||
|
'Open group as tiles': '以平铺方式打开分组',
|
||||||
|
'No sessions to show as tiles': '没有可平铺显示的会话',
|
||||||
|
'This group has no session to show as tiles': '此分组没有可平铺显示的会话',
|
||||||
|
'Zoom this tile': '放大此窗格',
|
||||||
|
'Restore the grid': '恢复平铺网格',
|
||||||
|
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
|
||||||
|
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
|
||||||
|
// A tile header's tooltip while tiles can move (with the state above it: a pattern below).
|
||||||
|
'Drag to move the tile': '拖动可移动窗格',
|
||||||
|
'Resize tile columns': '调整窗格列宽',
|
||||||
|
'Resize tile rows': '调整窗格行高',
|
||||||
|
Attach: '附加',
|
||||||
|
'Attaching…': '正在附加…',
|
||||||
|
'Not attached': '未附加',
|
||||||
|
'The session ended': '会话已结束',
|
||||||
|
'The agent exited': '智能体已退出',
|
||||||
|
'It cannot be restarted in place: close it from ⋯ (Close session).': '无法原地重启:请通过 ⋯(关闭会话)关闭它。',
|
||||||
|
'Could not attach the session': '无法附加会话',
|
||||||
|
// The tab's exited-agent badge (app.js applyPaneExitBadge, Ark0N/Codeman#446);
|
||||||
|
// its exit-code forms and the tab's accessible name are patterns.
|
||||||
|
exited: '已退出',
|
||||||
|
// The Run button family (session-ui.js _applyRunMode; "Run CC", "Run SH" ...
|
||||||
|
// are a pattern; mode codes and product names stay), and the toolbar beside it.
|
||||||
|
'Terminal / Shell': '终端 / Shell',
|
||||||
|
'Send Enter': '发送回车',
|
||||||
|
// The Help modal and the shortcut overlay. Key names stay; Wheel is a mouse
|
||||||
|
// input like Click (单击).
|
||||||
|
Tabs: '标签页',
|
||||||
|
'Toggle Session Sidebar': '切换会话侧边栏',
|
||||||
|
'Copy Selection': '复制选中内容',
|
||||||
|
'Copy Selection (interrupts when nothing is selected)': '复制选中内容(无选中内容时中断)',
|
||||||
|
'Focus Tabs': '聚焦标签页',
|
||||||
|
Wheel: '滚轮',
|
||||||
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
|
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
|
||||||
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
|
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
|
||||||
Notifications: '通知',
|
Notifications: '通知',
|
||||||
@@ -951,6 +1025,17 @@
|
|||||||
return value.replace(/\{([a-zA-Z][\w]*)\}/g, (_match, key) => String(variables[key] ?? ''));
|
return value.replace(/\{([a-zA-Z][\w]*)\}/g, (_match, key) => String(variables[key] ?? ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The six-state words of a tile header's tooltip (tile-grid.js _paintTileHandle).
|
||||||
|
const TILE_STATE_ZH = {
|
||||||
|
'needs you': '需要你',
|
||||||
|
error: '错误',
|
||||||
|
waiting: '等待中',
|
||||||
|
working: '工作中',
|
||||||
|
idle: '空闲',
|
||||||
|
done: '已完成',
|
||||||
|
exited: '已退出',
|
||||||
|
};
|
||||||
|
|
||||||
function translateDynamic(source) {
|
function translateDynamic(source) {
|
||||||
const patterns = [
|
const patterns = [
|
||||||
[/^(\d+) tokens?$/, (_m, count) => `${count} 个 Token`],
|
[/^(\d+) tokens?$/, (_m, count) => `${count} 个 Token`],
|
||||||
@@ -975,6 +1060,71 @@
|
|||||||
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
|
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
|
||||||
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
|
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
|
||||||
],
|
],
|
||||||
|
// Tile grid: counts, exit codes and durations pass through.
|
||||||
|
[/^(\d+) tiles$/, (_m, n) => `${n} 个窗格`],
|
||||||
|
[/^Tiles \u00B7 (\d+)$/, (_m, n) => `平铺 · ${n}`],
|
||||||
|
[
|
||||||
|
/^This window fits (\d+) tiles?: a click opens (\d+)$/,
|
||||||
|
(_m, n, m) => `此窗口可容纳 ${n} 个窗格:单击将打开 ${m} 个`,
|
||||||
|
],
|
||||||
|
[/^This window fits (\d+) tiles?$/, (_m, n) => `此窗口可容纳 ${n} 个窗格`],
|
||||||
|
[/^The grid holds at most (\d+) tiles$/, (_m, n) => `平铺网格最多容纳 ${n} 个窗格`],
|
||||||
|
[
|
||||||
|
/^The grid already holds what this window fits \((\d+)\)$/,
|
||||||
|
(_m, n) => `平铺网格已达到此窗口可容纳的数量(${n})`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^The grid holds at most (\d+) tiles: the new session opens on its own$/,
|
||||||
|
(_m, n) => `平铺网格最多容纳 ${n} 个窗格:新会话将单独打开`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^The grid already holds what this window fits \((\d+)\): the new session opens on its own$/,
|
||||||
|
(_m, n) => `平铺网格已达到此窗口可容纳的数量(${n}):新会话将单独打开`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^The window is too small for (\d+) tiles: showing the focused one$/,
|
||||||
|
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
|
||||||
|
],
|
||||||
|
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
|
||||||
|
[/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`],
|
||||||
|
// A session header's harness logo (tile grid, split pane): "<harness> · <model>",
|
||||||
|
// and where the model came from when the CLI did not report it. The harness
|
||||||
|
// and model names pass through untranslated.
|
||||||
|
[/^(.+) \(set at launch\)$/, (_m, names) => `${names}(启动时设定)`],
|
||||||
|
[/^(.+) \(custom endpoint\)$/, (_m, names) => `${names}(自定义端点)`],
|
||||||
|
[/^(.+) \(from config\)$/, (_m, names) => `${names}(来自配置)`],
|
||||||
|
// The Run button's mode codes ("Run CC", "Run SH", "Run OC" ...; a registry
|
||||||
|
// CLI's shortBadge too). Exact entries win first ("Run Shell", "Run OMP").
|
||||||
|
[/^Run ([A-Z][A-Z0-9]{1,5})$/, (_m, code) => `运行 ${code}`],
|
||||||
|
// The tab's exited-agent badge, and the tab's accessible name carrying it.
|
||||||
|
// The session name is user text: it passes through untranslated.
|
||||||
|
[/^exited \((-?\d+)\)$/, (_m, code) => `已退出(${code})`],
|
||||||
|
[/^exited \(signal (\d+)\)$/, (_m, signal) => `已退出(信号 ${signal})`],
|
||||||
|
[
|
||||||
|
/^(.+) session, agent exited \(signal (\d+)\)$/,
|
||||||
|
(_m, name, signal) => `${name} 会话,智能体已退出(信号 ${signal})`,
|
||||||
|
],
|
||||||
|
[/^(.+) session, agent exited \((-?\d+)\)$/, (_m, name, code) => `${name} 会话,智能体已退出(${code})`],
|
||||||
|
[/^(.+) session, agent exited$/, (_m, name) => `${name} 会话,智能体已退出`],
|
||||||
|
// A session name is user text: it passes through untranslated.
|
||||||
|
[
|
||||||
|
/^(.+) was stopped after crashing repeatedly\. Restart it\?$/,
|
||||||
|
(_m, name) => `${name} 因反复崩溃已被停止。要重启吗?`,
|
||||||
|
],
|
||||||
|
// A tile header's tooltip: a state and how long ("idle 3m"). The duration
|
||||||
|
// is required: bare state words stay out of the table, they collide with
|
||||||
|
// state strings on other surfaces (see mobile-overview.js).
|
||||||
|
[
|
||||||
|
/^(needs you|error|waiting|working|idle|done|exited) (<1m|\d+[dhm](?: \d+[hm])?)$/,
|
||||||
|
(_m, state, duration) => `${TILE_STATE_ZH[state]} ${duration}`,
|
||||||
|
],
|
||||||
|
// The same while tiles can move, with the drag hint on a second line.
|
||||||
|
// Anchored on the hint, so a bare state word is safe here.
|
||||||
|
[
|
||||||
|
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\nDrag to move the tile$/,
|
||||||
|
(_m, state, duration) =>
|
||||||
|
`${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN['Drag to move the tile']}`,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
for (const [pattern, replacement] of patterns) {
|
for (const [pattern, replacement] of patterns) {
|
||||||
const match = source.match(pattern);
|
const match = source.match(pattern);
|
||||||
@@ -1033,6 +1183,23 @@
|
|||||||
return !element || Boolean(element.closest(SKIP_SELECTOR));
|
return !element || Boolean(element.closest(SKIP_SELECTOR));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// xterm's DOM renderer rewrites its rows (`.xterm-rows > div`) on every frame
|
||||||
|
// a pane changes: thousands of mutation records a second with a grid of tiles,
|
||||||
|
// each paying a closest() over the whole skip list. All rows of one terminal
|
||||||
|
// share that parent, so its own shouldSkip() verdict is kept once it says
|
||||||
|
// skip; a skip verdict cannot lapse, since xterm keeps `.xterm-rows` inside
|
||||||
|
// its `.xterm`. A rows container that is not skipped is never kept: its rows
|
||||||
|
// go through the full check below like any other node.
|
||||||
|
const skippedRows = new WeakSet();
|
||||||
|
function isSkippedRow(node) {
|
||||||
|
const rows = node.parentNode;
|
||||||
|
if (!rows?.classList?.contains('xterm-rows')) return false;
|
||||||
|
if (skippedRows.has(rows)) return true;
|
||||||
|
if (!shouldSkip(rows)) return false;
|
||||||
|
skippedRows.add(rows);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function shouldSkipText(node) {
|
function shouldSkipText(node) {
|
||||||
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
||||||
return shouldSkip(node) || Boolean(element?.closest(USER_TEXT_SELECTOR));
|
return shouldSkip(node) || Boolean(element?.closest(USER_TEXT_SELECTOR));
|
||||||
@@ -1128,6 +1295,13 @@
|
|||||||
observer = new MutationObserver((mutations) => {
|
observer = new MutationObserver((mutations) => {
|
||||||
if (applying) return;
|
if (applying) return;
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
|
// A change inside a skipped surface cannot need translating: every
|
||||||
|
// node it adds or edits sits under the same skip ancestor, so both
|
||||||
|
// translators would return on their own closest() check anyway. One
|
||||||
|
// check per record instead of one per text node and attribute matters
|
||||||
|
// for xterm's DOM renderer, which replaces rows every frame (the split
|
||||||
|
// pane, every tile of the grid).
|
||||||
|
if (isSkippedRow(mutation.target) || shouldSkip(mutation.target)) continue;
|
||||||
if (mutation.type === 'characterData') translateNode(mutation.target);
|
if (mutation.type === 'characterData') translateNode(mutation.target);
|
||||||
if (mutation.type === 'attributes') translateAttributes(mutation.target);
|
if (mutation.type === 'attributes') translateAttributes(mutation.target);
|
||||||
for (const added of mutation.addedNodes) translateNode(added);
|
for (const added of mutation.addedNodes) translateNode(added);
|
||||||
|
|||||||
@@ -50,8 +50,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
||||||
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
||||||
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
||||||
_handleImagePaste() {
|
// `target` names the terminal the Ctrl+V came from and its session; both
|
||||||
|
// default to the primary pane. A second terminal (the split pane) passes its
|
||||||
|
// own, so text pastes into THAT xterm and images upload to THAT session.
|
||||||
|
_handleImagePaste(target = {}) {
|
||||||
const self = this;
|
const self = this;
|
||||||
|
const terminal = target.terminal || this.terminal;
|
||||||
|
const sessionId = target.sessionId || this.activeSessionId;
|
||||||
|
|
||||||
// Create a hidden contenteditable div to receive the paste
|
// Create a hidden contenteditable div to receive the paste
|
||||||
const trap = document.createElement('div');
|
const trap = document.createElement('div');
|
||||||
@@ -93,11 +98,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
||||||
// Refocus the terminal
|
// Refocus the terminal
|
||||||
if (self.terminal) self.terminal.focus();
|
if (terminal) terminal.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
if (imageFiles.length > 0) {
|
if (imageFiles.length > 0) {
|
||||||
self._uploadAndInsertImages(imageFiles);
|
self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
|
||||||
} else {
|
} else {
|
||||||
// No image -- route text through xterm's paste() so bracketed-paste
|
// No image -- route text through xterm's paste() so bracketed-paste
|
||||||
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
||||||
@@ -106,7 +111,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// indistinguishable from typed input, weakening the CLI's
|
// indistinguishable from typed input, weakening the CLI's
|
||||||
// prompt-injection defenses.
|
// prompt-injection defenses.
|
||||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||||
if (text && self.terminal) self.terminal.paste(text);
|
if (text && terminal) terminal.paste(text);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,9 +131,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
/** Upload a batch and normally insert its paths into the active terminal.
|
/** Upload a batch and normally insert its paths into the active terminal.
|
||||||
* The prompt composer passes `{ insert: false }` so it can put those paths
|
* The prompt composer passes `{ insert: false }` so it can put those paths
|
||||||
* into its textarea instead. Returns successful paths in selection order. */
|
* into its textarea instead. `options.sessionId` names the session to upload
|
||||||
|
* to (default: the active one). Returns successful paths in selection order. */
|
||||||
async _uploadAndInsertImages(fileList, options = {}) {
|
async _uploadAndInsertImages(fileList, options = {}) {
|
||||||
const sessionId = this.activeSessionId;
|
const sessionId = options.sessionId || this.activeSessionId;
|
||||||
if (!sessionId) return [];
|
if (!sessionId) return [];
|
||||||
|
|
||||||
let files = Array.from(fileList || []);
|
let files = Array.from(fileList || []);
|
||||||
@@ -179,8 +185,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const paths = results.filter(Boolean);
|
const paths = results.filter(Boolean);
|
||||||
if (paths.length > 0 && options.insert !== false) {
|
if (paths.length > 0 && options.insert !== false) {
|
||||||
// Insert all paths in one shot, space-separated, in selection order.
|
// Insert all paths in one shot, space-separated, in selection order, into
|
||||||
await this.sendInput(paths.join(' '));
|
// the session the batch was uploaded TO. Not sendInput(): it re-reads
|
||||||
|
// activeSessionId, and after the awaits above that is whatever tab the
|
||||||
|
// user switched to mid-upload, so the paths landed in the wrong session.
|
||||||
|
// Same delivery sendInput() uses (durable queue, useMux for the POST path).
|
||||||
|
this._sendInputAsync(sessionId, paths.join(' '), { useMux: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final status: successes, plus any failures / cap so nothing is silent.
|
// Final status: successes, plus any failures / cap so nothing is silent.
|
||||||
|
|||||||
@@ -189,9 +189,10 @@
|
|||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path class="icon-folder-closed" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><g class="icon-folder-open"><path d="M3 17V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1.5"/><path d="M3 17l2.3-5.4A2 2 0 0 1 7.2 10.5H20a1.5 1.5 0 0 1 1.4 2l-1.9 5.2A2 2 0 0 1 17.6 19H5a2 2 0 0 1-2-2z"/></g></svg></button>
|
||||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||||
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
||||||
|
<button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)" aria-describedby="tileGridHint" aria-label="Tiles: show several sessions side by side (right-click for how many)" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button>
|
||||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
||||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||||
@@ -435,6 +436,12 @@
|
|||||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tile grid (tile-grid.js): 1 to 6 sessions side by side, each a
|
||||||
|
TerminalTile. A SIBLING of .terminal-wrap, never a parent: while
|
||||||
|
.main.tiles-active is set the main terminal is parked (hidden) and
|
||||||
|
this section takes its place. -->
|
||||||
|
<section class="tile-grid" id="tileGrid" aria-label="Tiled sessions"></section>
|
||||||
|
|
||||||
<!-- Web tab layer: one iframe per open dashboard, shown in place of the
|
<!-- Web tab layer: one iframe per open dashboard, shown in place of the
|
||||||
terminal while a web tab is active. Frames stay mounted while hidden so
|
terminal while a web tab is active. Frames stay mounted while hidden so
|
||||||
switching tabs does not reload (and re-authenticate) a dashboard. -->
|
switching tabs does not reload (and re-authenticate) a dashboard. -->
|
||||||
@@ -807,7 +814,6 @@
|
|||||||
<section class="shortcut-section">
|
<section class="shortcut-section">
|
||||||
<h4>Session</h4>
|
<h4>Session</h4>
|
||||||
<div class="shortcuts-grid">
|
<div class="shortcuts-grid">
|
||||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
|
||||||
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
|
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
|
||||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||||
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
||||||
@@ -822,11 +828,23 @@
|
|||||||
<div><kbd>Ctrl</kbd>+<kbd>}</kbd></div><div>Move Active Tab Right</div>
|
<div><kbd>Ctrl</kbd>+<kbd>}</kbd></div><div>Move Active Tab Right</div>
|
||||||
<div><kbd>ArrowLeft</kbd> / <kbd>ArrowUp</kbd></div><div>Focus Previous Tab</div>
|
<div><kbd>ArrowLeft</kbd> / <kbd>ArrowUp</kbd></div><div>Focus Previous Tab</div>
|
||||||
<div><kbd>ArrowRight</kbd> / <kbd>ArrowDown</kbd></div><div>Focus Next Tab</div>
|
<div><kbd>ArrowRight</kbd> / <kbd>ArrowDown</kbd></div><div>Focus Next Tab</div>
|
||||||
<div><kbd>Home</kbd></div><div>Focus First Tab</div>
|
<div><kbd data-i18n-skip>Home</kbd></div><div>Focus First Tab</div>
|
||||||
<div><kbd>End</kbd></div><div>Focus Last Tab</div>
|
<div><kbd>End</kbd></div><div>Focus Last Tab</div>
|
||||||
<div><kbd>Enter</kbd> / <kbd>Space</kbd></div><div>Activate Focused Tab</div>
|
<div><kbd>Enter</kbd> / <kbd>Space</kbd></div><div>Activate Focused Tab</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="shortcut-section">
|
||||||
|
<h4>Tiles</h4>
|
||||||
|
<div class="shortcuts-grid">
|
||||||
|
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd></div><div>Toggle Tile Grid</div>
|
||||||
|
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div>
|
||||||
|
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Move Tile Left / Right / Up / Down</div>
|
||||||
|
<div><kbd>Drag</kbd> a tile's header</div><div>Move the Tile (onto Another: Swap)</div>
|
||||||
|
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div>
|
||||||
|
<div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div>
|
||||||
|
<div><kbd>Right-click</kbd> the Tiles button</div><div>Choose How Many Tiles (2, 4 or 6)</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="shortcut-section">
|
<section class="shortcut-section">
|
||||||
<h4>Terminal</h4>
|
<h4>Terminal</h4>
|
||||||
<div class="shortcuts-grid">
|
<div class="shortcuts-grid">
|
||||||
@@ -1904,7 +1922,7 @@
|
|||||||
<div class="set-group-head"><h4>Header buttons</h4><span class="set-scope">device</span></div>
|
<div class="set-group-head"><h4>Header buttons</h4><span class="set-scope">device</span></div>
|
||||||
<p class="set-group-hint">Tap to show a control in the header. Multi-monitor is the one entry here that syncs across devices.</p>
|
<p class="set-group-hint">Tap to show a control in the header. Multi-monitor is the one entry here that syncs across devices.</p>
|
||||||
<div class="set-group-body">
|
<div class="set-group-body">
|
||||||
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response file viewer attachments monitor session away cron redraw">
|
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response file viewer attachments monitor session away cron redraw split tiles">
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="1" data-preview-text="A+"><input type="checkbox" id="appSettingsShowFontControls"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20 10 5l6 15"/><path d="M6.5 15h7"/><path d="M18 12h4M20 10v4"/></svg><span>Font Size</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="1" data-preview-text="A+"><input type="checkbox" id="appSettingsShowFontControls"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20 10 5l6 15"/><path d="M6.5 15h7"/><path d="M18 12h4M20 10v4"/></svg><span>Font Size</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="CPU 12%"><input type="checkbox" id="appSettingsShowSystemStats" checked><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2.5-7 4 14L16 12h5"/></svg><span>System Stats</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="CPU 12%"><input type="checkbox" id="appSettingsShowSystemStats" checked><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2.5-7 4 14L16 12h5"/></svg><span>System Stats</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="3"><input type="checkbox" id="appSettingsShowRedrawButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg><span>Redraw Terminal</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="3"><input type="checkbox" id="appSettingsShowRedrawButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg><span>Redraw Terminal</span></label>
|
||||||
@@ -1914,7 +1932,8 @@
|
|||||||
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
<label class="set-chip" data-search="split pane side by side two sessions" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
||||||
|
<label class="set-chip" data-search="tiles tile grid side by side several sessions" data-preview="header" data-preview-order="11.6"><input type="checkbox" id="appSettingsShowTileGridButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg><span>Tiles</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
|
||||||
</div>
|
</div>
|
||||||
@@ -2153,7 +2172,7 @@
|
|||||||
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
|
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
|
||||||
<div class="set-row-text">
|
<div class="set-row-text">
|
||||||
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
|
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
|
||||||
<span class="set-row-desc">Draw lines from each session to the sessions it spawned. The selected tab's family is drawn thicker.</span>
|
<span class="set-row-desc">Draw a line under the tab strip from a session to the sessions it spawned.</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
@@ -3919,7 +3938,9 @@
|
|||||||
<script defer src="app.js"></script>
|
<script defer src="app.js"></script>
|
||||||
<script defer src="tab-rail-resize.js"></script>
|
<script defer src="tab-rail-resize.js"></script>
|
||||||
<script defer src="terminal-ui.js"></script>
|
<script defer src="terminal-ui.js"></script>
|
||||||
|
<script defer src="terminal-tile.js"></script>
|
||||||
<script defer src="terminal-split.js"></script>
|
<script defer src="terminal-split.js"></script>
|
||||||
|
<script defer src="tile-grid.js"></script>
|
||||||
<script defer src="respawn-ui.js"></script>
|
<script defer src="respawn-ui.js"></script>
|
||||||
<script defer src="ralph-panel.js"></script>
|
<script defer src="ralph-panel.js"></script>
|
||||||
<script defer src="orchestrator-panel.js"></script>
|
<script defer src="orchestrator-panel.js"></script>
|
||||||
|
|||||||
@@ -1,41 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview Session lineage lines: the tree joining a tab to the tabs it spawned.
|
* @fileoverview Session lineage lines — the arcs joining a tab to the tabs it spawned.
|
||||||
*
|
*
|
||||||
* A session that starts another session (the `codeman` agent skill spawning a worker,
|
* A session that starts another session (the `codeman` agent skill spawning a worker,
|
||||||
* which passes its own `$CODEMAN_SESSION_ID`) gets `parentSessionId` stamped on its
|
* which passes its own `$CODEMAN_SESSION_ID`) gets `parentSessionId` stamped on its
|
||||||
* state server-side. This module turns that field into a quiet orthogonal tree, one per
|
* state server-side. This module turns that field into the same kind of glowing
|
||||||
* spawning tab, routed through the gaps between tab rows so it never crosses a label or
|
* connection line the subagent windows use, but tab → tab, so the strip shows at a
|
||||||
* the terminal (geometry: `CodemanLineage.computeTree` in constants.js).
|
* glance which tab spawned which.
|
||||||
*
|
|
||||||
* EVERY FAMILY IS ALWAYS DRAWN, AND THE SELECTED TAB'S IS EMPHASIZED: the family the
|
|
||||||
* active tab spawned, and the family it belongs to as a child, get the
|
|
||||||
* `lineage-family--focus` group (thicker, full opacity, drawn last so nothing covers
|
|
||||||
* it). Drawing ONLY the selected family was tried first and rejected by the owner
|
|
||||||
* (2026-10-07: "I wanna see all the connections always"). Selection still has to
|
|
||||||
* redraw to move the emphasis, which `_updateActiveTabImmediate()` does whenever any
|
|
||||||
* lineage exists (`_lineageTotalEdges`).
|
|
||||||
*
|
*
|
||||||
* It is an ADDITIONAL LAYER on the existing SVG pass, not a second pass: the core
|
* It is an ADDITIONAL LAYER on the existing SVG pass, not a second pass: the core
|
||||||
* `_updateConnectionLinesImmediate()` (subagent-windows.js) calls
|
* `_updateConnectionLinesImmediate()` (subagent-windows.js) calls
|
||||||
* `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode does,
|
* `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode does,
|
||||||
* so every layer shares ONE batched read → write reflow and one tab-rect cache.
|
* so every layer shares ONE batched read → write reflow and one tab-rect cache.
|
||||||
*
|
*
|
||||||
* Constraints that are not obvious from the code:
|
* Two constraints that are not obvious from the code:
|
||||||
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (lines paint
|
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (arcs paint
|
||||||
* over it, which is what lets them touch tab bottoms), but under 1024px mobile.css
|
* over it, which is what lets them touch tab bottoms), but under 1024px mobile.css
|
||||||
* makes the header `position: fixed; z-index: 1200` and would bury them. The phone
|
* makes the header `position: fixed; z-index: 1200` and would bury them. The phone
|
||||||
* strip is also a scroller where both endpoints are rarely on screen at once.
|
* strip is also a scroller where both endpoints are rarely on screen at once.
|
||||||
* - The routing room is RESERVED in CSS (`.session-tabs.lineage-tree`), toggled by
|
|
||||||
* `_syncLineageGutter()` from `updateTabOverflowMode()`. It keys on whether ANY
|
|
||||||
* family exists, never on the selection, so switching tabs never resizes the header
|
|
||||||
* (and with it the terminal and the PTY).
|
|
||||||
* - Paths carry `data-agent-id="lineage:<childId>"` because that is the attribute
|
* - Paths carry `data-agent-id="lineage:<childId>"` because that is the attribute
|
||||||
* `_applyLineEntrances()` queries, so the draw-in animation and its
|
* `_applyLineEntrances()` queries, so the draw-in animation and its
|
||||||
* negative-`animation-delay` resume across `svg.innerHTML = ''` come for free.
|
* negative-`animation-delay` resume across `svg.innerHTML = ''` come for free.
|
||||||
*
|
*
|
||||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||||
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
|
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
|
||||||
* @dependency constants.js (window.CodemanLineage.computeTree + .COLORS)
|
* @dependency constants.js (window.CodemanLineage.computePath + .COLORS)
|
||||||
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
|
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
|
||||||
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
||||||
*/
|
*/
|
||||||
@@ -74,99 +62,49 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
applyLineageLineSettings() {
|
applyLineageLineSettings() {
|
||||||
const prev = this._lineageLinesOn;
|
const prev = this._lineageLinesOn;
|
||||||
const next = this._syncLineageLinesEnabled();
|
const next = this._syncLineageLinesEnabled();
|
||||||
if (prev !== next) {
|
if (prev !== next) this.updateConnectionLines();
|
||||||
// The reserved routing room follows the setting; updateTabOverflowMode()
|
|
||||||
// re-syncs it and re-measures the wrap with the new padding.
|
|
||||||
this.updateTabOverflowMode?.();
|
|
||||||
this.updateConnectionLines();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reserve (or release) the strip's routing room: `.lineage-tree` on #sessionTabs
|
* Every parent → child pair worth drawing, with the child's index among its siblings
|
||||||
* widens the row gap, pads the bottom for the last row's gap, and opens the spine
|
* (that index is what nests sibling arcs instead of overprinting them).
|
||||||
* channel on the left of a wrapped strip (styles.css). Called at the top of
|
|
||||||
* `updateTabOverflowMode()`, so it runs on every tab render, BEFORE the wrap is
|
|
||||||
* measured.
|
|
||||||
*
|
*
|
||||||
* Keyed on whether any family exists at all, never on which one is selected: a
|
* Walks `sessionOrder` rather than the sessions Map so sibling depth follows the
|
||||||
* class that followed the selection would grow and shrink the header on every tab
|
* strip's own left-to-right order, which is what the user sees.
|
||||||
* switch, and the header's height is the terminal's height.
|
|
||||||
*
|
|
||||||
* Only the header strip routes through reserved gaps. The vertical rail keeps its
|
|
||||||
* own `--lineage-vertical-gutter`, and the sidebar draws no lineage.
|
|
||||||
*/
|
|
||||||
_syncLineageGutter() {
|
|
||||||
const edges = this._lineageLinesEnabled() ? this._collectLineageEdges() : [];
|
|
||||||
this._lineageTotalEdges = edges.length;
|
|
||||||
const strip = document.getElementById('sessionTabs');
|
|
||||||
if (!strip) return;
|
|
||||||
const want = edges.length > 0 && !this._isVerticalTabList?.();
|
|
||||||
if (strip.classList.contains('lineage-tree') !== want) strip.classList.toggle('lineage-tree', want);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every parent → child pair, in strip order. Walks `sessionOrder` rather than the
|
|
||||||
* sessions Map so sibling order follows the strip's own left-to-right order, which
|
|
||||||
* is what the user sees.
|
|
||||||
*/
|
*/
|
||||||
_collectLineageEdges() {
|
_collectLineageEdges() {
|
||||||
const edges = [];
|
const edges = [];
|
||||||
if (!this.sessions || this.sessions.size < 2) return edges;
|
if (!this.sessions || this.sessions.size < 2) return edges;
|
||||||
const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()];
|
const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()];
|
||||||
|
const seenPerParent = new Map();
|
||||||
for (const id of order) {
|
for (const id of order) {
|
||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
const parentId = session && session.parentSessionId;
|
const parentId = session && session.parentSessionId;
|
||||||
// A parent that is gone (closed, or never came back after a restart) draws
|
// A parent that is gone (closed, or never came back after a restart) draws
|
||||||
// nothing: the field is decoration, so a dangling one is simply not rendered.
|
// nothing: the field is decoration, so a dangling one is simply not rendered.
|
||||||
if (!parentId || parentId === id || !this.sessions.has(parentId)) continue;
|
if (!parentId || parentId === id || !this.sessions.has(parentId)) continue;
|
||||||
edges.push({ parentId, childId: id, status: session.status || 'idle' });
|
const depth = seenPerParent.get(parentId) || 0;
|
||||||
|
seenPerParent.set(parentId, depth + 1);
|
||||||
|
edges.push({ parentId, childId: id, depth, status: session.status || 'idle' });
|
||||||
}
|
}
|
||||||
return edges;
|
return edges;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Every family as `{ parentId, edges }`, in strip order of first appearance. */
|
|
||||||
_lineageFamilies(edges) {
|
|
||||||
const families = new Map();
|
|
||||||
for (const edge of edges) {
|
|
||||||
if (!families.has(edge.parentId)) families.set(edge.parentId, []);
|
|
||||||
families.get(edge.parentId).push(edge);
|
|
||||||
}
|
|
||||||
return [...families].map(([parentId, familyEdges]) => ({ parentId, edges: familyEdges }));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parent ids of the families the selection emphasizes: the family the selected tab
|
* Colour for one arc, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
|
||||||
* spawned, and the family it was spawned into (its parent plus its siblings). Empty
|
|
||||||
* when a web tab holds the stage, or the selected tab has no lineage at all.
|
|
||||||
*/
|
|
||||||
_lineageFocusParents(edges) {
|
|
||||||
const parents = new Set();
|
|
||||||
const focus = this.activeWebviewId ? null : this.activeSessionId;
|
|
||||||
if (!focus) return parents;
|
|
||||||
for (const edge of edges) {
|
|
||||||
if (edge.parentId === focus || edge.childId === focus) parents.add(edge.parentId);
|
|
||||||
}
|
|
||||||
return parents;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Colour for one family, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
|
|
||||||
*
|
*
|
||||||
* ⚠ Per PARENT, not per child: every line leaving one tab is the same colour, no
|
* ⚠️ Per PARENT, not per child: every arc leaving one tab is the same colour, no
|
||||||
* matter how many workers it spawns, so the strip reads as "these five came from
|
* matter how many workers it spawns, so the strip reads as "these five came from
|
||||||
* w1, those two came from w2". Keying it per child instead gave one tab's own
|
* w1, those two came from w2". Keying it per child instead gave one tab's own
|
||||||
* children a different colour each, which is the thing the colours exist to tell
|
* children a different colour each, which is the thing the colours exist to tell
|
||||||
* apart. A child that goes on to spawn its own workers is a parent in its turn and
|
* apart. A child that goes on to spawn its own workers is a parent in its turn and
|
||||||
* gets its own colour for the lines BELOW it, so a chain changes colour at each
|
* gets its own colour for the arcs BELOW it, so a chain changes colour at each
|
||||||
* generation while each generation's fan-out stays uniform.
|
* generation while each generation's fan-out stays uniform.
|
||||||
*
|
*
|
||||||
* Assigned in FIRST-SEEN order and remembered per parent id. First-seen rather than
|
* Assigned in FIRST-SEEN order and remembered per parent id. First-seen rather than
|
||||||
* draw-index keeps a colour stable across re-renders, tab reorders and sibling
|
* draw-index keeps a colour stable across re-renders, tab reorders and sibling
|
||||||
* closes (the SVG is wiped and rebuilt constantly, so an index-based colour would
|
* closes (the SVG is wiped and rebuilt constantly, so an index-based colour would
|
||||||
* flicker). The draw pass claims a colour for EVERY family in strip order before it
|
* flicker). An empty string means "no override": the CSS falls back to
|
||||||
* draws anything, so neither the draw order (the selected family goes last) nor
|
|
||||||
* which family was selected first ever decides who gets which colour. An empty string means "no override": the CSS falls back to
|
|
||||||
* --session-blue, so the first spawning tab keeps the skin-aware blue.
|
* --session-blue, so the first spawning tab keeps the skin-aware blue.
|
||||||
*/
|
*/
|
||||||
_lineageColorFor(parentId) {
|
_lineageColorFor(parentId) {
|
||||||
@@ -202,20 +140,19 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_appendLineageConnectionLines(svg, rects) {
|
_appendLineageConnectionLines(svg, rects) {
|
||||||
this._lineageEdgeCount = 0;
|
this._lineageEdgeCount = 0;
|
||||||
if (!svg || !this._lineageLinesEnabled()) return;
|
if (!svg || !this._lineageLinesEnabled()) return;
|
||||||
// Sidebar layout: the tree is routed for a horizontal strip or the vertical
|
// Sidebar layout: computeLineagePath()'s whole geometry — the U-bridge hung
|
||||||
// rail. The sidebar is a vertical list with its own scroller and no reserved
|
// from the STRIP's bottom edge, the 64px dip corridor — assumes a horizontal
|
||||||
// channel; parent/child adjacency reads fine there without lines.
|
// tab row. Against a vertical list the "strip bottom" is the bottom of the
|
||||||
|
// sidebar, so every arc would draw a giant loop to the foot of the list.
|
||||||
|
// Parent/child adjacency reads fine in a vertical list without arcs; a
|
||||||
|
// sideways lineage shape is a follow-up with its own visual tuning, not a
|
||||||
|
// by-product of a layout port.
|
||||||
if (this.isSessionSidebarActive?.()) return;
|
if (this.isSessionSidebarActive?.()) return;
|
||||||
const computeTree = window.CodemanLineage && window.CodemanLineage.computeTree;
|
const compute = window.CodemanLineage && window.CodemanLineage.computePath;
|
||||||
if (!computeTree) return;
|
if (!compute) return;
|
||||||
|
|
||||||
const edges = this._collectLineageEdges();
|
const edges = this._collectLineageEdges();
|
||||||
this._lineageTotalEdges = edges.length;
|
|
||||||
if (edges.length === 0) return;
|
if (edges.length === 0) return;
|
||||||
// Claim colours in strip order for every family before drawing (_lineageColorFor).
|
|
||||||
for (const edge of edges) this._lineageColorFor(edge.parentId);
|
|
||||||
const families = this._lineageFamilies(edges);
|
|
||||||
const focusParents = this._lineageFocusParents(edges);
|
|
||||||
if (!rects) rects = new Map();
|
if (!rects) rects = new Map();
|
||||||
|
|
||||||
// PHASE 1 — reads.
|
// PHASE 1 — reads.
|
||||||
@@ -225,9 +162,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const orientation =
|
const orientation =
|
||||||
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
||||||
// A session hidden inside a collapsed group of the grouped rail has no row
|
// A session hidden inside a collapsed group of the grouped rail has no row
|
||||||
// to anchor to, so its end of the line moves to that group's header (a
|
// to anchor to, so its end of the arc moves to that group's header (a
|
||||||
// "proxied" endpoint, drawn quieter). A child proxied to the same header as
|
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
|
||||||
// its parent would be a line from a row to itself: skipped.
|
// header would be an arc from a row to itself: skipped.
|
||||||
const resolveEndpoint = (id) => {
|
const resolveEndpoint = (id) => {
|
||||||
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||||
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
|
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
|
||||||
@@ -236,125 +173,71 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
|
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
|
||||||
return { key: 'group:' + groupId, element: header, proxied: !!header };
|
return { key: 'group:' + groupId, element: header, proxied: !!header };
|
||||||
};
|
};
|
||||||
const measure = (endpoint) => {
|
const resolvedEdges = [];
|
||||||
if (!rects.has(endpoint.key)) {
|
for (const edge of edges) {
|
||||||
|
const parentEndpoint = resolveEndpoint(edge.parentId);
|
||||||
|
const childEndpoint = resolveEndpoint(edge.childId);
|
||||||
|
if (parentEndpoint.key === childEndpoint.key) continue;
|
||||||
|
resolvedEdges.push({ edge, parentEndpoint, childEndpoint });
|
||||||
|
for (const endpoint of [parentEndpoint, childEndpoint]) {
|
||||||
|
if (rects.has(endpoint.key)) continue;
|
||||||
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
|
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
|
||||||
}
|
}
|
||||||
return rects.get(endpoint.key);
|
|
||||||
};
|
|
||||||
const resolvedFamilies = [];
|
|
||||||
for (const family of families) {
|
|
||||||
const parentEndpoint = resolveEndpoint(family.parentId);
|
|
||||||
const parentRect = measure(parentEndpoint);
|
|
||||||
if (!parentRect) continue;
|
|
||||||
const children = [];
|
|
||||||
for (const edge of family.edges) {
|
|
||||||
const childEndpoint = resolveEndpoint(edge.childId);
|
|
||||||
if (childEndpoint.key === parentEndpoint.key) continue;
|
|
||||||
const rect = measure(childEndpoint);
|
|
||||||
if (rect) children.push({ edge, endpoint: childEndpoint, rect });
|
|
||||||
}
|
|
||||||
if (children.length > 0) resolvedFamilies.push({ family, parentEndpoint, parentRect, children });
|
|
||||||
}
|
|
||||||
if (resolvedFamilies.length === 0) return;
|
|
||||||
// The header strip's rows come from EVERY tab in it (computeTree hangs a row's
|
|
||||||
// gap under its tallest tab), web tabs included. The rail needs none.
|
|
||||||
const tabRects = [];
|
|
||||||
if (orientation === 'horizontal') {
|
|
||||||
for (const tab of strip.querySelectorAll('.session-tab')) {
|
|
||||||
const id = tab.getAttribute('data-id');
|
|
||||||
const key = id ? 'tab:' + id : null;
|
|
||||||
if (key && rects.has(key)) tabRects.push(rects.get(key));
|
|
||||||
else {
|
|
||||||
const rect = tab.getBoundingClientRect();
|
|
||||||
if (key) rects.set(key, rect);
|
|
||||||
tabRects.push(rect);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
this._lineageEdgeCount = resolvedEdges.length;
|
||||||
|
|
||||||
// PHASE 2 — writes, from the cache only.
|
// PHASE 2 — writes, from the cache only.
|
||||||
// Lanes follow STRIP order, so a family keeps its lane when the selection moves;
|
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
|
||||||
// only the DRAW order changes (the emphasized families last, on top). A row gap
|
const parentRect = rects.get(parentEndpoint.key);
|
||||||
// fits a few lanes, so they cycle: families that share one are told apart by colour.
|
const childRect = rects.get(childEndpoint.key);
|
||||||
const laneLimit = Math.max(1, (window.CodemanLineage && window.CodemanLineage.MAX_LANES) || 3);
|
if (!parentRect || !childRect) continue;
|
||||||
const laneCount = Math.min(laneLimit, resolvedFamilies.length);
|
|
||||||
const drawOrder = resolvedFamilies
|
const geom = compute({
|
||||||
.map((resolved, index) => ({
|
|
||||||
...resolved,
|
|
||||||
lane: index % laneCount,
|
|
||||||
focus: focusParents.has(resolved.family.parentId),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.focus - b.focus);
|
|
||||||
for (const { family, parentEndpoint, parentRect, children, lane, focus } of drawOrder) {
|
|
||||||
const geom = computeTree({
|
|
||||||
parent: parentRect,
|
parent: parentRect,
|
||||||
children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })),
|
child: childRect,
|
||||||
strip: stripRect,
|
strip: stripRect,
|
||||||
tabs: tabRects,
|
depth: edge.depth,
|
||||||
orientation,
|
orientation,
|
||||||
lane,
|
|
||||||
laneCount,
|
|
||||||
});
|
});
|
||||||
if (!geom || geom.routes.length === 0) continue;
|
if (!geom) continue; // scrolled out of the strip, or a degenerate rect
|
||||||
const byId = new Map(children.map((c) => [c.edge.childId, c]));
|
|
||||||
const color = this._lineageColorFor(family.parentId);
|
|
||||||
// One group per family: it carries the translucency, so the stretches its
|
|
||||||
// routes share (the trunk) do not stack into a brighter line than the branches,
|
|
||||||
// and the emphasis for the selected tab's families (styles.css).
|
|
||||||
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
||||||
group.setAttribute('class', 'lineage-family' + (focus ? ' lineage-family--focus' : ''));
|
|
||||||
group.setAttribute('data-parent-tab', family.parentId);
|
|
||||||
// Working routes go in FIRST, so an idle sibling's solid stroke covers the
|
|
||||||
// shared trunk and only the working child's own branch shows its dashes.
|
|
||||||
const routes = geom.routes
|
|
||||||
.map((route) => ({ route, child: byId.get(route.id) }))
|
|
||||||
.filter((r) => r.child)
|
|
||||||
.sort((a, b) => (b.child.edge.status === 'working') - (a.child.edge.status === 'working'));
|
|
||||||
for (const { route, child } of routes) {
|
|
||||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
line.setAttribute('d', route.d);
|
line.setAttribute('d', geom.d);
|
||||||
// `status` is the CHILD's, which is the interesting end: a working child's
|
// The working class marches the dashes, so an active worker is visible along
|
||||||
// route is dashed (and marches, motion permitting).
|
// the line itself. `status` is the CHILD's, which is the interesting end.
|
||||||
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
|
const working = edge.status === 'working' ? ' lineage-line--working' : '';
|
||||||
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
|
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
|
||||||
line.setAttribute(
|
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
|
||||||
'class',
|
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
|
||||||
'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : '')
|
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
|
||||||
);
|
// Every arc out of one tab shares it — see _lineageColorFor().
|
||||||
// The PARENT's colour rides a CSS custom property so the stylesheet keeps
|
const color = this._lineageColorFor(edge.parentId);
|
||||||
// owning weight and dash; an empty colour leaves the --session-blue fallback.
|
|
||||||
if (color) line.style.setProperty('--lineage-color', color);
|
if (color) line.style.setProperty('--lineage-color', color);
|
||||||
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
||||||
line.setAttribute('data-agent-id', 'lineage:' + child.edge.childId);
|
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
|
||||||
line.setAttribute('data-parent-tab', family.parentId);
|
line.setAttribute('data-parent-tab', edge.parentId);
|
||||||
line.setAttribute('data-child-tab', child.edge.childId);
|
line.setAttribute('data-child-tab', edge.childId);
|
||||||
group.appendChild(line);
|
svg.appendChild(line);
|
||||||
}
|
|
||||||
// Direction marker at each CHILD end, after every path so no stroke covers it.
|
// Direction marker at the CHILD end. A circle rather than an SVG <marker>:
|
||||||
for (const { route, child } of routes) {
|
// markers need a <defs> block and fight the dash pattern.
|
||||||
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
|
|
||||||
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
|
|
||||||
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||||
dot.setAttribute('cx', String(route.endX));
|
dot.setAttribute('cx', String(geom.endX));
|
||||||
dot.setAttribute('cy', String(route.endY));
|
dot.setAttribute('cy', String(geom.endY));
|
||||||
// Fallback radius only: styles.css sizes the dot through `--lineage-dot-r`
|
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
|
||||||
// (larger in an emphasized family), and `lineage-dot-pulse` breathes from it.
|
// works, so the two have to be changed together.
|
||||||
dot.setAttribute('r', '2.5');
|
dot.setAttribute('r', '3.5');
|
||||||
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
|
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
|
||||||
dot.setAttribute('data-child-tab', child.edge.childId);
|
dot.setAttribute('data-child-tab', edge.childId);
|
||||||
if (color) dot.style.setProperty('--lineage-color', color);
|
if (color) dot.style.setProperty('--lineage-color', color);
|
||||||
group.appendChild(dot);
|
svg.appendChild(dot);
|
||||||
}
|
|
||||||
svg.appendChild(group);
|
|
||||||
this._lineageEdgeCount += routes.length;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The strip scrolls (desktop `overflow-x: auto` and every wrapped layout), and a
|
* The strip scrolls (desktop `overflow-x: auto` and every wrapped layout), and a
|
||||||
* scroll moves both endpoints without firing any render, so the lines would slide
|
* scroll moves both endpoints without firing any render, so the arcs would slide off
|
||||||
* off their tabs. Passive listener, and the redraw is the normal coalesced one.
|
* their tabs. Passive listener, and the redraw is the normal coalesced one.
|
||||||
*
|
*
|
||||||
* Installed once; the guard also keeps a re-init from stacking listeners.
|
* Installed once; the guard also keeps a re-init from stacking listeners.
|
||||||
*/
|
*/
|
||||||
@@ -365,24 +248,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._lineageScrollHandler = () => {
|
this._lineageScrollHandler = () => {
|
||||||
// Sidebar layout and the vertical rail scroll the SAME element
|
// Sidebar layout and the vertical rail scroll the SAME element
|
||||||
// vertically, and there the subagent/ultracode connectors anchor to tab
|
// vertically, and there the subagent/ultracode connectors anchor to tab
|
||||||
// rects too (the sidebar skips lineage entirely, and the rail can show
|
// rects too (the sidebar skips lineage arcs entirely, and the rail can
|
||||||
// connectors with zero lineage edges, so _lineageEdgeCount alone would
|
// show connectors with zero lineage edges, so _lineageEdgeCount alone
|
||||||
// never redraw them).
|
// would never redraw them).
|
||||||
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
|
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
|
||||||
};
|
};
|
||||||
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
|
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
|
||||||
|
|
||||||
// ⚠ A SELECTION RESIZES TABS AFTER THE REDRAW. The active tab reveals its gear
|
|
||||||
// and close icons by transitioning their padding (styles.css), so it keeps
|
|
||||||
// widening for ~150ms after `_updateActiveTabImmediate()` has already redrawn,
|
|
||||||
// and the tab it was selected from shrinks. That can move tabs or re-wrap a row,
|
|
||||||
// which left a family's trunk hanging under the tab's OLD position. Redraw once
|
|
||||||
// a size transition inside the strip ends (coalesced, like every other redraw).
|
|
||||||
this._lineageTransitionHandler = (event) => {
|
|
||||||
const prop = event.propertyName || '';
|
|
||||||
if (!(prop === 'width' || prop === 'max-width' || prop.startsWith('padding'))) return;
|
|
||||||
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
|
|
||||||
};
|
|
||||||
strip.addEventListener('transitionend', this._lineageTransitionHandler);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -573,6 +573,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (session?.id) this._onSessionCreated(session);
|
if (session?.id) this._onSessionCreated(session);
|
||||||
|
// A session this tab's Run created joins an open tile grid (tile-grid.js),
|
||||||
|
// so Run's selectSession() below focuses its tile instead of leaving the
|
||||||
|
// grid. Only here: sessions created elsewhere arrive by session:created.
|
||||||
|
this._joinTileGridFromRun?.(sessionId);
|
||||||
// session:created normally uses the debounced renderer. The direct POST path
|
// session:created normally uses the debounced renderer. The direct POST path
|
||||||
// needs the tab in the DOM before selectSession() marks it active.
|
// needs the tab in the DOM before selectSession() marks it active.
|
||||||
this._renderSessionTabsImmediate?.();
|
this._renderSessionTabsImmediate?.();
|
||||||
|
|||||||
@@ -430,6 +430,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||||
document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||||
|
document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;
|
||||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
||||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||||
// Phone overview home screen: only meaningful under 600px, so the row is
|
// Phone overview home screen: only meaningful under 600px, so the row is
|
||||||
@@ -2492,6 +2493,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||||
showSplitButton: document.getElementById('appSettingsShowSplitButton').checked,
|
showSplitButton: document.getElementById('appSettingsShowSplitButton').checked,
|
||||||
|
showTileGridButton: document.getElementById('appSettingsShowTileGridButton').checked,
|
||||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||||
@@ -2730,6 +2732,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT
|
// SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT
|
||||||
// (moving it into displayKeys alone is not the strip; this is).
|
// (moving it into displayKeys alone is not the strip; this is).
|
||||||
showSplitButton: _ssp,
|
showSplitButton: _ssp,
|
||||||
|
// Same as Split: a per-device header button (and the Tiles chord), absent
|
||||||
|
// from SettingsUpdateSchema (.strict()), so sending it 400s the whole PUT.
|
||||||
|
showTileGridButton: _stg,
|
||||||
webglRendererEnabled: _wgl,
|
webglRendererEnabled: _wgl,
|
||||||
terminalWheelLocalScrollback: _twls,
|
terminalWheelLocalScrollback: _twls,
|
||||||
// Copy-on-select. Per-device (clipboard access differs by device and by
|
// Copy-on-select. Per-device (clipboard access differs by device and by
|
||||||
@@ -3420,6 +3425,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
ultracodeFloatingWindows: false,
|
ultracodeFloatingWindows: false,
|
||||||
showMultiMonitorButton: false,
|
showMultiMonitorButton: false,
|
||||||
showSplitButton: false,
|
showSplitButton: false,
|
||||||
|
showTileGridButton: false,
|
||||||
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
|
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
|
||||||
// OFF so the phone header stays minimal and the mobile-header-buttons
|
// OFF so the phone header stays minimal and the mobile-header-buttons
|
||||||
// policy guard keeps passing.
|
// policy guard keeps passing.
|
||||||
@@ -3632,6 +3638,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||||
this._applySplitButtonVisibility?.(showSplitButton);
|
this._applySplitButtonVisibility?.(showSplitButton);
|
||||||
|
|
||||||
|
// Tiles button: same gate and backstop as Split (tile-grid.js).
|
||||||
|
const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;
|
||||||
|
this._applyTileGridButtonVisibility?.(showTileGridButton);
|
||||||
|
|
||||||
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
|
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
|
||||||
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
|
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
|
||||||
// from the mobile-header-buttons-policy guard.
|
// from the mobile-header-buttons-policy guard.
|
||||||
@@ -4053,6 +4063,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
'mobileOverviewEnabled',
|
'mobileOverviewEnabled',
|
||||||
'sessionLineageLines',
|
'sessionLineageLines',
|
||||||
'showSplitButton',
|
'showSplitButton',
|
||||||
|
'showTileGridButton',
|
||||||
]);
|
]);
|
||||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||||
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
|
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
|
||||||
|
|||||||
+932
-141
File diff suppressed because one or more lines are too long
+146
-649
@@ -1,640 +1,16 @@
|
|||||||
// src/web/public/terminal-split.js
|
// src/web/public/terminal-split.js
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
|
* @fileoverview Split-pane orchestration: opens a second live session
|
||||||
* ("Pane B") for split-view sessions. Deliberately plainer than the primary
|
* ("Pane B") beside the active one, in a TerminalTile (terminal-tile.js), with
|
||||||
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
|
* a draggable divider, a session picker, and auto-collapse when either
|
||||||
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
|
* session ends. Desktop-only; see docs/split-pane-sessions-plan.md.
|
||||||
* feature by nature — see docs/split-pane-sessions-plan.md.
|
|
||||||
*
|
*
|
||||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
* @dependency terminal-tile.js (window.TerminalTile)
|
||||||
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
* @dependency constants.js (window.CodemanSplitPane, SPLIT_PANE_MIN_WIDTH)
|
||||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
* @loadorder 7.5 of 16, loaded after terminal-tile.js and before tile-grid.js
|
||||||
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(function (global) {
|
|
||||||
// How long a scroll-to-top history pull may hold Pane B's live output.
|
|
||||||
const HISTORY_PULL_TIMEOUT_MS = 10000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal chunked write for Pane B's own xterm instance — write() in
|
|
||||||
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
|
||||||
* giant synchronous write that blocks the main thread while parsing a long
|
|
||||||
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
|
|
||||||
* (terminal-ui.js): that one is wired into session-switch generation
|
|
||||||
* counters and the live-output gate this simpler, independently
|
|
||||||
* created/destroyed pane has no equivalent of.
|
|
||||||
*/
|
|
||||||
function writeChunked(terminal, buffer, isDestroyed) {
|
|
||||||
if (!buffer) return Promise.resolve();
|
|
||||||
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
|
|
||||||
terminal.write(buffer);
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
// Resolves once the LAST chunk is written (or the pane was destroyed
|
|
||||||
// mid-replay), so _loadBuffer() below can hold its single-flight flag
|
|
||||||
// across the whole replay rather than just the fetch that precedes it.
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let offset = 0;
|
|
||||||
const writeNext = () => {
|
|
||||||
if (isDestroyed() || !terminal) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
|
|
||||||
offset += chunk.length;
|
|
||||||
terminal.write(chunk);
|
|
||||||
if (offset < buffer.length) {
|
|
||||||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
|
|
||||||
else setTimeout(writeNext, 16);
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
writeNext();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class SplitTerminalPane {
|
|
||||||
constructor(sessionId, mountEl, opts = {}) {
|
|
||||||
this.sessionId = sessionId;
|
|
||||||
this.mountEl = mountEl;
|
|
||||||
this.sessionMode = opts.mode;
|
|
||||||
this.fontSettings = opts.fontSettings || {};
|
|
||||||
// Live reference (not a snapshot) to the app's detachedSessions Set —
|
|
||||||
// detaching this session AFTER the split is already open must still be
|
|
||||||
// seen by _sendResize() below, or it re-creates the exact PTY-size
|
|
||||||
// fight the split picker already refuses to open at pick time.
|
|
||||||
this.detachedSessions = opts.detachedSessions;
|
|
||||||
this.terminal = null;
|
|
||||||
this.fitAddon = null;
|
|
||||||
this.ws = null;
|
|
||||||
this._wsReady = false;
|
|
||||||
this._wsClosed = false;
|
|
||||||
this._destroyed = false;
|
|
||||||
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
|
||||||
this._bufferLoading = false;
|
|
||||||
this._bufferRefreshPending = false;
|
|
||||||
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
|
|
||||||
// `_liveQueue` is non-null from the pull's response until its finally
|
|
||||||
// block: live frames are held there with their arrival time instead of
|
|
||||||
// written under the replay. `_markerOwed` is the "disconnected" marker a
|
|
||||||
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
|
|
||||||
this._historyPullAt = 0;
|
|
||||||
this._historyPullUseless = false;
|
|
||||||
this._liveQueue = null;
|
|
||||||
this._markerOwed = false;
|
|
||||||
this._onWheel = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async connect() {
|
|
||||||
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
|
|
||||||
this.terminal = new Terminal({
|
|
||||||
theme: { ...global.codemanCurrentXtermTheme() },
|
|
||||||
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
|
|
||||||
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
|
|
||||||
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
cursorBlink: false,
|
|
||||||
cursorStyle: 'block',
|
|
||||||
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
|
||||||
scrollback: DEFAULT_SCROLLBACK,
|
|
||||||
allowTransparency: true,
|
|
||||||
allowProposedApi: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.fitAddon = new FitAddon.FitAddon();
|
|
||||||
this.terminal.loadAddon(this.fitAddon);
|
|
||||||
this.terminal.open(this.mountEl);
|
|
||||||
this.fitAddon.fit();
|
|
||||||
|
|
||||||
this._installWheelListener();
|
|
||||||
|
|
||||||
this.terminal.onData((data) => {
|
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pane B has no gates of its own by default, so every app-level chord
|
|
||||||
// that the document capture-phase handler (app.js) only preventDefault()s
|
|
||||||
// — never stopPropagation()s — reaches xterm here too and writes its raw
|
|
||||||
// byte/escape sequence into THIS session's PTY on top of whatever the app
|
|
||||||
// action already did to Pane A (COD-153; mirrors the primary pane's own
|
|
||||||
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
|
|
||||||
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
|
|
||||||
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
|
|
||||||
// registry-aware predicates so a rebind or a disable restores plain
|
|
||||||
// terminal behavior here too. Ctrl+V is deliberately left on xterm's own
|
|
||||||
// default (plain-text paste): Pane B has no image-paste trap to route it
|
|
||||||
// to, so intercepting it here would only break paste.
|
|
||||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
|
||||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
|
||||||
if (
|
|
||||||
ev.altKey &&
|
|
||||||
!ev.ctrlKey &&
|
|
||||||
!ev.shiftKey &&
|
|
||||||
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
|
|
||||||
// swallow — in a plain shell session this is the user's own
|
|
||||||
// job-control tool and must reach the PTY, but in every other mode
|
|
||||||
// (claude/omp/pi/codex/...) it silently stops an unattended agent
|
|
||||||
// loop dead. Pane B has its own PTY/session and must not send a
|
|
||||||
// suspend into a non-shell one just because the primary pane's own
|
|
||||||
// gate lives elsewhere.
|
|
||||||
if (
|
|
||||||
ev.type === 'keydown' &&
|
|
||||||
ev.key.toLowerCase() === 'z' &&
|
|
||||||
ev.ctrlKey &&
|
|
||||||
!ev.altKey &&
|
|
||||||
!ev.metaKey &&
|
|
||||||
!ev.shiftKey &&
|
|
||||||
this.sessionMode !== 'shell'
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
|
|
||||||
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
|
|
||||||
// every Enter variant, so an Ink app (Claude Code) can't tell a
|
|
||||||
// newline from a submit. Without this gate, Pane B's onData would
|
|
||||||
// send that bare \r straight over the WS and submit an incomplete
|
|
||||||
// prompt instead of adding a line to it. Targets THIS pane's own
|
|
||||||
// session (this.sessionId), never the primary pane's
|
|
||||||
// activeSessionId, and has no local-echo overlay of its own to flush
|
|
||||||
// first (Pane B is deliberately plainer — see the fileoverview).
|
|
||||||
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
|
|
||||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
|
|
||||||
if (ev.type === 'keydown') {
|
|
||||||
fetch(`/api/sessions/${this.sessionId}/send-key`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
|
||||||
}).catch(() => {
|
|
||||||
/* Best-effort, matching this pane's tolerance elsewhere. */
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
|
|
||||||
// selection, Ctrl+C copies THIS pane's own selection instead of
|
|
||||||
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
|
|
||||||
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
|
|
||||||
// explicit, never-falls-through copy chord, and the predicate above
|
|
||||||
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
|
|
||||||
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
|
|
||||||
// a branch that assigns c.key only for a couple of special cases
|
|
||||||
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
|
|
||||||
// for Ctrl+Shift+C either way — this is not about an accidental
|
|
||||||
// interrupt byte reaching the PTY (verified live: it does not).
|
|
||||||
// Gating this whole block on hasSelection() (an earlier draft) meant
|
|
||||||
// that with no selection Ctrl+Shift+C skipped straight to `return
|
|
||||||
// true`, silently ceding the keystroke to the BROWSER's own handling
|
|
||||||
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
|
|
||||||
// attempt to copy, unlike Pane A, which always intercepts it.
|
|
||||||
// Re-implemented against this.terminal rather than reusing
|
|
||||||
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
|
|
||||||
// and would copy the wrong pane's selection.
|
|
||||||
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
|
||||||
const raw = this.terminal?.getSelection?.() || '';
|
|
||||||
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
|
|
||||||
// Both clean options are read for THIS pane, never the primary one:
|
|
||||||
// the gutter width comes from this.sessionId's own run mode, and the
|
|
||||||
// partial-first-line flag from this terminal's own selection range.
|
|
||||||
// Passing neither left Pane B keeping a margin Pane A dropped, on the
|
|
||||||
// same split and the same keystroke.
|
|
||||||
const range = global.app?._normalisedSelectionRange?.(this.terminal);
|
|
||||||
const selection = isColumnSelection
|
|
||||||
? raw
|
|
||||||
: (global.CodemanCopySelection?.clean?.(raw, {
|
|
||||||
margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
|
|
||||||
firstLinePartial: !!range && range.start.x > 0,
|
|
||||||
}) ?? raw);
|
|
||||||
if (selection.trim()) {
|
|
||||||
ev.preventDefault();
|
|
||||||
void global.app._copyText?.(selection).then((ok) => {
|
|
||||||
this.terminal?.clearSelection?.();
|
|
||||||
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Nothing worth copying — clear for feedback (a padding-only
|
|
||||||
// selection cleans to '' and this press still falls through to the
|
|
||||||
// PTY as 0x03, matching the primary pane's own rule).
|
|
||||||
if (this.terminal?.hasSelection?.()) {
|
|
||||||
this.terminal.clearSelection?.();
|
|
||||||
global.app.showToast?.('Nothing to copy', 'warning');
|
|
||||||
}
|
|
||||||
// Ctrl+Shift+C never falls through, even with nothing to copy —
|
|
||||||
// matches terminal-ui.js's own ev.shiftKey branch.
|
|
||||||
if (ev.shiftKey) {
|
|
||||||
ev.preventDefault();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load existing scrollback before going live. The WS below is
|
|
||||||
// subscribe-only (ws-routes.ts sends nothing on connect, only future
|
|
||||||
// 'terminal' events), so without this Pane B stays blank until the
|
|
||||||
// target session happens to produce new output. It LOOKED
|
|
||||||
// intermittent rather than always-broken because _sendResize() below
|
|
||||||
// often nudges the shared session's real tmux window to a new size,
|
|
||||||
// and tmux repaints its current screen on resize — that repaint was
|
|
||||||
// getting captured and streamed here, incidentally populating the
|
|
||||||
// pane. When Pane B's computed dimensions happened to already match
|
|
||||||
// the session's last-known size, Session.resize() (session.ts) skips
|
|
||||||
// the resize as a no-op, no repaint fires, and the pane stayed blank.
|
|
||||||
// The await covers the whole chunked replay, not just the fetch, so a
|
|
||||||
// live frame from the socket below can never land in the middle of it.
|
|
||||||
await this._loadBuffer();
|
|
||||||
if (this._destroyed) return;
|
|
||||||
|
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
|
|
||||||
this.ws = new WebSocket(url);
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
this._wsReady = true;
|
|
||||||
this._sendResize();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
if (msg.t === 'o') {
|
|
||||||
this._onLiveOutput(msg.d);
|
|
||||||
} else if (msg.t === 'c') {
|
|
||||||
this._onLiveClear();
|
|
||||||
} else if (msg.t === 'r') {
|
|
||||||
// Server-triggered refresh (SSE backpressure cleared, terminal
|
|
||||||
// data was dropped). The primary pane routes this to
|
|
||||||
// _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
|
|
||||||
// buffer loader for the same reason connect() does.
|
|
||||||
this._refreshBuffer();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* Malformed frame — ignore, matches primary pane's tolerance. */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
|
|
||||||
// must go false on a drop or fit()/_sendResize() silently no-ops on a
|
|
||||||
// closed socket per the WebSocket spec (no exception, no log). No
|
|
||||||
// reconnect logic here — Pane B is deliberately plainer than the
|
|
||||||
// primary pane (see the fileoverview above); a drop just stops
|
|
||||||
// resizing until the parent recreates the pane. But onData already
|
|
||||||
// silently drops keystrokes while _wsReady is false (below), so
|
|
||||||
// without a visible marker a dropped socket left Pane B looking
|
|
||||||
// normal while it quietly ate everything typed into it. v1 scope is
|
|
||||||
// "say so", not reconnect — collapsing the split would lose the
|
|
||||||
// user's place in Pane B's scrollback for a transient blip.
|
|
||||||
this.ws.onclose = () => this._onSocketClosed();
|
|
||||||
|
|
||||||
this.ws.onerror = () => {
|
|
||||||
// onclose fires after onerror — cleanup happens there.
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The socket's close, split out of connect() so the tests can drive it.
|
|
||||||
// While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
|
|
||||||
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
|
|
||||||
// written now, it would sit above the output a pull is still holding (flushed
|
|
||||||
// after it on a skip, a downgrade or a failed fetch), above a refresh's
|
|
||||||
// replay, or in the middle of a chunked replay. A pull still waiting for its
|
|
||||||
// response holds the marker too, for as long as the request takes (up to its
|
|
||||||
// budget, see _pullHistory()).
|
|
||||||
_onSocketClosed() {
|
|
||||||
this._wsReady = false;
|
|
||||||
this._wsClosed = true;
|
|
||||||
if (this._bufferLoading) this._markerOwed = true;
|
|
||||||
else this._writeDisconnectedMarker();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settles a marker the pane owes: set when a close lands during a load (the
|
|
||||||
// replay would otherwise sit below it) or when a load wipes the terminal on
|
|
||||||
// a closed socket. Called from each load's own finally, just before
|
|
||||||
// _endBufferLoad() starts any trailing refresh.
|
|
||||||
_stampMarkerIfOwed() {
|
|
||||||
// A trailing refresh is about to clear() synchronously, while xterm parses
|
|
||||||
// a write() on a later tick: a marker written here would land in the
|
|
||||||
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
|
|
||||||
// The refresh re-owes the marker on a closed socket and stamps it itself.
|
|
||||||
if (this._bufferRefreshPending && !this._destroyed) return;
|
|
||||||
const owed = this._markerOwed;
|
|
||||||
this._markerOwed = false;
|
|
||||||
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extracted so both _onSocketClosed() and a load that ends owing it on a
|
|
||||||
// closed socket can write it (see _stampMarkerIfOwed()).
|
|
||||||
_writeDisconnectedMarker() {
|
|
||||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetches and writes the session's current scrollback. Used both by
|
|
||||||
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
|
||||||
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
|
||||||
// scoped to `this.activeSessionId` and clears/rewrites the primary
|
|
||||||
// terminal, neither of which applies to this independent pane, so this is
|
|
||||||
// a standalone equivalent rather than a call into it.
|
|
||||||
//
|
|
||||||
// Mirrors the primary pane's own mode check (app.js's selectSession /
|
|
||||||
// _onSessionNeedsRefresh): a shell session can retain hundreds of
|
|
||||||
// thousands of plain scrollback lines, so pulling `?full=1` there parses
|
|
||||||
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
|
|
||||||
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
|
|
||||||
// get one full replay. `fetch` here goes through the global wrapper
|
|
||||||
// (constants.js), which already prefixes CodemanBase — unlike the raw
|
|
||||||
// WebSocket URL above, which does not.
|
|
||||||
//
|
|
||||||
// Single-flight: the flag is held across the fetch AND the chunked write
|
|
||||||
// (writeChunked resolves after its last chunk), so two replays can never
|
|
||||||
// interleave their chunks into one terminal. A second call while one is
|
|
||||||
// in flight is dropped here; _refreshBuffer() is the caller that queues
|
|
||||||
// a trailing re-run instead.
|
|
||||||
async _loadBuffer() {
|
|
||||||
if (this._bufferLoading) return;
|
|
||||||
this._bufferLoading = true;
|
|
||||||
try {
|
|
||||||
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
|
||||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
|
|
||||||
const payload = (await res.json())?.data ?? {};
|
|
||||||
if (payload.terminalBuffer && this.terminal) {
|
|
||||||
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* Best-effort — live output still arrives once the socket connects. */
|
|
||||||
} finally {
|
|
||||||
this._stampMarkerIfOwed();
|
|
||||||
this._endBufferLoad();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ends a single-flight load (initial, refresh or history pull): clears the
|
|
||||||
// flag, then runs the ONE trailing refresh that arrived while it was busy.
|
|
||||||
_endBufferLoad() {
|
|
||||||
this._bufferLoading = false;
|
|
||||||
if (this._bufferRefreshPending && !this._destroyed) {
|
|
||||||
this._bufferRefreshPending = false;
|
|
||||||
this._refreshBuffer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live terminal output. Written straight through, except while a history
|
|
||||||
// pull is replaying: a capture is current only up to the instant tmux took
|
|
||||||
// it, so a frame arriving mid-replay is held with its arrival time and
|
|
||||||
// replayed behind the snapshot by _pullHistory() (the primary pane's
|
|
||||||
// _finishBufferLoad `since` rule), never written underneath it.
|
|
||||||
_onLiveOutput(data) {
|
|
||||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
|
|
||||||
else this.terminal?.write(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The server's `{t:'c'}` clear frame takes the same route as output, for the
|
|
||||||
// same reason: clearing straight away, mid-replay, would wipe the half-written
|
|
||||||
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
|
|
||||||
// one it is restoring. Queued, it lands in order with the frames around it.
|
|
||||||
_onLiveClear() {
|
|
||||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
|
|
||||||
else this.terminal?.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
|
||||||
// event it consumes, so a bubbling listener here would never see the wheel
|
|
||||||
// while the pane still has scrollback to scroll. Passive: this only observes,
|
|
||||||
// xterm keeps doing the scrolling.
|
|
||||||
_installWheelListener() {
|
|
||||||
this._onWheel = (ev) => {
|
|
||||||
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
|
|
||||||
};
|
|
||||||
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
|
|
||||||
// output (`cat` of a file longer than the screen) instead of scrolling it,
|
|
||||||
// so this pane's xterm ends up with about one screen of scrollback while
|
|
||||||
// tmux holds every line — and nothing here ever went back to ask, so the
|
|
||||||
// history was unreachable. The primary pane has the same pull
|
|
||||||
// (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
|
|
||||||
// own. Shell only: a non-shell CLI's history is out of scope for this pull
|
|
||||||
// (its load already takes `full=1`; codex and Claude's inline renderer do
|
|
||||||
// grow tmux history, this just isn't how they recover it). The alternate-
|
|
||||||
// screen skip (nano, vim, less) only matters for a direct-PTY shell — under
|
|
||||||
// tmux the browser xterm never enters the alternate buffer.
|
|
||||||
_maybeLoadMoreHistory() {
|
|
||||||
if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
|
|
||||||
if (this._bufferLoading) return;
|
|
||||||
// Mirrors app.js _maybeRefetchFullHistory and this pane's own
|
|
||||||
// _sendResize(): a detached session's own window already owns its PTY
|
|
||||||
// size and scrollback, so Pane B has nothing of its own to reconcile.
|
|
||||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
|
||||||
const active = this.terminal.buffer.active;
|
|
||||||
if (active.type !== 'normal' || active.viewportY !== 0) return;
|
|
||||||
// Momentum scrolling fires this dozens of times per flick, so cooldown
|
|
||||||
// rather than latch; a pull that could only have downgraded the pane
|
|
||||||
// waits far longer.
|
|
||||||
const cooldown = this._historyPullUseless ? 60000 : 4000;
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this._historyPullAt < cooldown) return;
|
|
||||||
this._historyPullAt = now;
|
|
||||||
void this._pullHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
|
|
||||||
// a tab switch loads, so a multi-megabyte capture never lands on xterm's
|
|
||||||
// main thread) and replays it under the reader's current place. Holds the
|
|
||||||
// single-flight flag across the fetch AND the replay, like _loadBuffer().
|
|
||||||
async _pullHistory() {
|
|
||||||
this._bufferLoading = true;
|
|
||||||
let replayed = false;
|
|
||||||
let capturedAt = 0;
|
|
||||||
// Two budgets on one signal. The request itself gets the primary pane's
|
|
||||||
// (CodemanFetchDeadline, constants.js): live output is not held while it
|
|
||||||
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
|
|
||||||
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
|
|
||||||
// for that whole budget. Once the headers land live output IS held, so the
|
|
||||||
// body read gets the short one instead: a body that hangs would otherwise
|
|
||||||
// freeze the pane for the long budget. Aborting lands in the catch below,
|
|
||||||
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
|
|
||||||
// be re-armed, hence the controller; without AbortController the pull
|
|
||||||
// simply has no deadline.
|
|
||||||
const controller = global.AbortController ? new global.AbortController() : null;
|
|
||||||
let abortTimer = null;
|
|
||||||
const armDeadline = (ms) => {
|
|
||||||
if (!controller) return;
|
|
||||||
clearTimeout(abortTimer);
|
|
||||||
abortTimer = setTimeout(() => controller.abort(), ms);
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
|
|
||||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
|
||||||
signal: controller?.signal,
|
|
||||||
});
|
|
||||||
armDeadline(HISTORY_PULL_TIMEOUT_MS);
|
|
||||||
// The cutoff below is the response's arrival, the same `since` rule the
|
|
||||||
// primary pane uses (_finishBufferLoad). It is a client clock standing in
|
|
||||||
// for the instant tmux took the capture, which lies somewhere in the
|
|
||||||
// round trip, so a frame in that window can be lost or doubled. Bounded
|
|
||||||
// by one round trip and not closable without a server-side capture time.
|
|
||||||
capturedAt = performance.now();
|
|
||||||
// Opened only now: a frame from before the response is either replaced by
|
|
||||||
// the capture or written unchanged, so holding it for the round trip
|
|
||||||
// bought nothing and froze the pane for as long as the fetch took.
|
|
||||||
this._liveQueue = [];
|
|
||||||
const payload = (await res.json())?.data;
|
|
||||||
clearTimeout(abortTimer);
|
|
||||||
const buffer = payload?.terminalBuffer;
|
|
||||||
const term = this.terminal;
|
|
||||||
if (!buffer || !term || this._destroyed) return;
|
|
||||||
const rowsBefore = term.buffer.active.length;
|
|
||||||
const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
|
|
||||||
// xterm keeps at most `scrollback + rows` rows while tmux keeps far more
|
|
||||||
// lines, so a window of short lines can carry more rows than this pane
|
|
||||||
// can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
|
|
||||||
const scrollbackCap = term.options?.scrollback || 0;
|
|
||||||
const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
|
|
||||||
// Nothing to gain (this also covers a downgrade, which would delete
|
|
||||||
// history mid-scroll), and a reset+rewrite would jump the viewport. An
|
|
||||||
// untruncated window IS all of tmux's history and the next burst can add
|
|
||||||
// more, so keep the 4 s cooldown. A truncated window can never reach past
|
|
||||||
// what the pane shows, and every ask costs the server a capture-pane of
|
|
||||||
// the whole history (`tail` is cut after it): back off to 60 s, as the
|
|
||||||
// primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
|
|
||||||
// off too, since no window can ever fit in it.
|
|
||||||
if (rowsIncoming <= rowsBefore || paneFull) {
|
|
||||||
if (payload.truncated || paneFull) this._historyPullUseless = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._historyPullUseless = false;
|
|
||||||
term.write('\x1bc');
|
|
||||||
replayed = true;
|
|
||||||
if (this._wsClosed) this._markerOwed = true;
|
|
||||||
await writeChunked(term, buffer, () => this._destroyed);
|
|
||||||
if (this._destroyed || !this.terminal) return;
|
|
||||||
// xterm parses asynchronously: an empty write's callback fires only
|
|
||||||
// after everything before it, so the row count below is the settled one.
|
|
||||||
await new Promise((resolve) => this.terminal.write('', resolve));
|
|
||||||
if (this._destroyed || !this.terminal) return;
|
|
||||||
// The replay grew the buffer UPWARD, so what was row 0 is now `delta`
|
|
||||||
// rows down; land there and the recovered history sits above it.
|
|
||||||
const delta = this.terminal.buffer.active.length - rowsBefore;
|
|
||||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
|
||||||
else this.terminal.scrollToTop();
|
|
||||||
} catch {
|
|
||||||
/* Best-effort — live output keeps arriving whatever happens here. */
|
|
||||||
} finally {
|
|
||||||
clearTimeout(abortTimer);
|
|
||||||
const queued = this._liveQueue ?? [];
|
|
||||||
this._liveQueue = null;
|
|
||||||
// After a replay, only frames that arrived after the capture are news;
|
|
||||||
// earlier ones are already in it. With no replay, every held frame is.
|
|
||||||
const cutoff = replayed ? capturedAt : 0;
|
|
||||||
for (const entry of queued) {
|
|
||||||
if (entry.at < cutoff) continue;
|
|
||||||
if (entry.clear) this.terminal?.clear();
|
|
||||||
else this.terminal?.write(entry.data);
|
|
||||||
}
|
|
||||||
// Settled after the queue flush so the marker is the last thing on
|
|
||||||
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
|
|
||||||
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
|
|
||||||
// one written before it, which would paint a fresh, current-looking
|
|
||||||
// history while onData keeps silently dropping every keystroke on the
|
|
||||||
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
|
|
||||||
// is left to that refresh, which writes it below its own replay.
|
|
||||||
this._stampMarkerIfOwed();
|
|
||||||
this._endBufferLoad();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
|
||||||
// frames in a row used to start two concurrent replays, each clearing
|
|
||||||
// the terminal under the other's chunked write. A refresh that arrives
|
|
||||||
// mid-replay is COALESCED into one trailing re-run rather than ignored:
|
|
||||||
// the in-flight fetch may predate the drop the new frame is reporting,
|
|
||||||
// and no further frame is coming to correct stale content.
|
|
||||||
_refreshBuffer() {
|
|
||||||
if (this._bufferLoading) {
|
|
||||||
this._bufferRefreshPending = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.terminal?.clear();
|
|
||||||
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
|
|
||||||
// trailing refresh behind a pull that the socket's close then interrupts),
|
|
||||||
// so a refresh on a closed socket owes it back once its replay is written.
|
|
||||||
if (this._wsClosed) this._markerOwed = true;
|
|
||||||
void this._loadBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local reflow only — no PTY resize frame. Split out so a divider drag
|
|
||||||
// can reflow both panes at the browser's paint rate (rAF) while sending
|
|
||||||
// the actual `{t:'z'}` resize once, at drag end, matching the primary
|
|
||||||
// pane's own convention (throttledResize in terminal-ui.js).
|
|
||||||
localFit() {
|
|
||||||
if (!this.fitAddon) return;
|
|
||||||
this.fitAddon.fit();
|
|
||||||
}
|
|
||||||
|
|
||||||
fit() {
|
|
||||||
this.localFit();
|
|
||||||
this._sendResize();
|
|
||||||
}
|
|
||||||
|
|
||||||
_sendResize() {
|
|
||||||
if (!this._wsReady || !this.fitAddon) return;
|
|
||||||
// One PTY cannot hold two sizes (mirrors sendResize's own
|
|
||||||
// detachedElsewhere yield in terminal-ui.js): the session got detached
|
|
||||||
// to its own window AFTER this split was opened, so its own window now
|
|
||||||
// owns the PTY's size and Pane B must stand aside.
|
|
||||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
|
||||||
const dims = this.fitAddon.proposeDimensions();
|
|
||||||
if (!dims) return;
|
|
||||||
// Send the real proposed dimensions unclamped, matching the primary
|
|
||||||
// pane's convention (terminal-ui.js's getTerminalDimensions()) — the
|
|
||||||
// server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
|
|
||||||
// A 40/10 floor here misreported Pane B's real width to the PTY at the
|
|
||||||
// divider's own reachable 20% floor position, causing real
|
|
||||||
// output-wrapping bugs.
|
|
||||||
this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this._destroyed = true;
|
|
||||||
if (this._onWheel) {
|
|
||||||
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
|
|
||||||
this._onWheel = null;
|
|
||||||
}
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.onopen = null;
|
|
||||||
this.ws.onmessage = null;
|
|
||||||
// onclose fires asynchronously AFTER close(); without this it ran
|
|
||||||
// its "disconnected" write against a pane already torn down.
|
|
||||||
this.ws.onclose = null;
|
|
||||||
this.ws.onerror = null;
|
|
||||||
this.ws.close();
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
if (this.terminal) {
|
|
||||||
this.terminal.dispose();
|
|
||||||
this.terminal = null;
|
|
||||||
}
|
|
||||||
this.fitAddon = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
global.SplitTerminalPane = SplitTerminalPane;
|
|
||||||
})(window);
|
|
||||||
|
|
||||||
Object.assign(CodemanApp.prototype, {
|
Object.assign(CodemanApp.prototype, {
|
||||||
/**
|
/**
|
||||||
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
|
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
|
||||||
@@ -670,6 +46,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// the old exact-node check below) bubbled straight through to
|
// the old exact-node check below) bubbled straight through to
|
||||||
// `document` and self-closed the menu it just opened.
|
// `document` and self-closed the menu it just opened.
|
||||||
event?.stopPropagation();
|
event?.stopPropagation();
|
||||||
|
// The tile grid and the split are never open together (tile-grid.js); the
|
||||||
|
// Split button shows as unavailable meanwhile.
|
||||||
|
if (this._tilesOwnTerminal?.()) return;
|
||||||
if (this._splitPane) {
|
if (this._splitPane) {
|
||||||
this.closeSplitPane();
|
this.closeSplitPane();
|
||||||
return;
|
return;
|
||||||
@@ -763,6 +142,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// see _applySplitButtonVisibility's comment for why both a JS check and
|
// see _applySplitButtonVisibility's comment for why both a JS check and
|
||||||
// a CSS backstop exist.
|
// a CSS backstop exist.
|
||||||
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
|
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
|
||||||
|
// Never beside the tile grid: the main terminal is parked while it is open.
|
||||||
|
if (this._tilesOwnTerminal?.()) return;
|
||||||
// No active session means there is no `.terminal-wrap` to split against
|
// No active session means there is no `.terminal-wrap` to split against
|
||||||
// (the welcome overlay is showing) — without this, a split opened from
|
// (the welcome overlay is showing) — without this, a split opened from
|
||||||
// the home screen still created the container and connected Pane B, just
|
// the home screen still created the container and connected Pane B, just
|
||||||
@@ -777,7 +158,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// B's own session tab while split can otherwise land here with
|
// B's own session tab while split can otherwise land here with
|
||||||
// sessionId === activeSessionId: two live WebSockets to the same
|
// sessionId === activeSessionId: two live WebSockets to the same
|
||||||
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
|
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
|
||||||
// resize frame. Refuse before creating any DOM or SplitTerminalPane.
|
// resize frame. Refuse before creating any DOM or TerminalTile.
|
||||||
if (sessionId === this.activeSessionId) return;
|
if (sessionId === this.activeSessionId) return;
|
||||||
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
|
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
|
||||||
// re-applied here: the menu can sit open while a listed session's CLI
|
// re-applied here: the menu can sit open while a listed session's CLI
|
||||||
@@ -803,22 +184,38 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const paneB = document.createElement('div');
|
const paneB = document.createElement('div');
|
||||||
paneB.className = 'terminal-pane-b';
|
paneB.className = 'terminal-pane-b';
|
||||||
paneB.innerHTML = `
|
// Pane B's header: the harness logo, the name and the model, as on a grid
|
||||||
<div class="terminal-pane-b-header">
|
// tile, and the close button at a tile button's size.
|
||||||
<span class="session-name">${escapeHtml(session?.name || 'Session')}</span>
|
const headerB = this._buildSplitPaneHeader();
|
||||||
<button type="button" class="terminal-pane-b-close" onclick="app.closeSplitPane()" aria-label="Close split">×</button>
|
const close = document.createElement('button');
|
||||||
</div>
|
close.type = 'button';
|
||||||
<div class="terminal-pane-b-container"></div>
|
close.className = 'tile-btn tile-remove terminal-pane-b-close';
|
||||||
`;
|
close.title = 'Close split';
|
||||||
|
close.setAttribute('aria-label', 'Close split');
|
||||||
|
close.textContent = '\u00D7';
|
||||||
|
close.addEventListener('click', () => this.closeSplitPane());
|
||||||
|
headerB.el.appendChild(close);
|
||||||
|
const bodyB = document.createElement('div');
|
||||||
|
bodyB.className = 'terminal-pane-b-container';
|
||||||
|
paneB.append(headerB.el, bodyB);
|
||||||
|
// Pane A is the main terminal, which has no header of its own: while the
|
||||||
|
// split is open it gets the same strip, so each session in the split view
|
||||||
|
// names its harness and model. It takes height from the main terminal,
|
||||||
|
// which the opening resize below fits through syncTerminalGeometry (#464);
|
||||||
|
// closeSplitPane gives it back.
|
||||||
|
const headerA = this._buildSplitPaneHeader();
|
||||||
|
headerA.el.classList.add('terminal-pane-a-header');
|
||||||
|
this._splitHeaders = { a: headerA, b: headerB };
|
||||||
|
|
||||||
parent.insertBefore(container, wrap);
|
parent.insertBefore(container, wrap);
|
||||||
|
wrap.insertBefore(headerA.el, wrap.firstChild);
|
||||||
container.appendChild(wrap);
|
container.appendChild(wrap);
|
||||||
wrap.style.flexBasis = '50%';
|
wrap.style.flexBasis = '50%';
|
||||||
container.appendChild(divider);
|
container.appendChild(divider);
|
||||||
container.appendChild(paneB);
|
container.appendChild(paneB);
|
||||||
paneB.style.flexBasis = '50%';
|
paneB.style.flexBasis = '50%';
|
||||||
|
|
||||||
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
|
this._splitPane = new window.TerminalTile(sessionId, bodyB, {
|
||||||
mode: session?.mode,
|
mode: session?.mode,
|
||||||
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
||||||
detachedSessions: this.detachedSessions,
|
detachedSessions: this.detachedSessions,
|
||||||
@@ -828,6 +225,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
initial load — live output still arrives once/if the socket connects. */
|
initial load — live output still arrives once/if the socket connects. */
|
||||||
});
|
});
|
||||||
this._splitSessionId = sessionId;
|
this._splitSessionId = sessionId;
|
||||||
|
this._renderSplitChrome();
|
||||||
|
|
||||||
// Pane A just went from full width to 50%, but nothing has told its
|
// Pane A just went from full width to 50%, but nothing has told its
|
||||||
// session's PTY/tmux window about it yet — the passive ResizeObserver in
|
// session's PTY/tmux window about it yet — the passive ResizeObserver in
|
||||||
@@ -855,6 +253,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._splitPane.destroy();
|
this._splitPane.destroy();
|
||||||
this._splitPane = null;
|
this._splitPane = null;
|
||||||
this._splitSessionId = null;
|
this._splitSessionId = null;
|
||||||
|
// Before the refit below, so the main terminal gets its full height back.
|
||||||
|
this._splitHeaders?.a.el.remove();
|
||||||
|
this._splitHeaders = null;
|
||||||
this._updateSplitButtonState(false);
|
this._updateSplitButtonState(false);
|
||||||
|
|
||||||
const container = document.querySelector('.terminal-split-container');
|
const container = document.querySelector('.terminal-split-container');
|
||||||
@@ -865,14 +266,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
parent.insertBefore(wrap, container);
|
parent.insertBefore(wrap, container);
|
||||||
container.remove();
|
container.remove();
|
||||||
|
|
||||||
if (this.fitAddon) this.fitAddon.fit();
|
// Pane A takes the whole width back and, with its header strip gone, its
|
||||||
|
// whole height: through syncTerminalGeometry (#464), never a bare
|
||||||
|
// fitAddon.fit(). sendResize fits that way as its first step.
|
||||||
// The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split
|
// The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split
|
||||||
// while activeSessionId is still the id the server just removed, so a
|
// while activeSessionId is still the id the server just removed, so a
|
||||||
// resize from here would be aimed at a session that no longer exists;
|
// resize from here would be aimed at a session that no longer exists;
|
||||||
// the promoted session gets its own resize from selectSession().
|
// the promoted session gets its own resize from selectSession(), and the
|
||||||
if (!options.skipPrimaryResize) {
|
// terminal is only refitted here.
|
||||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
if (options.skipPrimaryResize) this.syncTerminalGeometry?.();
|
||||||
}
|
else this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
// A click on .btn-split does one of two things — open the picker, or
|
// A click on .btn-split does one of two things — open the picker, or
|
||||||
@@ -880,6 +283,92 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// nothing on the button said which. `.split-open` + aria-pressed give it
|
// nothing on the button said which. `.split-open` + aria-pressed give it
|
||||||
// the same active-state language as the codebase's other toggle buttons
|
// the same active-state language as the codebase's other toggle buttons
|
||||||
// (keyboard-accessory's Ctrl key, the voice-input mic).
|
// (keyboard-accessory's Ctrl key, the voice-input mic).
|
||||||
|
/**
|
||||||
|
* A split pane's header strip: the harness logo, the session name and the
|
||||||
|
* model, the three a grid tile's header shows. Built from nodes (the name
|
||||||
|
* and the model are untrusted text) and painted by _renderSplitChrome.
|
||||||
|
*/
|
||||||
|
_buildSplitPaneHeader() {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'terminal-pane-b-header';
|
||||||
|
const harness = document.createElement('span');
|
||||||
|
harness.className = 'split-harness run-mode-dot';
|
||||||
|
harness.setAttribute('role', 'img');
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.className = 'split-title';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
// `.session-name` is one of the translator's skipped surfaces (user text).
|
||||||
|
name.className = 'session-name';
|
||||||
|
// As on a tile: the name inside is never translated, the tooltip may be,
|
||||||
|
// and screen readers hear the model once, in the logo's accessible name.
|
||||||
|
const model = document.createElement('span');
|
||||||
|
model.className = 'split-model';
|
||||||
|
model.setAttribute('aria-hidden', 'true');
|
||||||
|
model.hidden = true;
|
||||||
|
const modelName = document.createElement('span');
|
||||||
|
modelName.setAttribute('data-i18n-skip', '');
|
||||||
|
model.appendChild(modelName);
|
||||||
|
title.append(name, model);
|
||||||
|
el.append(harness, title);
|
||||||
|
return { el, harness, name, model, modelName };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both split headers from their sessions: Pane A shows the active session,
|
||||||
|
* Pane B its own. Runs after every tab render, so a rename or a model
|
||||||
|
* change reaches them; unchanged values write nothing.
|
||||||
|
*/
|
||||||
|
_renderSplitChrome() {
|
||||||
|
const headers = this._splitHeaders;
|
||||||
|
if (!headers || !this._splitPane) return;
|
||||||
|
for (const [parts, id] of [
|
||||||
|
[headers.a, this.activeSessionId],
|
||||||
|
[headers.b, this._splitSessionId],
|
||||||
|
]) {
|
||||||
|
const session = id ? this.sessions.get(id) : null;
|
||||||
|
if (!session) continue;
|
||||||
|
const name = this.getSessionName?.(session) || session.name || 'Session';
|
||||||
|
if (parts.nameValue !== name) {
|
||||||
|
parts.nameValue = name;
|
||||||
|
parts.name.textContent = name;
|
||||||
|
}
|
||||||
|
this._paintSessionHarness(parts, session, 'split-harness');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints a session header's harness logo and model: a grid tile's, and the
|
||||||
|
* split panes'. The logo is PR #532's `run-mode-dot <cliId>` slot (the id is
|
||||||
|
* data, never a branch), the model is text (describeSessionHarness,
|
||||||
|
* constants.js). Diffs against the values it last wrote, kept on `parts`,
|
||||||
|
* never against the DOM, which the translator may have rewritten: an
|
||||||
|
* unchanged session writes nothing, and this runs on every tab render.
|
||||||
|
*
|
||||||
|
* @param {{harness: HTMLElement, model: HTMLElement, modelName: HTMLElement}} parts - the
|
||||||
|
* header's nodes (the model's box and the name inside it); the memo lives here too
|
||||||
|
* @param {object} session - the session the header shows
|
||||||
|
* @param {string} logoClass - the header's own class for its logo
|
||||||
|
*/
|
||||||
|
_paintSessionHarness(parts, session, logoClass) {
|
||||||
|
const harness = window.CodemanSessionHarness.describeSessionHarness(session, window.__codemanCliCatalog);
|
||||||
|
const cls = `${logoClass} run-mode-dot${harness.id ? ` ${harness.id}` : ''}`;
|
||||||
|
if (parts.harnessClass !== cls) {
|
||||||
|
parts.harnessClass = cls;
|
||||||
|
parts.harness.className = cls;
|
||||||
|
}
|
||||||
|
if (parts.harnessTitle !== harness.title) {
|
||||||
|
parts.harnessTitle = harness.title;
|
||||||
|
parts.harness.title = harness.title;
|
||||||
|
parts.harness.setAttribute('aria-label', harness.title);
|
||||||
|
parts.model.title = harness.title;
|
||||||
|
}
|
||||||
|
if (parts.modelValue !== harness.model) {
|
||||||
|
parts.modelValue = harness.model;
|
||||||
|
parts.modelName.textContent = harness.model;
|
||||||
|
parts.model.hidden = !harness.model;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
_updateSplitButtonState(open) {
|
_updateSplitButtonState(open) {
|
||||||
const btn = document.querySelector('.btn-split');
|
const btn = document.querySelector('.btn-split');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
@@ -900,10 +389,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// frame). Coalesced to one call per animation frame below — a raw
|
// frame). Coalesced to one call per animation frame below — a raw
|
||||||
// mousemove stream fires far faster than the browser repaints, and
|
// mousemove stream fires far faster than the browser repaints, and
|
||||||
// without the rAF gate each event did a full xterm reflow on BOTH
|
// without the rAF gate each event did a full xterm reflow on BOTH
|
||||||
// panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has
|
// panes AND sent Pane B a `{t:'z'}` resize frame, which fanned out
|
||||||
// no client-side "dims unchanged" skip), which fanned out into a
|
// into a `tmux resize-window` child plus a SIGWINCH per frame, roughly
|
||||||
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
|
// fifty of each dragging across half a wide viewport.
|
||||||
// of each dragging across half a wide viewport.
|
|
||||||
const applyDragPercent = (clientX) => {
|
const applyDragPercent = (clientX) => {
|
||||||
const container = divider.parentElement;
|
const container = divider.parentElement;
|
||||||
// The split can auto-collapse mid-drag (the other pane's session
|
// The split can auto-collapse mid-drag (the other pane's session
|
||||||
@@ -1039,6 +527,15 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
|
|||||||
// this, Pane A rebinds to a session that Pane B's independent WebSocket is
|
// this, Pane A rebinds to a session that Pane B's independent WebSocket is
|
||||||
// still attached to — two live WebSockets to one session, each claiming PTY
|
// still attached to — two live WebSockets to one session, each claiming PTY
|
||||||
// dimensions via its own `{t:'z',...}` resize frame.
|
// dimensions via its own `{t:'z',...}` resize frame.
|
||||||
|
// Every tab render (any session change: a rename, a model switch) refreshes
|
||||||
|
// the split headers too, the way tile-grid.js refreshes the tile headers.
|
||||||
|
const _splitOriginalRenderSessionTabsImmediate = CodemanApp.prototype._renderSessionTabsImmediate;
|
||||||
|
CodemanApp.prototype._renderSessionTabsImmediate = function (...args) {
|
||||||
|
const result = _splitOriginalRenderSessionTabsImmediate.apply(this, args);
|
||||||
|
this._renderSplitChrome?.();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const _originalSelectSession = CodemanApp.prototype.selectSession;
|
const _originalSelectSession = CodemanApp.prototype.selectSession;
|
||||||
CodemanApp.prototype.selectSession = function (sessionId, ...args) {
|
CodemanApp.prototype.selectSession = function (sessionId, ...args) {
|
||||||
if (this._splitPane && this._splitSessionId === sessionId) {
|
if (this._splitPane && this._splitSessionId === sessionId) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+157
-44
@@ -544,6 +544,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._installMobileTapMouseGuard();
|
this._installMobileTapMouseGuard();
|
||||||
this._installShiftDragSelection();
|
this._installShiftDragSelection();
|
||||||
this._installTouchSelectionFocusGuard();
|
this._installTouchSelectionFocusGuard();
|
||||||
|
// Focus coming back to the primary terminal ends a second pane's claim on
|
||||||
|
// the keyboard (see _focusedPane).
|
||||||
|
this.terminal.textarea?.addEventListener('focus', () => this._noteFocusedTile(null));
|
||||||
|
|
||||||
// Let xterm's CompositionHelper own IME key events. In particular, a
|
// Let xterm's CompositionHelper own IME key events. In particular, a
|
||||||
// non-composing keyCode 229 is how an active IME commits numbers and
|
// non-composing keyCode 229 is how an active IME commits numbers and
|
||||||
@@ -587,6 +590,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tile grid chords (Ctrl+Shift+G, Alt+Shift+Arrows): the capture handler
|
||||||
|
// has already acted on one that applies, and its preventDefault() does not
|
||||||
|
// stop xterm. Every event type, and BEFORE the Shift+Enter branch below.
|
||||||
|
if (this.tileShortcutFor?.(ev)) return false;
|
||||||
|
|
||||||
// Smart copy (#211): with a selection, Ctrl+C copies it instead of sending
|
// Smart copy (#211): with a selection, Ctrl+C copies it instead of sending
|
||||||
// ^C. With NO selection the branch must fall through (return true, and no
|
// ^C. With NO selection the branch must fall through (return true, and no
|
||||||
// preventDefault) or the interrupt key is lost, which is the whole reason
|
// preventDefault) or the interrupt key is lost, which is the whole reason
|
||||||
@@ -1325,6 +1333,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Same yield as sendResize: never resize a PTY whose session is showing
|
// Same yield as sendResize: never resize a PTY whose session is showing
|
||||||
// in its own window. Dragging the dashboard's border must not reshape it.
|
// in its own window. Dragging the dashboard's border must not reshape it.
|
||||||
const detachedElsewhere = !this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId);
|
const detachedElsewhere = !this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId);
|
||||||
|
// The tile grid parks the main terminal: its session is sized by its
|
||||||
|
// tile, which this same timer refits below (_forEachTile).
|
||||||
|
const tilesOwnTerminal = this._tilesOwnTerminal?.();
|
||||||
// ⚠️ Whether to fit is the SAME question as whether to send (issue #464).
|
// ⚠️ Whether to fit is the SAME question as whether to send (issue #464).
|
||||||
// This block used to fit unconditionally and skip only the SIGWINCH,
|
// This block used to fit unconditionally and skip only the SIGWINCH,
|
||||||
// which is the one combination that cannot be right: it moves xterm to
|
// which is the one combination that cannot be right: it moves xterm to
|
||||||
@@ -1332,7 +1343,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// repaints from the shape it was told. Withhold both, or neither —
|
// repaints from the shape it was told. Withhold both, or neither —
|
||||||
// a reflow nothing is rendering for buys nothing and costs correctness.
|
// a reflow nothing is rendering for buys nothing and costs correctness.
|
||||||
const dims =
|
const dims =
|
||||||
this.activeSessionId && !keyboardUp && !detachedElsewhere ? this._geometryForResizeRequest() : null;
|
this.activeSessionId && !keyboardUp && !detachedElsewhere && !tilesOwnTerminal
|
||||||
|
? this._geometryForResizeRequest()
|
||||||
|
: null;
|
||||||
// ⚠️ A null measurement is NOT a reason to report the floor. It used to
|
// ⚠️ A null measurement is NOT a reason to report the floor. It used to
|
||||||
// fall back to a bare 40x10, which tells the PTY a shape nothing measured
|
// fall back to a bare 40x10, which tells the PTY a shape nothing measured
|
||||||
// and xterm does not hold — the write-only guess this whole change exists
|
// and xterm does not hold — the write-only guess this whole change exists
|
||||||
@@ -1415,8 +1428,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// frame — this observer only ever measured Pane A's container, so
|
// frame — this observer only ever measured Pane A's container, so
|
||||||
// without this call Pane B never learned about a window resize, an
|
// without this call Pane B never learned about a window resize, an
|
||||||
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
|
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
|
||||||
// stayed at whatever size it was last dragged to.
|
// stayed at whatever size it was last dragged to. Grid tiles are left
|
||||||
this._splitPane?.fit();
|
// out: the grid's own observer (tile-grid.js _scheduleTileGridRefit)
|
||||||
|
// refits every one of them on the same resize, and a second fit here
|
||||||
|
// only re-measured six panes to send nothing.
|
||||||
|
this._forEachTile?.((tile) => tile.fit(), { grid: false });
|
||||||
}, 300); // Trailing-edge: only fire after 300ms of no resize events
|
}, 300); // Trailing-edge: only fire after 300ms of no resize events
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1827,9 +1843,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* Register a custom link provider for xterm.js that detects file paths
|
* Register a custom link provider for xterm.js that detects file paths
|
||||||
* in terminal output and makes them clickable.
|
* in terminal output and makes them clickable.
|
||||||
* When clicked, opens a floating log viewer window with live streaming.
|
* When clicked, opens a floating log viewer window with live streaming.
|
||||||
|
*
|
||||||
|
* `target` defaults to the primary terminal and the active session. A second
|
||||||
|
* terminal (the split pane) passes its own `{ terminal, getSessionId,
|
||||||
|
* setHovered }`, so a path printed there opens against THAT pane's session and
|
||||||
|
* hovering it never flips the primary pane's `_linkHovered`. Only the primary
|
||||||
|
* registration is kept on `_terminalLinkProvider`, which the touch path reads.
|
||||||
|
* Returns the provider.
|
||||||
*/
|
*/
|
||||||
registerFilePathLinkProvider() {
|
registerFilePathLinkProvider(target = {}) {
|
||||||
const self = this;
|
const self = this;
|
||||||
|
const terminal = target.terminal || this.terminal;
|
||||||
|
const getSessionId = target.getSessionId || (() => this.activeSessionId);
|
||||||
|
const setHovered =
|
||||||
|
target.setHovered ||
|
||||||
|
((hovered) => {
|
||||||
|
this._linkHovered = hovered;
|
||||||
|
});
|
||||||
|
const isPrimary = terminal === this.terminal;
|
||||||
|
|
||||||
// Debug: Track if provider is being invoked
|
// Debug: Track if provider is being invoked
|
||||||
let lastInvokedLine = -1;
|
let lastInvokedLine = -1;
|
||||||
@@ -1842,7 +1873,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
console.debug('[LinkProvider] Checking line:', bufferLineNumber);
|
console.debug('[LinkProvider] Checking line:', bufferLineNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = self.terminal.buffer.active;
|
const buffer = terminal.buffer.active;
|
||||||
// provideLinks passes 1-based line number, getLine expects 0-based
|
// provideLinks passes 1-based line number, getLine expects 0-based
|
||||||
const line = buffer.getLine(bufferLineNumber - 1);
|
const line = buffer.getLine(bufferLineNumber - 1);
|
||||||
|
|
||||||
@@ -1866,7 +1897,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
|
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
|
||||||
buffer,
|
buffer,
|
||||||
bufferLineNumber - 1,
|
bufferLineNumber - 1,
|
||||||
self.terminal.cols,
|
terminal.cols,
|
||||||
MAX_STITCHED_ROWS
|
MAX_STITCHED_ROWS
|
||||||
);
|
);
|
||||||
if (!logical) {
|
if (!logical) {
|
||||||
@@ -1919,10 +1950,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
window.open(text, '_blank', 'noopener,noreferrer');
|
window.open(text, '_blank', 'noopener,noreferrer');
|
||||||
},
|
},
|
||||||
hover() {
|
hover() {
|
||||||
self._linkHovered = true;
|
setHovered(true);
|
||||||
},
|
},
|
||||||
leave() {
|
leave() {
|
||||||
self._linkHovered = false;
|
setHovered(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1978,17 +2009,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// path clicked in the response viewer previewed fine. The preview
|
// path clicked in the response viewer previewed fine. The preview
|
||||||
// reads those through the guarded attachment routes, so external
|
// reads those through the guarded attachment routes, so external
|
||||||
// paths route there and the two surfaces agree.
|
// paths route there and the two surfaces agree.
|
||||||
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) {
|
const sessionId = getSessionId();
|
||||||
self.openFilePreview(text, self.activeSessionId);
|
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) {
|
||||||
|
self.openFilePreview(text, sessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.openLogViewerWindow(text, self.activeSessionId);
|
self.openLogViewerWindow(text, sessionId);
|
||||||
},
|
},
|
||||||
hover() {
|
hover() {
|
||||||
self._linkHovered = true;
|
setHovered(true);
|
||||||
},
|
},
|
||||||
leave() {
|
leave() {
|
||||||
self._linkHovered = false;
|
setHovered(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -2031,10 +2063,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// produce), so the tap path asks this SAME provider what is under the finger
|
// produce), so the tap path asks this SAME provider what is under the finger
|
||||||
// rather than growing a second, driftable copy of the patterns.
|
// rather than growing a second, driftable copy of the patterns.
|
||||||
// See _terminalLinkAtPoint.
|
// See _terminalLinkAtPoint.
|
||||||
this._terminalLinkProvider = provider;
|
if (isPrimary) this._terminalLinkProvider = provider;
|
||||||
this.terminal.registerLinkProvider(provider);
|
terminal.registerLinkProvider(provider);
|
||||||
|
|
||||||
console.log('[LinkProvider] File path link provider registered');
|
console.log('[LinkProvider] File path link provider registered');
|
||||||
|
return provider;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4480,8 +4513,56 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Terminal Controls
|
// Terminal Controls
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The terminal the keyboard is in, as `{ terminal, sessionId, isPrimary, tile }`.
|
||||||
|
*
|
||||||
|
* The ONE place a shortcut, voice or paste should ask "which pane?", rather
|
||||||
|
* than reading `this.terminal` / `this.activeSessionId`, which always mean the
|
||||||
|
* primary pane. It answers with the pane whose terminal was focused LAST, not
|
||||||
|
* with `document.activeElement`: clicking the mic or a header button moves
|
||||||
|
* DOM focus to that button, and the dictation it starts still belongs to the
|
||||||
|
* pane the user was typing in. A second pane (the split pane's Pane B) claims
|
||||||
|
* it from its own terminal's focus; the primary terminal's focus gives it back.
|
||||||
|
*/
|
||||||
|
_focusedPane() {
|
||||||
|
let tile = this._focusedTile;
|
||||||
|
// With the tile grid open the main terminal is parked, so the pane is the
|
||||||
|
// focused tile even when DOM focus sits on a button or a panel.
|
||||||
|
if ((!tile || tile._destroyed || !tile.terminal) && this._tilesOwnTerminal?.()) {
|
||||||
|
tile = this._tileFor(this.activeSessionId);
|
||||||
|
}
|
||||||
|
if (tile && !tile._destroyed && tile.terminal) {
|
||||||
|
return { terminal: tile.terminal, sessionId: tile.sessionId, isPrimary: false, tile };
|
||||||
|
}
|
||||||
|
return { terminal: this.terminal, sessionId: this.activeSessionId, isPrimary: true, tile: null };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Record which second pane holds the keyboard (null: the primary terminal). */
|
||||||
|
_noteFocusedTile(tile) {
|
||||||
|
this._focusedTile = tile || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn(tile)` for every secondary terminal pane on screen: the split
|
||||||
|
* pane's second terminal and every tile of the tile grid (never both: the two
|
||||||
|
* modes are not open together). Font, weight, family and skin changes go
|
||||||
|
* through here so they reach every pane without a special case per pane kind.
|
||||||
|
* `{ grid: false }` skips grid tiles (they keep their own font size).
|
||||||
|
* Agent Teams terminals size themselves and are not tiles.
|
||||||
|
*/
|
||||||
|
_forEachTile(fn, { grid = true } = {}) {
|
||||||
|
if (this._splitPane?.terminal) fn(this._splitPane);
|
||||||
|
if (!grid || !this._tileGrid?.open) return;
|
||||||
|
for (const { tile } of this._tileGrid.tiles.values()) {
|
||||||
|
if (tile.terminal) fn(tile);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clears the pane the keyboard is in. The chord itself also reaches that
|
||||||
|
// pane's xterm (the capture handler only preventDefault()s), so the ^L lands
|
||||||
|
// in the same pane whose display is cleared, never a different one.
|
||||||
clearTerminal() {
|
clearTerminal() {
|
||||||
this.terminal.clear();
|
this._focusedPane().terminal?.clear();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Insert editable text at the active prompt without pressing Enter. */
|
/** Insert editable text at the active prompt without pressing Enter. */
|
||||||
@@ -4545,10 +4626,21 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
|
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
|
||||||
*/
|
*/
|
||||||
async restoreTerminalSize() {
|
async restoreTerminalSize() {
|
||||||
|
// A second pane owns its own geometry: refit it and force its PTY to the
|
||||||
|
// size it renders at (TerminalTile.fit), whatever another device set.
|
||||||
|
const pane = this._focusedPane();
|
||||||
|
if (!pane.isPrimary) {
|
||||||
|
pane.tile.fit({ force: true });
|
||||||
|
this.showToast(`Terminal restored to ${pane.terminal.cols}x${pane.terminal.rows}`, 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!this.activeSessionId) {
|
if (!this.activeSessionId) {
|
||||||
this.showToast('No active session', 'warning');
|
this.showToast('No active session', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Backstop: _focusedPane() answers with the focused tile while the grid is
|
||||||
|
// open, so this is reached only if that tile is gone mid-call.
|
||||||
|
if (this._tilesOwnTerminal?.()) return;
|
||||||
|
|
||||||
// The pane belongs to the popup showing it, so this window has nothing to
|
// The pane belongs to the popup showing it, so this window has nothing to
|
||||||
// restore. Say so rather than reporting a size that was never sent — the
|
// restore. Say so rather than reporting a size that was never sent — the
|
||||||
@@ -4639,15 +4731,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* terminal._core for cell dimensions, and falls back to cleaning normally if
|
* terminal._core for cell dimensions, and falls back to cleaning normally if
|
||||||
* a future xterm renames it. SelectionMode.COLUMN is 3.
|
* a future xterm renames it. SelectionMode.COLUMN is 3.
|
||||||
*/
|
*/
|
||||||
cleanedTerminalSelection(text) {
|
cleanedTerminalSelection(text, target = {}) {
|
||||||
const raw = text ?? (this.terminal?.hasSelection?.() ? this.terminal.getSelection() : '');
|
// `target` names a second terminal (the split pane) and its session; both
|
||||||
|
// default to the primary pane, whose `this.terminal` this file otherwise reads.
|
||||||
|
const terminal = target.terminal || this.terminal;
|
||||||
|
const raw = text ?? (terminal?.hasSelection?.() ? terminal.getSelection() : '');
|
||||||
if (!raw) return '';
|
if (!raw) return '';
|
||||||
if (this.terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
if (terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
||||||
const clean = window.CodemanCopySelection?.clean;
|
const clean = window.CodemanCopySelection?.clean;
|
||||||
if (!clean) return raw;
|
if (!clean) return raw;
|
||||||
const range = this._normalisedSelectionRange();
|
const range = this._normalisedSelectionRange(terminal);
|
||||||
return clean(raw, {
|
return clean(raw, {
|
||||||
margin: this._cliGutterColumns(),
|
margin: this._cliGutterColumns(target.sessionId),
|
||||||
firstLinePartial: !!range && range.start.x > 0,
|
firstLinePartial: !!range && range.start.x > 0,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -4717,8 +4812,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
|
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
|
||||||
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
|
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
|
||||||
// option serves plain HTTP, where navigator.clipboard is undefined.
|
// option serves plain HTTP, where navigator.clipboard is undefined.
|
||||||
async copyTerminalSelection(text) {
|
async copyTerminalSelection(text, target = {}) {
|
||||||
const selection = this.cleanedTerminalSelection(text);
|
// Every terminal touched below is the TARGET one: clearing or refocusing the
|
||||||
|
// primary after copying from the split pane would hit the wrong pane.
|
||||||
|
const terminal = target.terminal || this.terminal;
|
||||||
|
const selection = this.cleanedTerminalSelection(text, target);
|
||||||
// trim(), not emptiness: a multi-row drag across padding cleans to newlines
|
// trim(), not emptiness: a multi-row drag across padding cleans to newlines
|
||||||
// alone, which are truthy, and a bare newline pasted into a chat composer
|
// alone, which are truthy, and a bare newline pasted into a chat composer
|
||||||
// or a shell submits the line. decideAutoCopy applies the same rule.
|
// or a shell submits the line. decideAutoCopy applies the same rule.
|
||||||
@@ -4727,7 +4825,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// selection, so a padding-only selection left set can no longer swallow a
|
// selection, so a padding-only selection left set can no longer swallow a
|
||||||
// later interrupt; it cleans to '' and the press reaches the PTY. What the
|
// later interrupt; it cleans to '' and the press reaches the PTY. What the
|
||||||
// clear avoids is a highlight that sits there having copied nothing.
|
// clear avoids is a highlight that sits there having copied nothing.
|
||||||
this.terminal?.clearSelection?.();
|
terminal?.clearSelection?.();
|
||||||
this.showToast('Nothing to copy', 'warning');
|
this.showToast('Nothing to copy', 'warning');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -4735,14 +4833,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already
|
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already
|
||||||
// drops the selection on any keypress, so this matches existing feel).
|
// drops the selection on any keypress, so this matches existing feel).
|
||||||
this.terminal.clearSelection?.();
|
terminal.clearSelection?.();
|
||||||
this.showToast('Copied to clipboard', 'success');
|
this.showToast('Copied to clipboard', 'success');
|
||||||
} else {
|
} else {
|
||||||
this.showToast('Failed to copy', 'error');
|
this.showToast('Failed to copy', 'error');
|
||||||
}
|
}
|
||||||
// The execCommand fallback focuses a temp textarea, so hand focus back. This
|
// The execCommand fallback focuses a temp textarea, so hand focus back. This
|
||||||
// is the CJK-aware focus router, not xterm's raw focus().
|
// is the CJK-aware focus router, not xterm's raw focus().
|
||||||
this.terminal.focus();
|
terminal.focus();
|
||||||
return ok;
|
return ok;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -5671,11 +5769,20 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
increaseFontSize() {
|
increaseFontSize() {
|
||||||
|
// With the tile grid open, Ctrl +/- sizes the tiles (their own font size).
|
||||||
|
if (this._tilesOwnTerminal?.()) {
|
||||||
|
this.setTileFontSize(Math.min(this._tileGridFontSize() + 2, 24));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const current = this.terminal.options.fontSize || 14;
|
const current = this.terminal.options.fontSize || 14;
|
||||||
this.setFontSize(Math.min(current + 2, 24));
|
this.setFontSize(Math.min(current + 2, 24));
|
||||||
},
|
},
|
||||||
|
|
||||||
decreaseFontSize() {
|
decreaseFontSize() {
|
||||||
|
if (this._tilesOwnTerminal?.()) {
|
||||||
|
this.setTileFontSize(Math.max(this._tileGridFontSize() - 2, 10));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const current = this.terminal.options.fontSize || 14;
|
const current = this.terminal.options.fontSize || 14;
|
||||||
this.setFontSize(Math.max(current - 2, 10));
|
this.setFontSize(Math.max(current - 2, 10));
|
||||||
},
|
},
|
||||||
@@ -5688,10 +5795,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Update overlay font cache and re-render at new cell dimensions
|
// Update overlay font cache and re-render at new cell dimensions
|
||||||
this._localEchoOverlay?.refreshFont();
|
this._localEchoOverlay?.refreshFont();
|
||||||
this._predictiveEcho?.refreshFont();
|
this._predictiveEcho?.refreshFont();
|
||||||
if (this._splitPane?.terminal) {
|
this._forEachTile?.(
|
||||||
this._splitPane.terminal.options.fontSize = size;
|
(tile) => {
|
||||||
this._splitPane.fitAddon?.fit();
|
tile.terminal.options.fontSize = size;
|
||||||
}
|
tile.fit(); // a font change is a size change: tell its PTY too (#464)
|
||||||
|
},
|
||||||
|
{ grid: false }
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -5717,10 +5827,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._refitAfterCellSizeChange();
|
this._refitAfterCellSizeChange();
|
||||||
this._localEchoOverlay?.refreshFont();
|
this._localEchoOverlay?.refreshFont();
|
||||||
this._predictiveEcho?.refreshFont();
|
this._predictiveEcho?.refreshFont();
|
||||||
if (this._splitPane?.terminal) {
|
this._forEachTile?.((tile) => {
|
||||||
this._splitPane.terminal.options.fontFamily = resolved;
|
tile.terminal.options.fontFamily = resolved;
|
||||||
this._splitPane.fitAddon?.fit();
|
tile.fit(); // a font change is a size change: tell its PTY too (#464)
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -5772,11 +5882,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
/* pane not laid out yet — its own resize observer refits it */
|
/* pane not laid out yet — its own resize observer refits it */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this._splitPane?.terminal) {
|
this._forEachTile?.((tile) => {
|
||||||
this._splitPane.terminal.options.fontWeight = fontWeight;
|
tile.terminal.options.fontWeight = fontWeight;
|
||||||
this._splitPane.terminal.options.fontWeightBold = fontWeightBold;
|
tile.terminal.options.fontWeightBold = fontWeightBold;
|
||||||
this._splitPane.fitAddon?.fit();
|
tile.fit(); // a font change is a size change: tell its PTY too (#464)
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
loadFontSize() {
|
loadFontSize() {
|
||||||
@@ -5995,6 +6105,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// settle-time refit through this call and has no fallback, which is correct:
|
// settle-time refit through this call and has no fallback, which is correct:
|
||||||
// a pane it does not own is not its to refit either.
|
// a pane it does not own is not its to refit either.
|
||||||
if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false;
|
if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false;
|
||||||
|
// Backstop: while the tile grid owns the terminal, a tile sizes this PTY and
|
||||||
|
// the parked main terminal measures nothing worth sending.
|
||||||
|
if (this._tilesOwnTerminal?.()) return false;
|
||||||
// Fit, floor, and apply in one step so the numbers below are the numbers
|
// Fit, floor, and apply in one step so the numbers below are the numbers
|
||||||
// xterm is actually holding (or, while another device holds the width,
|
// xterm is actually holding (or, while another device holds the width,
|
||||||
// the numbers this container would hold if the PTY followed).
|
// the numbers this container would hold if the PTY followed).
|
||||||
@@ -6245,13 +6358,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this._splitPane?.terminal) {
|
this._forEachTile?.((tile) => {
|
||||||
this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
tile.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||||
this._splitPane.terminal.options.theme = { ...theme };
|
tile.terminal.options.theme = { ...theme };
|
||||||
try {
|
try {
|
||||||
this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1);
|
tile.terminal.refresh(0, tile.terminal.rows - 1);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -554,6 +554,11 @@ const VoiceInput = {
|
|||||||
_analyserSource: null, // MediaStreamSource for level meter
|
_analyserSource: null, // MediaStreamSource for level meter
|
||||||
_audioContext: null, // AudioContext for level meter
|
_audioContext: null, // AudioContext for level meter
|
||||||
_levelAnimFrame: null, // rAF handle for level meter
|
_levelAnimFrame: null, // rAF handle for level meter
|
||||||
|
// The session dictation was started FOR, captured in start(). Transcripts
|
||||||
|
// arrive seconds later and the green send button / compose overlay can be
|
||||||
|
// used later still; reading app.activeSessionId at that point sent the text
|
||||||
|
// to whatever tab the user had switched to in the meantime.
|
||||||
|
_targetSessionId: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._initRecognition();
|
this._initRecognition();
|
||||||
@@ -663,10 +668,12 @@ const VoiceInput = {
|
|||||||
|
|
||||||
start() {
|
start() {
|
||||||
if (this.isRecording) return;
|
if (this.isRecording) return;
|
||||||
if (!app.activeSessionId) {
|
const target = app._focusedPane?.()?.sessionId || app.activeSessionId;
|
||||||
|
if (!target) {
|
||||||
app.showToast('No active session', 'warning');
|
app.showToast('No active session', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this._targetSessionId = target;
|
||||||
this._retryCount = 0;
|
this._retryCount = 0;
|
||||||
|
|
||||||
const provider = this._resolveProvider();
|
const provider = this._resolveProvider();
|
||||||
@@ -959,8 +966,31 @@ const VoiceInput = {
|
|||||||
this.stop();
|
this.stop();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** The session this dictation belongs to (see _targetSessionId). */
|
||||||
|
_targetSession() {
|
||||||
|
return this._targetSessionId || app.activeSessionId;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send text to the dictation's own session. The active session keeps going
|
||||||
|
* through app.sendInput() exactly as before; any other session goes straight
|
||||||
|
* to the durable queue with the same useMux flag sendInput() passes.
|
||||||
|
*/
|
||||||
|
_sendToTarget(target, text) {
|
||||||
|
if (target === app.activeSessionId) return app.sendInput(text);
|
||||||
|
// Closed while dictating: say so instead of queueing text for a session
|
||||||
|
// that will only answer 404 (and never typing it into some other tab).
|
||||||
|
if (app.sessions && !app.sessions.has(target)) {
|
||||||
|
app.showToast?.('That session has closed; dictation not sent', 'warning');
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
app._sendInputAsync(target, text, { useMux: true });
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
_insertText(text) {
|
_insertText(text) {
|
||||||
if (!app.activeSessionId || !text.trim()) return;
|
const target = this._targetSession();
|
||||||
|
if (!target || !text.trim()) return;
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
const mode = this._getDeepgramConfig().insertMode || 'direct';
|
const mode = this._getDeepgramConfig().insertMode || 'direct';
|
||||||
|
|
||||||
@@ -975,14 +1005,17 @@ const VoiceInput = {
|
|||||||
this._showComposeOverlay(trimmed);
|
this._showComposeOverlay(trimmed);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Direct mode: inject into local echo overlay if available, else send to PTY
|
// Direct mode: inject into local echo overlay if available, else send to PTY.
|
||||||
if (app._localEchoEnabled && app._localEchoOverlay) {
|
// The overlay belongs to the ACTIVE session's terminal, so text dictated
|
||||||
|
// for any other session must not be typed into it.
|
||||||
|
const isActive = target === app.activeSessionId;
|
||||||
|
if (isActive && app._localEchoEnabled && app._localEchoOverlay) {
|
||||||
app._localEchoOverlay.appendText(trimmed);
|
app._localEchoOverlay.appendText(trimmed);
|
||||||
} else {
|
} else {
|
||||||
app.sendInput(trimmed).catch(() => {});
|
this._sendToTarget(target, trimmed).catch(() => {});
|
||||||
}
|
}
|
||||||
this._showVoiceSendBtn();
|
this._showVoiceSendBtn();
|
||||||
setTimeout(() => { if (app.terminal) app.terminal.focus(); }, 150);
|
setTimeout(() => { if (isActive && app.terminal) app.terminal.focus(); }, 150);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1008,10 +1041,15 @@ const VoiceInput = {
|
|||||||
|
|
||||||
// Click handler
|
// Click handler
|
||||||
this._voiceSendHandler = () => {
|
this._voiceSendHandler = () => {
|
||||||
if (!app.activeSessionId) return;
|
const target = this._targetSession();
|
||||||
|
if (!target) return;
|
||||||
// Simulate Enter key: if local echo is active, flush its buffer + send \r;
|
// Simulate Enter key: if local echo is active, flush its buffer + send \r;
|
||||||
// otherwise just send \r directly to the PTY
|
// otherwise just send \r directly to the PTY. Both the overlay and the
|
||||||
if (app._localEchoEnabled && app._localEchoOverlay) {
|
// predictions belong to the ACTIVE session's terminal, so a dictation
|
||||||
|
// for another session just sends its Enter there.
|
||||||
|
if (target !== app.activeSessionId) {
|
||||||
|
this._sendToTarget(target, '\r').catch(() => {});
|
||||||
|
} else if (app._localEchoEnabled && app._localEchoOverlay) {
|
||||||
const text = app._localEchoOverlay.pendingText || '';
|
const text = app._localEchoOverlay.pendingText || '';
|
||||||
app._localEchoOverlay.clear();
|
app._localEchoOverlay.clear();
|
||||||
app._localEchoOverlay.suppressBufferDetection();
|
app._localEchoOverlay.suppressBufferDetection();
|
||||||
@@ -1065,7 +1103,7 @@ const VoiceInput = {
|
|||||||
const send = () => {
|
const send = () => {
|
||||||
const val = textarea.value.trim();
|
const val = textarea.value.trim();
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
if (val) app.sendInput(val + '\r').catch(() => {});
|
if (val) this._sendToTarget(this._targetSession(), val + '\r').catch(() => {});
|
||||||
};
|
};
|
||||||
const cancel = () => overlay.remove();
|
const cancel = () => overlay.remove();
|
||||||
const newInput = () => {
|
const newInput = () => {
|
||||||
|
|||||||
@@ -775,6 +775,20 @@ export function resolveOmpConfigForCreate(
|
|||||||
return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig;
|
return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tmux history lines a full capture may read (`capture-pane -S -<n>`): the
|
||||||
|
* optional `lines` query parameter, an integer of at least 1, never more than
|
||||||
|
* the configured history limit; absent or malformed, the limit itself, as
|
||||||
|
* before. A grid tile sends its own scrollback size: its xterm keeps no more
|
||||||
|
* than that, while a capture of the whole history (tens of thousands of lines,
|
||||||
|
* cut to `tail` only afterwards) is synchronous work on this event loop.
|
||||||
|
*/
|
||||||
|
function captureHistoryLines(raw: string | undefined, historyLimit: number): number {
|
||||||
|
if (typeof raw !== 'string' || !/^\d{1,9}$/.test(raw)) return historyLimit;
|
||||||
|
const lines = Number(raw);
|
||||||
|
return lines >= 1 ? Math.min(lines, historyLimit) : historyLimit;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `RemoteHost` → the wake registry's host shape. They differ in one field name only
|
* `RemoteHost` → the wake registry's host shape. They differ in one field name only
|
||||||
* (`id` in host config vs `hostId` on a session's `remote`), but the rename is load-
|
* (`id` in host config vs `hostId` on a session's `remote`), but the rename is load-
|
||||||
@@ -2986,7 +3000,7 @@ export function registerSessionRoutes(
|
|||||||
app.get('/api/sessions/:id/terminal', async (req, reply) => {
|
app.get('/api/sessions/:id/terminal', async (req, reply) => {
|
||||||
const routeStartedAt = performance.now();
|
const routeStartedAt = performance.now();
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const query = req.query as { tail?: string; full?: string };
|
const query = req.query as { tail?: string; full?: string; lines?: string };
|
||||||
const session = findSessionOrFail(ctx, id, req);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
// `full=1` is the EXPLICIT full-history signal (COD-47): capture the ENTIRE
|
// `full=1` is the EXPLICIT full-history signal (COD-47): capture the ENTIRE
|
||||||
@@ -3010,8 +3024,14 @@ export function registerSessionRoutes(
|
|||||||
// single reason: `capturedGeometry` comes BACK on it, and the response has
|
// single reason: `capturedGeometry` comes BACK on it, and the response has
|
||||||
// to tell the client what size the frame it is about to render was built
|
// to tell the client what size the frame it is about to render was built
|
||||||
// for. See PaneCaptureOptions.capturedGeometry.
|
// for. See PaneCaptureOptions.capturedGeometry.
|
||||||
|
// `lines` bounds only the history a FULL capture reads; the visible-frame
|
||||||
|
// path reads no history and is untouched by it.
|
||||||
const captureOpts: PaneCaptureOptions = isFullReload
|
const captureOpts: PaneCaptureOptions = isFullReload
|
||||||
? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
|
? {
|
||||||
|
fullHistory: true,
|
||||||
|
historyLimitLines: captureHistoryLines(query.lines, tmuxHistoryLimit),
|
||||||
|
maxCaptureBytes: terminalBufferMaxBytes,
|
||||||
|
}
|
||||||
: {};
|
: {};
|
||||||
const liveMuxBuffer =
|
const liveMuxBuffer =
|
||||||
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
* the header "Plan Usage Limits" chip. Auth-exempt like `/api/hook-event`
|
* the header "Plan Usage Limits" chip. Auth-exempt like `/api/hook-event`
|
||||||
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
|
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
|
||||||
*
|
*
|
||||||
|
* Also records the session's model (`model.display_name`) as its reported model, which
|
||||||
|
* `session:updated` publishes as `displayModel` for the session headers.
|
||||||
|
*
|
||||||
* Returns a compact plain-text status string for the exporter to print as the
|
* Returns a compact plain-text status string for the exporter to print as the
|
||||||
* in-terminal footer (print-through) when it has no statusline of the user's
|
* in-terminal footer (print-through) when it has no statusline of the user's
|
||||||
* own to wrap. An unknown session gets an EMPTY body: the old brand-word
|
* own to wrap. An unknown session gets an EMPTY body: the old brand-word
|
||||||
@@ -26,6 +29,7 @@ import {
|
|||||||
import { SessionStatusTelemetry } from '../sse-events.js';
|
import { SessionStatusTelemetry } from '../sse-events.js';
|
||||||
import { setLatestPlanUsage } from '../plan-usage-latest.js';
|
import { setLatestPlanUsage } from '../plan-usage-latest.js';
|
||||||
import type { SessionPort, EventPort } from '../ports/index.js';
|
import type { SessionPort, EventPort } from '../ports/index.js';
|
||||||
|
import { getCli } from '../../config/cli-registry/index.js';
|
||||||
|
|
||||||
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
|
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
|
||||||
// Last broadcast telemetry signature per session — the statusline fires on
|
// Last broadcast telemetry signature per session — the statusline fires on
|
||||||
@@ -46,6 +50,15 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
|
|||||||
|
|
||||||
const payload = data as RawStatuslinePayload | undefined;
|
const payload = data as RawStatuslinePayload | undefined;
|
||||||
|
|
||||||
|
// The model the CLI says it is running, for the session headers (displayModel). It
|
||||||
|
// rides every render, so it follows an in-session `/model`; the session dedupes and
|
||||||
|
// broadcasts only a change. Only a CLI that has a statusline exporter at all may
|
||||||
|
// report one here.
|
||||||
|
const session = ctx.sessions.get(sessionId);
|
||||||
|
if (session && getCli(session.mode)?.capabilities.statusLineTelemetry) {
|
||||||
|
session.noteReportedModel('statusline', payload?.model?.display_name);
|
||||||
|
}
|
||||||
|
|
||||||
// Plan-usage limits (account-wide) → broadcast to the header chip, when
|
// Plan-usage limits (account-wide) → broadcast to the header chip, when
|
||||||
// present and changed (the statusline fires on every assistant message).
|
// present and changed (the statusline fires on every assistant message).
|
||||||
const telemetry = parseStatusTelemetry(payload);
|
const telemetry = parseStatusTelemetry(payload);
|
||||||
|
|||||||
@@ -3542,6 +3542,10 @@ export class WebServer extends EventEmitter {
|
|||||||
// pane-exit watcher's own tick replaces it with a first-hand
|
// pane-exit watcher's own tick replaces it with a first-hand
|
||||||
// reading (not the stats collector — see `startPaneExitWatcher`).
|
// reading (not the stats collector — see `startPaneExitWatcher`).
|
||||||
paneExit: savedState?.paneExit,
|
paneExit: savedState?.paneExit,
|
||||||
|
// The model the CLI last reported (statusline or its own footer), so a
|
||||||
|
// tile header keeps naming it until the next report; an idle claude
|
||||||
|
// session re-renders its statusline only on its next turn.
|
||||||
|
displayModel: savedState?.displayModel,
|
||||||
// A record rebuilt from the socket has no provenance, so its
|
// A record rebuilt from the socket has no provenance, so its
|
||||||
// apparent locality is a guess (see `MuxSession.discovered`).
|
// apparent locality is a guess (see `MuxSession.discovered`).
|
||||||
discoveredMuxSession: muxSession.discovered,
|
discoveredMuxSession: muxSession.discovered,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface SessionListenerRefs {
|
|||||||
working: () => void;
|
working: () => void;
|
||||||
idle: () => void;
|
idle: () => void;
|
||||||
watchingChanged: () => void;
|
watchingChanged: () => void;
|
||||||
|
displayModelChanged: () => void;
|
||||||
taskCreated: (task: BackgroundTask) => void;
|
taskCreated: (task: BackgroundTask) => void;
|
||||||
taskUpdated: (task: BackgroundTask) => void;
|
taskUpdated: (task: BackgroundTask) => void;
|
||||||
taskCompleted: (task: BackgroundTask) => void;
|
taskCompleted: (task: BackgroundTask) => void;
|
||||||
@@ -275,6 +276,16 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
|||||||
deps.broadcastSessionStateDebounced(session.id);
|
deps.broadcastSessionStateDebounced(session.id);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes the session state when the model the CLI reports changes (its statusline,
|
||||||
|
* or its own footer), which the status does not reflect. Persisted too: a restart
|
||||||
|
* restores the last reported model until the next report.
|
||||||
|
*/
|
||||||
|
displayModelChanged: () => {
|
||||||
|
deps.broadcastSessionStateDebounced(session.id);
|
||||||
|
deps.persistSessionState(session);
|
||||||
|
},
|
||||||
|
|
||||||
// ─── Background Task Events ──────────────────────────────
|
// ─── Background Task Events ──────────────────────────────
|
||||||
|
|
||||||
/** Broadcasts `task:created` — new background task discovered */
|
/** Broadcasts `task:created` — new background task discovered */
|
||||||
@@ -508,6 +519,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
|||||||
session.on('working', refs.working);
|
session.on('working', refs.working);
|
||||||
session.on('idle', refs.idle);
|
session.on('idle', refs.idle);
|
||||||
session.on('watchingChanged', refs.watchingChanged);
|
session.on('watchingChanged', refs.watchingChanged);
|
||||||
|
session.on('displayModelChanged', refs.displayModelChanged);
|
||||||
session.on('taskCreated', refs.taskCreated);
|
session.on('taskCreated', refs.taskCreated);
|
||||||
session.on('taskUpdated', refs.taskUpdated);
|
session.on('taskUpdated', refs.taskUpdated);
|
||||||
session.on('taskCompleted', refs.taskCompleted);
|
session.on('taskCompleted', refs.taskCompleted);
|
||||||
@@ -545,6 +557,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
|||||||
session.off('working', refs.working);
|
session.off('working', refs.working);
|
||||||
session.off('idle', refs.idle);
|
session.off('idle', refs.idle);
|
||||||
session.off('watchingChanged', refs.watchingChanged);
|
session.off('watchingChanged', refs.watchingChanged);
|
||||||
|
session.off('displayModelChanged', refs.displayModelChanged);
|
||||||
session.off('taskCreated', refs.taskCreated);
|
session.off('taskCreated', refs.taskCreated);
|
||||||
session.off('taskUpdated', refs.taskUpdated);
|
session.off('taskUpdated', refs.taskUpdated);
|
||||||
session.off('taskCompleted', refs.taskCompleted);
|
session.off('taskCompleted', refs.taskCompleted);
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Ctrl+W never closes a session; it reaches the terminal.
|
||||||
|
*
|
||||||
|
* "Close Session" used to be bound to Ctrl+W by default. Ctrl+W is "delete the
|
||||||
|
* previous word" in every shell, readline prompt and agent CLI, so muscle memory
|
||||||
|
* killed the session (its tmux pane and CLI, with no confirm) in the middle of a
|
||||||
|
* sentence; with the split pane open it was not even the pane being typed in.
|
||||||
|
* The action now has NO default key: the capture-phase shortcut handler lets
|
||||||
|
* Ctrl+W through, and xterm sends ^W to the CLI like any other key. The action
|
||||||
|
* stays in the registry so a user can still bind a key to it in App Settings →
|
||||||
|
* Shortcuts.
|
||||||
|
*
|
||||||
|
* Real code under test: constants.js + app.js (DEFAULT_SHORTCUTS,
|
||||||
|
* getShortcutRegistry, matchesShortcutEvent) in a `vm` context.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||||
|
|
||||||
|
type Shortcut = { id: string; action?: string; bindings?: unknown[]; disabled?: boolean };
|
||||||
|
type App = {
|
||||||
|
getShortcutRegistry(): Shortcut[];
|
||||||
|
matchesShortcutEvent(e: Record<string, unknown>, s: Shortcut): boolean;
|
||||||
|
loadAppSettingsFromStorage: () => Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeApp(settings: Record<string, unknown> = {}): App {
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
performance,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
WebSocket: { OPEN: 1 },
|
||||||
|
fetch: vi.fn(),
|
||||||
|
document: { addEventListener: vi.fn() },
|
||||||
|
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||||
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
MobileDetection: {},
|
||||||
|
});
|
||||||
|
vm.runInContext(`${read('constants.js')}\n${read('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||||
|
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
||||||
|
const app = Object.create(CodemanApp.prototype) as App;
|
||||||
|
app.loadAppSettingsFromStorage = () => settings;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keydown = (overrides: Record<string, unknown>) => ({
|
||||||
|
type: 'keydown',
|
||||||
|
key: 'w',
|
||||||
|
code: 'KeyW',
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ctrl+W is left to the terminal', () => {
|
||||||
|
it('Close Session is still in the registry, with no default key', () => {
|
||||||
|
const close = makeApp()
|
||||||
|
.getShortcutRegistry()
|
||||||
|
.find((s) => s.id === 'close-session');
|
||||||
|
|
||||||
|
expect(close?.action).toBe('killActiveSession');
|
||||||
|
expect(close?.bindings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['Ctrl+W', { ctrlKey: true }],
|
||||||
|
['Cmd+W', { metaKey: true }],
|
||||||
|
])('no default shortcut answers %s, so the capture handler lets it reach xterm', (_name, mods) => {
|
||||||
|
const app = makeApp();
|
||||||
|
const event = keydown(mods);
|
||||||
|
|
||||||
|
const matched = app.getShortcutRegistry().filter((s) => !s.disabled && s.action && app.matchesShortcutEvent(event, s));
|
||||||
|
|
||||||
|
expect(matched.map((s) => s.id)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a user can still bind a key to Close Session', () => {
|
||||||
|
const app = makeApp({
|
||||||
|
shortcutOverrides: { 'close-session': { bindings: [{ modifiers: ['ctrl', 'shift'], key: 'w' }] } },
|
||||||
|
});
|
||||||
|
const close = app.getShortcutRegistry().find((s) => s.id === 'close-session')!;
|
||||||
|
|
||||||
|
expect(app.matchesShortcutEvent(keydown({ ctrlKey: true, shiftKey: true, key: 'W' }), close)).toBe(true);
|
||||||
|
expect(app.matchesShortcutEvent(keydown({ ctrlKey: true }), close)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the shortcut overlay says an unbound action is not bound', () => {
|
||||||
|
const appSource = read('app.js');
|
||||||
|
const overlay = appSource.slice(appSource.indexOf('renderShortcutOverlay() {'), appSource.indexOf('closeShortcutOverlay() {'));
|
||||||
|
|
||||||
|
expect(overlay).toContain("if (s.bindings.length === 0) return '<span class=\"shortcut-overlay-unbound\">not bound</span>';");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* The model a dsh session's TUI route config pins (src/deepseek-route-config.ts),
|
||||||
|
* shown in a session header when the TUI's screen names none.
|
||||||
|
*
|
||||||
|
* Fixture dsh homes are built in a temp directory. The two user layers below are the
|
||||||
|
* owner's real files on 2026-10-07, verbatim apart from the comment text: the
|
||||||
|
* profile layer pins the dsh-tui row's route (provider AND model), the home layer
|
||||||
|
* pins `agent-default-model`, which is the headless default and NOT the TUI's.
|
||||||
|
*
|
||||||
|
* Port: N/A.
|
||||||
|
*/
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
DSH_TUI_PACKAGE,
|
||||||
|
MAX_ROUTE_FILE_BYTES,
|
||||||
|
effectiveDshHome,
|
||||||
|
parseDshTuiPatches,
|
||||||
|
readDeepSeekRouteModel,
|
||||||
|
resolveDshTuiRouteModel,
|
||||||
|
} from '../src/deepseek-route-config.js';
|
||||||
|
|
||||||
|
const PROFILE_LAYER = `# Your patch layer for this dsh profile, applied after every bundle layer:
|
||||||
|
# a top-level YAML array of loader patch entries (id-targeted config
|
||||||
|
# overrides, disables, and insert lists; \`!!js\` expressions allowed).
|
||||||
|
#
|
||||||
|
# Route the TUI at the 5090 box's live serve. A config route counts only
|
||||||
|
# when it names BOTH provider and model.
|
||||||
|
- id: dsh-tui
|
||||||
|
config:
|
||||||
|
provider: qwen5090
|
||||||
|
model: qwen3.8-27b
|
||||||
|
effort: medium
|
||||||
|
preset: qwen5090
|
||||||
|
`;
|
||||||
|
const HOME_LAYER = `# Home-level patch layer. The default model is a composition entry,
|
||||||
|
# not a settings key.
|
||||||
|
- id: agent-default-model
|
||||||
|
config:
|
||||||
|
provider: qwen5090
|
||||||
|
model: qwen3.8-27b
|
||||||
|
`;
|
||||||
|
const SETTINGS = `agent-default-model:
|
||||||
|
provider: qwen5090
|
||||||
|
model: qwen3.8-27b
|
||||||
|
`;
|
||||||
|
const TUI_BUNDLES = ['@deepseek-ai/dsh-base', DSH_TUI_PACKAGE];
|
||||||
|
|
||||||
|
const homes: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of homes.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A fixture dsh home: profiles (bundles + optional patch layer), a home layer, settings.yaml. */
|
||||||
|
function makeHome(spec: {
|
||||||
|
profiles?: Record<string, { bundles?: string[]; patch?: string }>;
|
||||||
|
homePatch?: string;
|
||||||
|
settings?: string;
|
||||||
|
}): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'dsh-route-'));
|
||||||
|
homes.push(root);
|
||||||
|
const home = join(root, 'dsh');
|
||||||
|
mkdirSync(join(home, 'profiles'), { recursive: true });
|
||||||
|
for (const [name, p] of Object.entries(spec.profiles ?? {})) {
|
||||||
|
mkdirSync(join(home, 'profiles', name), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(home, 'profiles', name, 'package.json'),
|
||||||
|
JSON.stringify({ name: `dsh-profile-${name}`, dsh: { profile: { bundles: p.bundles ?? TUI_BUNDLES } } })
|
||||||
|
);
|
||||||
|
if (p.patch !== undefined) writeFileSync(join(home, 'profiles', name, 'cordis.patch.yml'), p.patch);
|
||||||
|
}
|
||||||
|
if (spec.homePatch !== undefined) writeFileSync(join(home, 'cordis.patch.yml'), spec.homePatch);
|
||||||
|
if (spec.settings !== undefined) writeFileSync(join(home, 'settings.yaml'), spec.settings);
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = (home: string, profile: string | undefined = 'dsh-tui', serverHome: string | null = home) =>
|
||||||
|
readDeepSeekRouteModel({ profile, home, serverHome });
|
||||||
|
|
||||||
|
describe('readDeepSeekRouteModel', () => {
|
||||||
|
it('a route pinned with both provider and model: the model', async () => {
|
||||||
|
const home = makeHome({
|
||||||
|
profiles: { 'dsh-tui': { patch: PROFILE_LAYER } },
|
||||||
|
homePatch: HOME_LAYER,
|
||||||
|
settings: SETTINGS,
|
||||||
|
});
|
||||||
|
expect(await read(home)).toBe('qwen3.8-27b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a half-pinned route (provider alone): nothing, the TUI drops it whole', async () => {
|
||||||
|
const half = '- id: dsh-tui\n config:\n provider: qwen5090\n effort: medium\n';
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: half } } }))).toBeNull();
|
||||||
|
const modelOnly = '- id: dsh-tui\n config:\n model: qwen3.8-27b\n';
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: modelOnly } } }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no profile layer: nothing (the bundle pins a provider alone, by design)', async () => {
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': {} }, homePatch: HOME_LAYER, settings: SETTINGS }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settings.yaml and the home layer's agent-default-model are the headless default, never the TUI's", async () => {
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': {} }, settings: SETTINGS }))).toBeNull();
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': {} }, homePatch: HOME_LAYER }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a layer that does not parse: nothing', async () => {
|
||||||
|
const broken = [
|
||||||
|
'- id: dsh-tui\n config:\n\tprovider: qwen5090\n\tmodel: x\n', // tab indentation
|
||||||
|
'- id: dsh-tui\n config:\n \tprovider: qwen5090\n \tmodel: x\n', // a tab inside the indentation
|
||||||
|
"- id: dsh-tui\n config:\n provider: 'qwen5090\n model: x\n", // unterminated quote
|
||||||
|
'- id: dsh-tui\n config:\n provider: qwen5090\n model: qwen3.8-27b\n model: other\n', // duplicate key
|
||||||
|
'dsh-tui:\n provider: qwen5090\n', // not a sequence
|
||||||
|
];
|
||||||
|
for (const patch of broken) {
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch } } })), patch).toBeNull();
|
||||||
|
}
|
||||||
|
// The home layer is read too: a broken one blanks a good profile layer.
|
||||||
|
const home = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, homePatch: '- id: [\n' });
|
||||||
|
expect(await read(home)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a layer that is a symlink out of the dsh home: nothing; one inside it is followed', async () => {
|
||||||
|
const home = makeHome({ profiles: { 'dsh-tui': {} } });
|
||||||
|
const outside = join(home, '..', 'outside.yml');
|
||||||
|
writeFileSync(outside, PROFILE_LAYER);
|
||||||
|
symlinkSync(outside, join(home, 'profiles', 'dsh-tui', 'cordis.patch.yml'));
|
||||||
|
expect(await read(home)).toBeNull();
|
||||||
|
|
||||||
|
const inside = makeHome({ profiles: { 'dsh-tui': {} } });
|
||||||
|
writeFileSync(join(inside, 'shared-route.yml'), PROFILE_LAYER);
|
||||||
|
symlinkSync(join(inside, 'shared-route.yml'), join(inside, 'profiles', 'dsh-tui', 'cordis.patch.yml'));
|
||||||
|
expect(await read(inside)).toBe('qwen3.8-27b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a session's own DSH_HOME is the one read", async () => {
|
||||||
|
const server = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } } });
|
||||||
|
const own = makeHome({
|
||||||
|
profiles: {
|
||||||
|
'dsh-tui': { patch: '- id: dsh-tui\n config:\n provider: deepseek-official\n model: deepseek-v4-pro\n' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await read(own, 'dsh-tui', server)).toBe('deepseek-v4-pro');
|
||||||
|
expect(await read(server, 'dsh-tui', server)).toBe('qwen3.8-27b');
|
||||||
|
// The status bar can be switched off in that home without touching the route.
|
||||||
|
expect(effectiveDshHome((k) => (k === 'DSH_HOME' ? own : undefined))).toBe(own);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the home layer outranks the profile layer, and replaces its whole config', async () => {
|
||||||
|
const pin = '- id: dsh-tui\n config:\n provider: deepseek-official\n model: deepseek-v4-flash\n';
|
||||||
|
const homeWins = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, homePatch: pin });
|
||||||
|
expect(await read(homeWins)).toBe('deepseek-v4-flash');
|
||||||
|
const half = '- id: dsh-tui\n config:\n effort: high\n';
|
||||||
|
const halved = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, homePatch: half });
|
||||||
|
expect(await read(halved)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no profile named: the one the launch would boot (the first terminal profile)', async () => {
|
||||||
|
const home = makeHome({
|
||||||
|
profiles: {
|
||||||
|
web: { bundles: ['@deepseek-ai/dsh-web-app'] },
|
||||||
|
'dsh-tui': { patch: PROFILE_LAYER },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await read(home, undefined)).toBe('qwen3.8-27b');
|
||||||
|
// An invalid name reads as unset at launch, so the default applies here too.
|
||||||
|
expect(await read(home, '../web')).toBe('qwen3.8-27b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a profile that does not compose dsh-TUI: nothing (its route is not this reader’s to know)', async () => {
|
||||||
|
const home = makeHome({ profiles: { other: { bundles: ['@someone/other-tui'], patch: PROFILE_LAYER } } });
|
||||||
|
expect(await read(home, 'other')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a disabled row, a name mismatch, a missing home or profile: nothing', async () => {
|
||||||
|
const disabled = `${PROFILE_LAYER}- id: dsh-tui\n disabled: true\n`;
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: disabled } } }))).toBeNull();
|
||||||
|
const mismatch = '- id: dsh-tui\n name: "@someone/else"\n config:\n provider: a\n model: b\n';
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: mismatch } } }))).toBeNull();
|
||||||
|
expect(await read(join(tmpdir(), 'no-such-dsh-home-xyz'))).toBeNull();
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } } }), 'missing')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an oversized layer: nothing', async () => {
|
||||||
|
const big = PROFILE_LAYER + `# ${'x'.repeat(MAX_ROUTE_FILE_BYTES)}\n`;
|
||||||
|
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: big } } }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('effectiveDshHome', () => {
|
||||||
|
it("the session's, else ~/.dsh; a relative value names no place", () => {
|
||||||
|
expect(effectiveDshHome(() => '/srv/dsh')).toBe('/srv/dsh');
|
||||||
|
expect(effectiveDshHome(() => ' ')).toMatch(/\.dsh$/);
|
||||||
|
expect(effectiveDshHome(() => undefined)).toMatch(/\.dsh$/);
|
||||||
|
expect(effectiveDshHome(() => 'dsh-copy')).toBeNull();
|
||||||
|
expect(effectiveDshHome(() => '~/dsh')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseDshTuiPatches (the narrow YAML subset)', () => {
|
||||||
|
it("reads the owner's two layers", () => {
|
||||||
|
expect(parseDshTuiPatches(PROFILE_LAYER)).toEqual([{ config: { provider: 'qwen5090', model: 'qwen3.8-27b' } }]);
|
||||||
|
expect(parseDshTuiPatches(HOME_LAYER)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an empty list, comments only, and a leading document marker', () => {
|
||||||
|
expect(parseDshTuiPatches('[]\n')).toEqual([]);
|
||||||
|
expect(parseDshTuiPatches('# nothing yet\n')).toEqual([]);
|
||||||
|
expect(parseDshTuiPatches(`---\n${PROFILE_LAYER}`)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quoted scalars, a key on its own line under `-`, a wider item indent', () => {
|
||||||
|
expect(
|
||||||
|
parseDshTuiPatches('- id: "dsh-tui"\n config:\n provider: "qwen5090"\n model: \'qwen3.8-27b\'\n')
|
||||||
|
).toEqual([{ config: { provider: 'qwen5090', model: 'qwen3.8-27b' } }]);
|
||||||
|
expect(parseDshTuiPatches('-\n id: dsh-tui\n config:\n provider: p\n model: m # trailing\n')).toEqual([
|
||||||
|
{ config: { provider: 'p', model: 'm' } },
|
||||||
|
]);
|
||||||
|
expect(parseDshTuiPatches('- id: dsh-tui\n config:\n provider: p\n model: m\n')).toEqual([
|
||||||
|
{ config: { provider: 'p', model: 'm' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anything that could change the answer and is beyond the subset: null', () => {
|
||||||
|
const cases = [
|
||||||
|
'- id: dsh-tui\n config:\n provider: !!js process.env.P\n model: m\n', // a tag
|
||||||
|
'- id: dsh-tui\n config: &route\n provider: p\n model: m\n', // an anchor
|
||||||
|
'- id: other\n config: &r { provider: p, model: m }\n- id: dsh-tui\n config: *r\n', // an alias
|
||||||
|
'- id: dsh-tui\n config:\n <<: { provider: p, model: m }\n', // a merge key
|
||||||
|
'- id: dsh-tui\n config: { provider: p, model: m }\n', // a flow config
|
||||||
|
'- id: dsh-tui\n config:\n provider: p\n model: 12\n', // a number, not a string
|
||||||
|
'- id: dsh-tui\n config:\n provider: p\n model: |\n m\n', // a block scalar
|
||||||
|
'- insert:\n - id: dsh-tui\n name: x\n', // re-inserting the row
|
||||||
|
`${PROFILE_LAYER}---\n- id: dsh-tui\n`, // a second document
|
||||||
|
'%YAML 1.2\n---\n- id: dsh-tui\n', // a directive
|
||||||
|
// A quoted scalar spanning lines swallows the next "key": YAML reads note as
|
||||||
|
// "start model: m" and the route as half-pinned.
|
||||||
|
'- id: dsh-tui\n config:\n note: "start\n model: m"\n provider: p\n',
|
||||||
|
// A plain scalar continued on the next line is one value ("m continued").
|
||||||
|
'- id: dsh-tui\n config:\n provider: p\n model: m\n continued\n',
|
||||||
|
// A group row whose config re-defines a dsh-tui row inside it.
|
||||||
|
'- id: tui-group\n config:\n - id: dsh-tui\n config:\n provider: p\n model: m\n',
|
||||||
|
'- id: tui-group\n config:\n - id: dsh-tui\n config:\n provider: p\n model: m\n',
|
||||||
|
];
|
||||||
|
for (const text of cases) expect(parseDshTuiPatches(text), text).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unrelated item it cannot follow is skipped, one about dsh-tui is not', () => {
|
||||||
|
// `rules`' sequence at its key's own indent is valid YAML but beyond the subset.
|
||||||
|
const odd = '- id: approval\n rules:\n - a\n config:\n policy: |\n line one\n';
|
||||||
|
expect(parseDshTuiPatches(`${odd}${PROFILE_LAYER}`)).toEqual([
|
||||||
|
{ config: { provider: 'qwen5090', model: 'qwen3.8-27b' } },
|
||||||
|
]);
|
||||||
|
expect(parseDshTuiPatches(`- id: dsh-tui\n config:\n rules:\n - a\n`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a disabled flag, a name, and an empty config', () => {
|
||||||
|
expect(parseDshTuiPatches('- id: dsh-tui\n disabled: true\n name: x\n config: {}\n')).toEqual([
|
||||||
|
{ disabled: true, name: 'x', config: {} },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveDshTuiRouteModel', () => {
|
||||||
|
it('the last config wins whole; a layer that could not be read blanks the answer', () => {
|
||||||
|
const full = { config: { provider: 'p', model: 'm' } };
|
||||||
|
expect(resolveDshTuiRouteModel([[full], []])).toBe('m');
|
||||||
|
expect(resolveDshTuiRouteModel([[full], [{ config: { provider: 'p' } }]])).toBeNull();
|
||||||
|
expect(resolveDshTuiRouteModel([[full], null])).toBeNull();
|
||||||
|
expect(resolveDshTuiRouteModel([[], []])).toBeNull();
|
||||||
|
expect(resolveDshTuiRouteModel([[full, { disabled: true }]])).toBeNull();
|
||||||
|
expect(resolveDshTuiRouteModel([[full, { name: '@someone/else', config: {} }]])).toBe('m');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -166,9 +166,8 @@ describe('entrance animation styles', () => {
|
|||||||
expect(blur).not.toBeNull();
|
expect(blur).not.toBeNull();
|
||||||
expect(blur.match(/var\(--line-glow\)/g)?.length).toBe(2);
|
expect(blur.match(/var\(--line-glow\)/g)?.length).toBe(2);
|
||||||
// The 100% frame deliberately omits opacity so the endpoint comes from the
|
// The 100% frame deliberately omits opacity so the endpoint comes from the
|
||||||
// element's own resting value: 0.9 on a subagent line, 1 on a lineage
|
// element's own resting value: 0.9 on a subagent line, 0.72 on a lineage
|
||||||
// line (its family group is translucent), 0.5 on a proxied one. Pinning a
|
// line, 0.95 on a working one. Pinning a number here snaps three of them.
|
||||||
// number here snaps them.
|
|
||||||
expect(blur).toMatch(/100%\s*\{\s*filter:[^}]*\}/);
|
expect(blur).toMatch(/100%\s*\{\s*filter:[^}]*\}/);
|
||||||
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
|
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview App-level terminal shortcuts act on the pane the keyboard is in.
|
||||||
|
*
|
||||||
|
* With the split pane open, the global shortcut handler (app.js, capture phase)
|
||||||
|
* used to resolve every terminal action against the PRIMARY pane: Ctrl+L typed
|
||||||
|
* into Pane B cleared Pane A's display while xterm sent the ^L into Pane B's
|
||||||
|
* PTY, and Ctrl+Shift+R restored Pane A's size. `_focusedPane()` (terminal-ui.js)
|
||||||
|
* now answers with the pane whose terminal was focused last, and the actions
|
||||||
|
* that are about a TERMINAL (clear, restore size) go through it.
|
||||||
|
*
|
||||||
|
* Close Session is not one of them: it has no default key any more (Ctrl+W is
|
||||||
|
* left to the terminal as delete-word, see ctrl-w-never-closes.test.ts), and a
|
||||||
|
* key a user binds to it closes the active session, as it always did.
|
||||||
|
*
|
||||||
|
* Real code under test: constants.js + terminal-ui.js in a `vm` context.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||||
|
|
||||||
|
function loadApp() {
|
||||||
|
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||||
|
const context = vm.createContext({
|
||||||
|
window: {},
|
||||||
|
document: {
|
||||||
|
body: { classList: { contains: () => false } },
|
||||||
|
activeElement: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
getElementById: () => null,
|
||||||
|
},
|
||||||
|
CodemanApp,
|
||||||
|
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn(), error: vi.fn() },
|
||||||
|
_crashDiag: { log: vi.fn() },
|
||||||
|
performance: { now: () => 0 },
|
||||||
|
requestAnimationFrame: () => 1,
|
||||||
|
setTimeout: () => 1,
|
||||||
|
MobileDetection: { isTouchDevice: () => false },
|
||||||
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
|
});
|
||||||
|
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||||
|
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
||||||
|
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
|
||||||
|
app.activeSessionId = 'session-a';
|
||||||
|
app.terminal = { clear: vi.fn(), cols: 120, rows: 40 };
|
||||||
|
app.showToast = vi.fn();
|
||||||
|
app.sendResize = vi.fn(async () => true);
|
||||||
|
app.getTerminalDimensions = () => ({ cols: 120, rows: 40 });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paneB(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
sessionId: 'session-b',
|
||||||
|
terminal: { clear: vi.fn(), cols: 60, rows: 30 },
|
||||||
|
fit: vi.fn(),
|
||||||
|
_destroyed: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('_focusedPane()', () => {
|
||||||
|
it('is the primary pane until another pane claims the keyboard', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
|
||||||
|
const pane = app._focusedPane();
|
||||||
|
|
||||||
|
expect(pane.isPrimary).toBe(true);
|
||||||
|
expect(pane.terminal).toBe(app.terminal);
|
||||||
|
expect(pane.sessionId).toBe('session-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the second pane after its terminal was focused, and the primary again after the primary was', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
expect(app._focusedPane()).toMatchObject({ isPrimary: false, sessionId: 'session-b', terminal: tile.terminal });
|
||||||
|
|
||||||
|
app._noteFocusedTile(null);
|
||||||
|
expect(app._focusedPane().isPrimary).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never answers with a destroyed pane', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
|
||||||
|
tile._destroyed = true;
|
||||||
|
|
||||||
|
expect(app._focusedPane().isPrimary).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('terminal shortcuts follow the focused pane', () => {
|
||||||
|
it('Ctrl+L clears the focused second pane, not the primary', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
|
||||||
|
app.clearTerminal();
|
||||||
|
|
||||||
|
expect(tile.terminal.clear).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app.terminal.clear).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+L still clears the primary when it holds the keyboard', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
|
||||||
|
app.clearTerminal();
|
||||||
|
|
||||||
|
expect(app.terminal.clear).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ctrl+Shift+R forces the focused second pane's size onto its own PTY", async () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
|
||||||
|
await app.restoreTerminalSize();
|
||||||
|
|
||||||
|
expect(tile.fit).toHaveBeenCalledWith({ force: true });
|
||||||
|
expect(app.sendResize).not.toHaveBeenCalled();
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 60x30', 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Shift+R keeps restoring the primary when it holds the keyboard', async () => {
|
||||||
|
const app = loadApp();
|
||||||
|
|
||||||
|
await app.restoreTerminalSize();
|
||||||
|
|
||||||
|
expect(app.sendResize).toHaveBeenCalledWith('session-a', { force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Close Session (user-bound key only) stays on the active session', () => {
|
||||||
|
it('killActiveSession closes activeSessionId and never consults the focused pane', () => {
|
||||||
|
const appSource = read('app.js');
|
||||||
|
const body = appSource.slice(
|
||||||
|
appSource.indexOf('async killActiveSession() {'),
|
||||||
|
appSource.indexOf('async killAllSessions() {')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body).toContain('await this.closeSession(this.activeSessionId);');
|
||||||
|
expect(body).not.toContain('_focusedPane');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview Static guard: no NEW CLI-id branch in the two files PR B2 touched
|
* @fileoverview Static guard: no NEW CLI-id branch in the two files PR B2 touched
|
||||||
* (`session-ui.js`, `mobile-overview.js`), mirroring
|
* (`session-ui.js`, `mobile-overview.js`), plus the files that draw a session
|
||||||
|
* header's harness logo and model (`constants.js`, `terminal-split.js`,
|
||||||
|
* `tile-grid.js`: the logo's `run-mode-dot <cliId>` class is the id as DATA),
|
||||||
|
* mirroring
|
||||||
* `test/cli-registry-no-id-branching.test.ts` for the backend registry.
|
* `test/cli-registry-no-id-branching.test.ts` for the backend registry.
|
||||||
*
|
*
|
||||||
* Deliberately scoped to ONLY these two files, not all of `src/web/public/`.
|
* Deliberately scoped to ONLY these two files, not all of `src/web/public/`.
|
||||||
@@ -21,7 +24,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||||
|
|
||||||
const PUBLIC = fileURLToPath(new URL('../src/web/public/', import.meta.url));
|
const PUBLIC = fileURLToPath(new URL('../src/web/public/', import.meta.url));
|
||||||
const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js'];
|
const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js', 'constants.js', 'terminal-split.js', 'tile-grid.js'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every currently-surviving branch, each with the COUNT of physical call
|
* Every currently-surviving branch, each with the COUNT of physical call
|
||||||
@@ -90,6 +93,13 @@ const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
|
|||||||
reason: 'display filter: the "CLIs found inside the container" summaries omit shell, which is not an agent CLI',
|
reason: 'display filter: the "CLIs found inside the container" summaries omit shell, which is not an agent CLI',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// tile-grid.js: a tile's Attach re-attaches through the same route pair the
|
||||||
|
// primary pane uses, and that pair is split by mode on the server side.
|
||||||
|
"tile-grid.js::mode === 'shell'": {
|
||||||
|
count: 1,
|
||||||
|
reason: 'attach route: a shell session attaches through /shell, an agent through /interactive',
|
||||||
|
},
|
||||||
|
|
||||||
// mobile-overview.js: shell is exempt from the isCliAvailable() gate the
|
// mobile-overview.js: shell is exempt from the isCliAvailable() gate the
|
||||||
// same way the toolbar's #runModeMenu exempts it (shell needs no CLI).
|
// same way the toolbar's #runModeMenu exempts it (shell needs no CLI).
|
||||||
"mobile-overview.js::mode !== 'shell'": {
|
"mobile-overview.js::mode !== 'shell'": {
|
||||||
@@ -177,7 +187,7 @@ function actualCounts(): Map<string, number> {
|
|||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('no NEW CLI-id branching in session-ui.js / mobile-overview.js (PR B2)', () => {
|
describe('no NEW CLI-id branching in the scanned frontend files', () => {
|
||||||
it('scans both files (sanity)', () => {
|
it('scans both files (sanity)', () => {
|
||||||
// If this drops to zero the scanner or the file list drifted and every
|
// If this drops to zero the scanner or the file list drifted and every
|
||||||
// assertion below would pass vacuously.
|
// assertion below would pass vacuously.
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Header icon buttons: hover motion moves the icon, never the button.
|
||||||
|
*
|
||||||
|
* A global `.btn-icon-header:hover { transform: rotate(45deg) }` (meant for the
|
||||||
|
* settings gear) turned EVERY header icon button on hover, so the folder, Tiles,
|
||||||
|
* Split and the rest swung their rounded hover background into a diamond. Three
|
||||||
|
* buttons had already been patched one by one with `transform: none`. The owner
|
||||||
|
* asked for a nicer hover on the folder and Tiles buttons; this file pins the
|
||||||
|
* shape of the fix:
|
||||||
|
*
|
||||||
|
* 1. No rule on a header icon button transforms the BUTTON on hover.
|
||||||
|
* 2. Only the gear's icon turns; the Tiles squares spread apart; the folder
|
||||||
|
* cross-fades from closed to open (two drawings in its SVG).
|
||||||
|
* 3. Those motions live inside `@media (hover: hover)`, so a tap on a touch
|
||||||
|
* screen cannot leave an icon stuck mid-motion, and reduced motion turns the
|
||||||
|
* transitions off.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import postcss, { type AtRule, type Rule } from 'postcss';
|
||||||
|
|
||||||
|
const ROOT = resolve(__dirname, '..');
|
||||||
|
const css = readFileSync(resolve(ROOT, 'src/web/public/styles.css'), 'utf8');
|
||||||
|
const html = readFileSync(resolve(ROOT, 'src/web/public/index.html'), 'utf8');
|
||||||
|
const root = postcss.parse(css);
|
||||||
|
|
||||||
|
interface FoundRule {
|
||||||
|
selector: string;
|
||||||
|
decls: Record<string, string>;
|
||||||
|
media: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rules(): FoundRule[] {
|
||||||
|
const out: FoundRule[] = [];
|
||||||
|
root.walkRules((rule: Rule) => {
|
||||||
|
const media: string[] = [];
|
||||||
|
for (let p = rule.parent; p && p.type !== 'root'; p = p.parent) {
|
||||||
|
if (p.type === 'atrule' && (p as AtRule).name === 'media') media.push((p as AtRule).params);
|
||||||
|
}
|
||||||
|
const decls: Record<string, string> = {};
|
||||||
|
rule.walkDecls((d) => {
|
||||||
|
decls[d.prop] = d.value;
|
||||||
|
});
|
||||||
|
for (const selector of rule.selectors) out.push({ selector, decls, media });
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = rules();
|
||||||
|
const iconRules = all.filter((r) => r.selector.includes('btn-icon-header'));
|
||||||
|
|
||||||
|
function buttonTag(cls: string): string {
|
||||||
|
const i = html.indexOf(`class="btn-icon-header ${cls}`);
|
||||||
|
expect(i, `${cls} button in index.html`).toBeGreaterThan(-1);
|
||||||
|
return html.slice(i, html.indexOf('</button>', i));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('header icon hover', () => {
|
||||||
|
it('never transforms the button itself on hover', () => {
|
||||||
|
const offenders = iconRules.filter(
|
||||||
|
(r) => /:hover\s*$/.test(r.selector) && r.decls.transform && r.decls.transform !== 'none'
|
||||||
|
);
|
||||||
|
expect(offenders.map((r) => `${r.selector} { transform: ${r.decls.transform} }`)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns only the gear icon, and only on pointer devices', () => {
|
||||||
|
const rotations = iconRules.filter((r) => /rotate\(/.test(r.decls.transform || ''));
|
||||||
|
expect(rotations.map((r) => r.selector)).toEqual(['.btn-icon-header.btn-settings:hover svg']);
|
||||||
|
expect(rotations[0].media).toContain('(hover: hover)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spreads the four Tiles squares apart, each toward its own corner', () => {
|
||||||
|
const svg = buttonTag('btn-tile-grid');
|
||||||
|
expect(svg.match(/<rect /g)?.length).toBe(4);
|
||||||
|
const expected = ['(-1.5px, -1.5px)', '(1.5px, -1.5px)', '(-1.5px, 1.5px)', '(1.5px, 1.5px)'];
|
||||||
|
expected.forEach((offset, i) => {
|
||||||
|
const rule = iconRules.find(
|
||||||
|
(r) => r.selector === `.btn-icon-header.btn-tile-grid:hover svg rect:nth-of-type(${i + 1})`
|
||||||
|
);
|
||||||
|
expect(rule?.decls.transform, `rect ${i + 1}`).toBe(`translate${offset}`);
|
||||||
|
expect(rule?.media).toContain('(hover: hover)');
|
||||||
|
});
|
||||||
|
const box = iconRules.find(
|
||||||
|
(r) => r.selector === '.btn-icon-header.btn-tile-grid svg rect' && r.decls['transform-box']
|
||||||
|
);
|
||||||
|
expect(box?.decls['transform-origin']).toBe('center');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the folder: the open drawing is hidden at rest and replaces the closed one on hover', () => {
|
||||||
|
const svg = buttonTag('btn-file-viewer');
|
||||||
|
expect(svg).toContain('class="icon-folder-closed"');
|
||||||
|
expect(svg).toContain('class="icon-folder-open"');
|
||||||
|
const rest = iconRules.find(
|
||||||
|
(r) => r.selector === '.btn-icon-header.btn-file-viewer svg .icon-folder-open' && r.decls.opacity
|
||||||
|
);
|
||||||
|
expect(rest?.decls.opacity).toBe('0');
|
||||||
|
expect(rest?.media).toEqual([]);
|
||||||
|
const hoverClosed = iconRules.find(
|
||||||
|
(r) => r.selector === '.btn-icon-header.btn-file-viewer:hover svg .icon-folder-closed'
|
||||||
|
);
|
||||||
|
const hoverOpen = iconRules.find(
|
||||||
|
(r) => r.selector === '.btn-icon-header.btn-file-viewer:hover svg .icon-folder-open'
|
||||||
|
);
|
||||||
|
expect(hoverClosed?.decls.opacity).toBe('0');
|
||||||
|
expect(hoverOpen?.decls.opacity).toBe('1');
|
||||||
|
expect(hoverClosed?.media).toContain('(hover: hover)');
|
||||||
|
expect(hoverOpen?.media).toContain('(hover: hover)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns the motion off under reduced motion', () => {
|
||||||
|
const reduced = iconRules.filter((r) => r.media.includes('(prefers-reduced-motion: reduce)'));
|
||||||
|
for (const sel of [
|
||||||
|
'.btn-icon-header.btn-settings svg',
|
||||||
|
'.btn-icon-header.btn-tile-grid svg rect',
|
||||||
|
'.btn-icon-header.btn-file-viewer svg .icon-folder-closed',
|
||||||
|
'.btn-icon-header.btn-file-viewer svg .icon-folder-open',
|
||||||
|
]) {
|
||||||
|
expect(reduced.find((r) => r.selector === sel)?.decls.transition, sel).toBe('none');
|
||||||
|
}
|
||||||
|
for (const sel of ['.btn-icon-header.btn-settings:hover svg', '.btn-icon-header.btn-tile-grid:hover svg rect']) {
|
||||||
|
expect(reduced.find((r) => r.selector === sel)?.decls.transform, sel).toBe('none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,14 +31,16 @@ function escapeRegExp(value: string): string {
|
|||||||
|
|
||||||
function expectShortcut(html: string, keys: string[], label: string): void {
|
function expectShortcut(html: string, keys: string[], label: string): void {
|
||||||
const keyPattern = keys.map((key) => `<kbd>${escapeRegExp(key)}</kbd>`).join('\\s*\\+\\s*');
|
const keyPattern = keys.map((key) => `<kbd>${escapeRegExp(key)}</kbd>`).join('\\s*\\+\\s*');
|
||||||
expect(html).toMatch(new RegExp(`${keyPattern}.*?${label}`, 'i'));
|
expect(html).toMatch(new RegExp(`${keyPattern}.*?${escapeRegExp(label)}`, 'i'));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('help modal shortcuts', () => {
|
describe('help modal shortcuts', () => {
|
||||||
const helpModal = normalizedHtml(extractElementById(INDEX_HTML, 'helpModal'));
|
const helpModal = normalizedHtml(extractElementById(INDEX_HTML, 'helpModal'));
|
||||||
|
|
||||||
it('documents implemented global and tab shortcuts', () => {
|
it('documents implemented global and tab shortcuts', () => {
|
||||||
expectShortcut(helpModal, ['Ctrl', 'W'], 'Close Session');
|
// Ctrl+W is NOT an app shortcut: it is delete-word in the terminal, and as
|
||||||
|
// Close Session it killed sessions with no confirm (ctrl-w-never-closes.test.ts).
|
||||||
|
expect(helpModal).not.toMatch(/<kbd>Ctrl<\/kbd>\s*\+\s*<kbd>W<\/kbd>/i);
|
||||||
expectShortcut(helpModal, ['Ctrl', 'Tab'], 'Next Session');
|
expectShortcut(helpModal, ['Ctrl', 'Tab'], 'Next Session');
|
||||||
expectShortcut(helpModal, ['Alt/Option', '['], 'Previous / Next Session');
|
expectShortcut(helpModal, ['Alt/Option', '['], 'Previous / Next Session');
|
||||||
expectShortcut(helpModal, ['Alt/Option', ']'], 'Previous / Next Session');
|
expectShortcut(helpModal, ['Alt/Option', ']'], 'Previous / Next Session');
|
||||||
@@ -51,6 +53,14 @@ describe('help modal shortcuts', () => {
|
|||||||
expectShortcut(helpModal, ['Escape'], 'Close Panels');
|
expectShortcut(helpModal, ['Escape'], 'Close Panels');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('documents the tile grid chords', () => {
|
||||||
|
expectShortcut(helpModal, ['Ctrl', 'Shift', 'G'], 'Toggle Tile Grid');
|
||||||
|
expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Arrows'], 'Focus Tile Left / Right / Up / Down');
|
||||||
|
expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Enter'], 'Zoom Focused Tile');
|
||||||
|
expectShortcut(helpModal, ['Ctrl/Cmd', 'Click'], 'Add the Session to the Tile Grid');
|
||||||
|
expectShortcut(helpModal, ['Right-click'], 'Choose How Many Tiles (2, 4 or 6)');
|
||||||
|
});
|
||||||
|
|
||||||
it('documents terminal input shortcuts without advertising stale run shortcuts', () => {
|
it('documents terminal input shortcuts without advertising stale run shortcuts', () => {
|
||||||
expectShortcut(helpModal, ['Ctrl', 'C'], 'Copy Selection');
|
expectShortcut(helpModal, ['Ctrl', 'C'], 'Copy Selection');
|
||||||
expectShortcut(helpModal, ['Ctrl', 'Shift', 'C'], 'Copy Selection');
|
expectShortcut(helpModal, ['Ctrl', 'Shift', 'C'], 'Copy Selection');
|
||||||
|
|||||||
@@ -91,6 +91,100 @@ describe('custom display name and browser localization', () => {
|
|||||||
dom.window.close();
|
dom.window.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not walk what a terminal renders: one check per mutation inside a skipped surface', async () => {
|
||||||
|
const dom = makeDom('<div class="xterm"><div class="xterm-rows" id="rows"></div></div><div id="app"></div>');
|
||||||
|
const { window } = dom;
|
||||||
|
const api = window.CodemanI18n;
|
||||||
|
api.start();
|
||||||
|
api.configure({ language: 'zh-CN' });
|
||||||
|
// i18n.js also starts itself on DOMContentLoaded (one walk of the body).
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
let walks = 0;
|
||||||
|
const createTreeWalker = window.document.createTreeWalker.bind(window.document);
|
||||||
|
window.document.createTreeWalker = (...args: Parameters<Document['createTreeWalker']>) => {
|
||||||
|
walks++;
|
||||||
|
return createTreeWalker(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
// What xterm's DOM renderer does every frame: replace the rows.
|
||||||
|
const rows = window.document.getElementById('rows')!;
|
||||||
|
for (let frame = 0; frame < 3; frame++) {
|
||||||
|
const fresh = Array.from({ length: 20 }, () => {
|
||||||
|
const row = window.document.createElement('div');
|
||||||
|
row.innerHTML = '<span>Settings saved</span><span title="Run">Run</span>';
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
rows.replaceChildren(...fresh);
|
||||||
|
}
|
||||||
|
rows.firstElementChild!.firstChild!.firstChild!.nodeValue = 'Settings saved';
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
expect(walks).toBe(0);
|
||||||
|
expect(rows.firstElementChild?.textContent).toBe('Settings savedRun');
|
||||||
|
expect(rows.querySelector('[title]')?.getAttribute('title')).toBe('Run');
|
||||||
|
|
||||||
|
// Application DOM next to it is still translated.
|
||||||
|
const button = window.document.createElement('button');
|
||||||
|
button.textContent = 'Settings saved';
|
||||||
|
window.document.getElementById('app')!.appendChild(button);
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
expect(button.textContent).toBe('设置已保存');
|
||||||
|
expect(walks).toBe(1);
|
||||||
|
dom.window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decides an xterm row by its rows container, with the verdict closest() gives', async () => {
|
||||||
|
// A terminal (rows inside .xterm) and a stray `.xterm-rows` outside any
|
||||||
|
// skipped surface, which the full check translates.
|
||||||
|
const dom = makeDom(
|
||||||
|
'<div class="xterm"><div class="xterm-screen"><div class="xterm-rows" id="rows"></div></div></div>' +
|
||||||
|
'<div class="xterm-rows" id="stray"></div>'
|
||||||
|
);
|
||||||
|
const { window } = dom;
|
||||||
|
const api = window.CodemanI18n;
|
||||||
|
api.start();
|
||||||
|
api.configure({ language: 'zh-CN' });
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
const rowsOf = (id: string) =>
|
||||||
|
Array.from({ length: 20 }, () => {
|
||||||
|
const row = window.document.createElement('div');
|
||||||
|
window.document.getElementById(id)!.appendChild(row);
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
const rows = rowsOf('rows');
|
||||||
|
const stray = rowsOf('stray');
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
let closest = 0;
|
||||||
|
const original = window.Element.prototype.closest;
|
||||||
|
window.Element.prototype.closest = function (this: Element, selector: string) {
|
||||||
|
closest++;
|
||||||
|
return original.call(this, selector);
|
||||||
|
};
|
||||||
|
|
||||||
|
// What xterm's DOM renderer does every frame: replace each row's children.
|
||||||
|
const frames = (list: Element[]) => {
|
||||||
|
for (let frame = 0; frame < 3; frame++) {
|
||||||
|
for (const row of list) {
|
||||||
|
const span = window.document.createElement('span');
|
||||||
|
span.textContent = 'Settings saved';
|
||||||
|
row.replaceChildren(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
frames(rows);
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
// 60 row records, ONE closest(): the container's own verdict, then kept.
|
||||||
|
expect(closest).toBe(1);
|
||||||
|
expect(rows.every((row) => row.textContent === 'Settings saved')).toBe(true);
|
||||||
|
|
||||||
|
// A rows container outside any skipped surface keeps the full check, and
|
||||||
|
// with it the verdict closest() gives: its rows are translated.
|
||||||
|
frames(stray);
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
window.Element.prototype.closest = original;
|
||||||
|
expect(stray.every((row) => row.textContent === '设置已保存')).toBe(true);
|
||||||
|
dom.window.close();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps a quoted group name apart from the fixed "Move to" entries in zh-CN', () => {
|
it('keeps a quoted group name apart from the fixed "Move to" entries in zh-CN', () => {
|
||||||
const dom = makeDom('');
|
const dom = makeDom('');
|
||||||
const api = dom.window.CodemanI18n;
|
const api = dom.window.CodemanI18n;
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview zh-CN coverage for the tab's exited-agent badge (and the tab's
|
||||||
|
* accessible name that carries it).
|
||||||
|
*
|
||||||
|
* Every form `paneExitLabel()` produces (plain, an exit code, a negative code,
|
||||||
|
* a signal) must translate fully through i18n.js's real `t()`, with the
|
||||||
|
* session name in the accessible name passed through untranslated (a name
|
||||||
|
* that is itself a dictionary word included), and read unchanged in English.
|
||||||
|
*
|
||||||
|
* Port: N/A.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
|
||||||
|
const I18N = read('i18n.js');
|
||||||
|
const APP = read('app.js');
|
||||||
|
|
||||||
|
type Api = { t(s: string): string; configure(o: Record<string, string>): void };
|
||||||
|
function translator(language: string) {
|
||||||
|
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
url: 'http://localhost/',
|
||||||
|
});
|
||||||
|
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||||
|
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
|
||||||
|
api.configure({ language });
|
||||||
|
return { api, dom };
|
||||||
|
}
|
||||||
|
const zh = translator('zh-CN');
|
||||||
|
const en = translator('en');
|
||||||
|
afterAll(() => {
|
||||||
|
zh.dom.window.close();
|
||||||
|
en.dom.window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Latin words left after removing what may stay: key names, the AI acronym, the N placeholder. */
|
||||||
|
const leftover = (text: string) =>
|
||||||
|
text
|
||||||
|
.replace(/\b(Ctrl|Cmd|Shift|Alt|Option)\+\w+/g, '')
|
||||||
|
.replace(/\b(Ctrl|Cmd|Shift|Alt|Option|Enter|Tab|Space|End|Home|Escape|G|AI|N)\b/g, '')
|
||||||
|
.match(/[A-Za-z]+/g) ?? [];
|
||||||
|
|
||||||
|
const helpers = new Function(
|
||||||
|
`${APP.match(/function paneExitLabel\([\s\S]*?\n\}/)![0]}\n${APP.match(/function paneExitAriaLabel\([\s\S]*?\n\}/)![0]}\n` +
|
||||||
|
'return { paneExitLabel, paneExitAriaLabel };'
|
||||||
|
)() as { paneExitLabel(p: unknown): string; paneExitAriaLabel(name: string, label: string): string };
|
||||||
|
|
||||||
|
const EXITS = [{}, { status: 0 }, { status: 3 }, { status: 137 }, { status: -1 }, { signal: 9 }];
|
||||||
|
|
||||||
|
describe('the exited-agent badge in zh-CN', () => {
|
||||||
|
const labels = EXITS.map((p) => helpers.paneExitLabel({ ...p, at: 1 }));
|
||||||
|
|
||||||
|
it('produces the forms this test covers', () => {
|
||||||
|
expect(labels).toEqual(['exited', 'exited (0)', 'exited (3)', 'exited (137)', 'exited (-1)', 'exited (signal 9)']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every badge form translates fully, and reads the same in English', () => {
|
||||||
|
const bad = labels.filter((l) => {
|
||||||
|
const text = zh.api.t(l);
|
||||||
|
return text === l || leftover(text).length > 0 || en.api.t(l) !== l;
|
||||||
|
});
|
||||||
|
expect(bad).toEqual([]);
|
||||||
|
expect(zh.api.t('exited (3)')).toBe('已退出(3)');
|
||||||
|
expect(zh.api.t('exited (signal 9)')).toBe('已退出(信号 9)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the tab's accessible name translates around the session name, which stays as typed", () => {
|
||||||
|
for (const name of ['w1-case', 'Open tiles', 'Tiles']) {
|
||||||
|
for (const label of labels) {
|
||||||
|
const source = helpers.paneExitAriaLabel(name, label);
|
||||||
|
const text = zh.api.t(source);
|
||||||
|
expect(text.startsWith(`${name} 会话,智能体已退出`), `${source} -> ${text}`).toBe(true);
|
||||||
|
expect(leftover(text.slice(name.length))).toEqual([]);
|
||||||
|
expect(en.api.t(source)).toBe(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the Run button family in zh-CN', () => {
|
||||||
|
const SESSION_UI = read('session-ui.js');
|
||||||
|
const STOCK = JSON.parse(readFileSync(resolve(import.meta.dirname, '../config/clis.stock.json'), 'utf8')) as Array<{
|
||||||
|
shortBadge: string;
|
||||||
|
}>;
|
||||||
|
// What _applyRunMode can show: its hard-coded labels, and `Run <shortBadge>` for any registry CLI.
|
||||||
|
const applyRunMode = SESSION_UI.slice(
|
||||||
|
SESSION_UI.indexOf(' _applyRunMode() {'),
|
||||||
|
SESSION_UI.indexOf(' sendEnterKey() {')
|
||||||
|
);
|
||||||
|
const hardCoded = [...applyRunMode.matchAll(/'(Run(?: [A-Z]+)?)'/g)].map((m) => m[1]);
|
||||||
|
const fromRegistry = STOCK.map((e) => `Run ${e.shortBadge}`);
|
||||||
|
|
||||||
|
it('covers the hard-coded labels and every stock mode code', () => {
|
||||||
|
expect(hardCoded).toEqual(expect.arrayContaining(['Run SH', 'Run OC', 'Run CX', 'Run OMP', 'Run']));
|
||||||
|
expect(fromRegistry).toEqual(expect.arrayContaining(['Run CC', 'Run SH', 'Run OM']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Run" becomes 运行, the mode code and product names stay, English unchanged', () => {
|
||||||
|
for (const label of new Set([...hardCoded, ...fromRegistry])) {
|
||||||
|
const text = zh.api.t(label);
|
||||||
|
const code = label.slice(4);
|
||||||
|
// A code that is also a product name has its own entry, matched without
|
||||||
|
// case ("Run PI" -> the "Run Pi" entry, 运行 Pi): the code survives either way.
|
||||||
|
expect(text.startsWith('运行'), label).toBe(true);
|
||||||
|
expect(text.slice(2).trim().toLowerCase(), label).toBe(code.toLowerCase());
|
||||||
|
expect(en.api.t(label)).toBe(label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the toolbar around it: titles and the Shell button', () => {
|
||||||
|
for (const s of [
|
||||||
|
'Run Shell',
|
||||||
|
'Select AI backend',
|
||||||
|
'Terminal / Shell',
|
||||||
|
'Send Enter',
|
||||||
|
'Instance count',
|
||||||
|
'Stop (Ctrl+C)',
|
||||||
|
]) {
|
||||||
|
const text = zh.api.t(s);
|
||||||
|
expect(text, s).not.toBe(s);
|
||||||
|
// "Shell" stays, as the table already had it (运行 Shell).
|
||||||
|
expect(leftover(text.replace(/Shell/g, '')), s).toEqual([]);
|
||||||
|
expect(en.api.t(s)).toBe(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the Help modal and the shortcut overlay in zh-CN', () => {
|
||||||
|
const dom = new JSDOM(read('index.html'), { runScripts: 'outside-only', url: 'http://localhost/' });
|
||||||
|
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||||
|
const doc = dom.window.document;
|
||||||
|
const control = doc.createElement('button');
|
||||||
|
control.textContent = 'Home';
|
||||||
|
doc.body.appendChild(control);
|
||||||
|
const api = (dom.window as unknown as { CodemanI18n: Api & { start(): void } }).CodemanI18n;
|
||||||
|
api.start();
|
||||||
|
api.configure({ language: 'zh-CN' });
|
||||||
|
|
||||||
|
it('no English left in the Help modal outside the key names', () => {
|
||||||
|
const left: string[] = [];
|
||||||
|
const walk = (el: Element) => {
|
||||||
|
for (const node of el.childNodes) {
|
||||||
|
if (node.nodeType === 3) {
|
||||||
|
if (node.parentElement?.tagName !== 'KBD' && leftover(node.nodeValue ?? '').length)
|
||||||
|
left.push(node.nodeValue!.trim());
|
||||||
|
} else if (node.nodeType === 1) walk(node as Element);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(doc.getElementById('helpModal')!);
|
||||||
|
expect(left).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the Home KEY stays Home, while the word Home elsewhere still translates', () => {
|
||||||
|
const homeKey = [...doc.querySelectorAll('#helpModal kbd')].find((k) => k.closest('div')?.nextElementSibling);
|
||||||
|
const keys = [...doc.querySelectorAll('#helpModal kbd')].map((k) => k.textContent);
|
||||||
|
expect(keys).toContain('Home');
|
||||||
|
expect(keys).not.toContain('主页');
|
||||||
|
// Mouse inputs in the key column do translate (as Click / Right-click do).
|
||||||
|
expect(keys).toContain('滚轮');
|
||||||
|
expect(homeKey).toBeTruthy();
|
||||||
|
expect(control.textContent).toBe('主页');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every shortcut registry group and label translates (the overlay and the App Settings list)', () => {
|
||||||
|
const start = APP.indexOf('const DEFAULT_SHORTCUTS = [');
|
||||||
|
const registry = APP.slice(start, APP.indexOf('\n];', start));
|
||||||
|
const pairs = [...registry.matchAll(/group: '([^']+)',\s*label: '([^']+)'/g)];
|
||||||
|
expect(pairs.length).toBeGreaterThan(20);
|
||||||
|
const bad = pairs.flatMap(([, group, label]) =>
|
||||||
|
[group, label].filter((s) => zh.api.t(s) === s || leftover(zh.api.t(s)).length > 0)
|
||||||
|
);
|
||||||
|
expect([...new Set(bad)]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the overlay's key column is never translated", () => {
|
||||||
|
const overlay = APP.slice(APP.indexOf(' renderShortcutOverlay() {'), APP.indexOf(' closeShortcutOverlay() {'));
|
||||||
|
expect(overlay.match(/<kbd data-i18n-skip>/g)).toHaveLength(2);
|
||||||
|
expect(overlay).not.toMatch(/<kbd>/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -143,6 +143,7 @@ function loadImageInputApp() {
|
|||||||
app.activeSessionId = 'session-1';
|
app.activeSessionId = 'session-1';
|
||||||
app.showToast = vi.fn();
|
app.showToast = vi.fn();
|
||||||
app.sendInput = vi.fn(async () => {});
|
app.sendInput = vi.fn(async () => {});
|
||||||
|
app._sendInputAsync = vi.fn();
|
||||||
app._normalizeImageForUpload = vi.fn(async (file) => file);
|
app._normalizeImageForUpload = vi.fn(async (file) => file);
|
||||||
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
|
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
|
||||||
return app as Record<string, any>;
|
return app as Record<string, any>;
|
||||||
@@ -199,6 +200,7 @@ describe('image upload insertion policy', () => {
|
|||||||
|
|
||||||
expect(Array.from(paths)).toEqual(['/tmp/first.png', '/tmp/second.png']);
|
expect(Array.from(paths)).toEqual(['/tmp/first.png', '/tmp/second.png']);
|
||||||
expect(app.sendInput).not.toHaveBeenCalled();
|
expect(app.sendInput).not.toHaveBeenCalled();
|
||||||
|
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves terminal insertion by default', async () => {
|
it('preserves terminal insertion by default', async () => {
|
||||||
@@ -207,6 +209,33 @@ describe('image upload insertion policy', () => {
|
|||||||
const paths = await app._uploadAndInsertImages([{ path: '/tmp/legacy.png' }]);
|
const paths = await app._uploadAndInsertImages([{ path: '/tmp/legacy.png' }]);
|
||||||
|
|
||||||
expect(Array.from(paths)).toEqual(['/tmp/legacy.png']);
|
expect(Array.from(paths)).toEqual(['/tmp/legacy.png']);
|
||||||
expect(app.sendInput).toHaveBeenCalledWith('/tmp/legacy.png');
|
// The same delivery sendInput() uses (durable queue, useMux for the POST
|
||||||
|
// fallback), but addressed to the session the batch was uploaded to.
|
||||||
|
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '/tmp/legacy.png', { useMux: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts into the session the upload started in, even after a tab switch mid-upload', async () => {
|
||||||
|
const app = loadImageInputApp();
|
||||||
|
// The user switches tabs while the upload is in flight. sendInput() re-read
|
||||||
|
// activeSessionId after the awaits, so the paths used to land in session-2.
|
||||||
|
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => {
|
||||||
|
app.activeSessionId = 'session-2';
|
||||||
|
return file.path;
|
||||||
|
});
|
||||||
|
|
||||||
|
await app._uploadAndInsertImages([{ path: '/tmp/shot.png' }]);
|
||||||
|
|
||||||
|
expect(app._uploadPasteImage).toHaveBeenCalledWith('session-1', { path: '/tmp/shot.png' });
|
||||||
|
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '/tmp/shot.png', { useMux: true });
|
||||||
|
expect(app.sendInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploads to and inserts into an explicitly named session', async () => {
|
||||||
|
const app = loadImageInputApp();
|
||||||
|
|
||||||
|
await app._uploadAndInsertImages([{ path: '/tmp/pane-b.png' }], { sessionId: 'session-b' });
|
||||||
|
|
||||||
|
expect(app._uploadPasteImage).toHaveBeenCalledWith('session-b', { path: '/tmp/pane-b.png' });
|
||||||
|
expect(app._sendInputAsync).toHaveBeenCalledWith('session-b', '/tmp/pane-b.png', { useMux: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ describe('the client never queues or keeps an undeliverable frame', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('drops a frame the WebSocket refused with an error ACK', () => {
|
it('drops a frame the WebSocket refused with an error ACK', () => {
|
||||||
const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg) {'));
|
const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg, sessionId = this._wsSessionId) {'));
|
||||||
expect(handler.slice(0, 600)).toMatch(/if \(msg && msg\.err\)/);
|
expect(handler.slice(0, 600)).toMatch(/if \(msg && msg\.err\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The durable input layer delivers over ANY registered terminal
|
||||||
|
* socket, not only the primary one (`_inputSocketFor`, app.js).
|
||||||
|
*
|
||||||
|
* The exactly-once queue (`_sendInputAsync` → `_reliableSend` → `_drainSession`,
|
||||||
|
* ACKed by `{t:'ia'}`, swept by `_redeliverSweep`) used to read the single
|
||||||
|
* primary socket (`this._ws` / `this._wsSessionId`) directly. A second terminal
|
||||||
|
* bound to another session (the split pane's Pane B, later a grid tile) had no
|
||||||
|
* way in, so its keystrokes went out as seq-less frames with no ACK, no retry
|
||||||
|
* and no idle-alert acknowledgement. These tests pin the seam: a registered
|
||||||
|
* socket gets seq frames, its ACK lands on ITS session's queue, a stale
|
||||||
|
* registration can never evict the socket that replaced it, and the sweep
|
||||||
|
* judges a socket's silence by that socket's own last frame.
|
||||||
|
*
|
||||||
|
* Loaded via `vm` with a stubbed context (no jsdom), like input-send-order.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
function loadCodemanAppClass() {
|
||||||
|
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
performance,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
WebSocket: { OPEN: 1 },
|
||||||
|
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
|
||||||
|
document: { addEventListener: vi.fn() },
|
||||||
|
localStorage: {
|
||||||
|
length: 0,
|
||||||
|
key: vi.fn(),
|
||||||
|
getItem: vi.fn(),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
},
|
||||||
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
MobileDetection: {},
|
||||||
|
});
|
||||||
|
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||||
|
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodemanApp = loadCodemanAppClass();
|
||||||
|
|
||||||
|
type Frame = { t: string; d: string; seq?: number; cid?: string };
|
||||||
|
type FakeSocket = { readyState: number; send: (data: string) => void; close: ReturnType<typeof vi.fn> };
|
||||||
|
type Handle = { ws: FakeSocket; lastRecvAt: number };
|
||||||
|
type Rec = { seq: number; data: string; sentAt: number; tries: number };
|
||||||
|
|
||||||
|
type App = {
|
||||||
|
_sendInputAsync: (sessionId: string, input: string, opts?: { useMux?: boolean }) => void;
|
||||||
|
_sendInputEphemeral: (sessionId: string, input: string) => void;
|
||||||
|
_inputSocketFor: (sessionId: string) => { ws: FakeSocket; lastRecvAt: number } | null;
|
||||||
|
_registerInputSocket: (sessionId: string, handle: Handle) => void;
|
||||||
|
_unregisterInputSocket: (sessionId: string, handle: Handle) => void;
|
||||||
|
_onWsInputAck: (seq: number, msg: Record<string, unknown>, sessionId?: string) => void;
|
||||||
|
_onWsReady: (sessionId: string) => void;
|
||||||
|
_redeliverSweep: () => void;
|
||||||
|
_pendingDeliveries: Map<string, Rec[]>;
|
||||||
|
_seqCounters: Map<string, number>;
|
||||||
|
_ws: FakeSocket | null;
|
||||||
|
_wsSessionId: string | null;
|
||||||
|
_wsLastRecvAt: number;
|
||||||
|
_reliableAckTimeoutMs: number;
|
||||||
|
markIdleAlertSeen: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeApp(): App {
|
||||||
|
const app = Object.create((CodemanApp as { prototype: object }).prototype) as App & Record<string, unknown>;
|
||||||
|
app._clientId = 'c-test';
|
||||||
|
app._seqCounters = new Map();
|
||||||
|
app._pendingDeliveries = new Map();
|
||||||
|
app._postDraining = new Set();
|
||||||
|
app._persistReliableState = vi.fn();
|
||||||
|
app._persistReliableNow = vi.fn();
|
||||||
|
app._updateConnectionIndicator = vi.fn();
|
||||||
|
app.markIdleAlertSeen = vi.fn();
|
||||||
|
app.activeSessionId = 'primary';
|
||||||
|
app._ws = null;
|
||||||
|
app._wsSessionId = null;
|
||||||
|
app._wsLastRecvAt = 0;
|
||||||
|
app._reliableAckTimeoutMs = 4000;
|
||||||
|
return app as unknown as App;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeSocket(frames: Frame[]): FakeSocket {
|
||||||
|
return { readyState: 1, send: (d: string) => frames.push(JSON.parse(d) as Frame), close: vi.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('a registered second socket joins the exactly-once queue', () => {
|
||||||
|
it('delivers seq-tagged frames over the registered socket for its session', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const primaryFrames: Frame[] = [];
|
||||||
|
const tileFrames: Frame[] = [];
|
||||||
|
app._ws = fakeSocket(primaryFrames);
|
||||||
|
app._wsSessionId = 'primary';
|
||||||
|
app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 });
|
||||||
|
|
||||||
|
app._sendInputAsync('other', 'x');
|
||||||
|
app._sendInputAsync('other', 'y');
|
||||||
|
|
||||||
|
expect(tileFrames.map((f) => [f.d, f.seq, f.cid])).toEqual([
|
||||||
|
['x', 1, 'c-test'],
|
||||||
|
['y', 2, 'c-test'],
|
||||||
|
]);
|
||||||
|
// Nothing for the other session leaks onto the primary socket.
|
||||||
|
expect(primaryFrames).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still prefers the primary socket for the primary session', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const primaryFrames: Frame[] = [];
|
||||||
|
app._ws = fakeSocket(primaryFrames);
|
||||||
|
app._wsSessionId = 'primary';
|
||||||
|
|
||||||
|
app._sendInputAsync('primary', 'a');
|
||||||
|
|
||||||
|
expect(primaryFrames.map((f) => f.d)).toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a registered socket that is not OPEN (falls back to POST)', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const posts: Array<{ input: string; seq: number }> = [];
|
||||||
|
global.fetch = vi.fn(async (_url, init) => {
|
||||||
|
posts.push(JSON.parse(String(init?.body)));
|
||||||
|
return new Response('{}', { status: 200 });
|
||||||
|
});
|
||||||
|
const closing = { ...fakeSocket([]), readyState: 3 };
|
||||||
|
app._registerInputSocket('other', { ws: closing, lastRecvAt: 0 });
|
||||||
|
|
||||||
|
expect(app._inputSocketFor('other')).toBeNull();
|
||||||
|
app._sendInputAsync('other', 'z');
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(posts.map((p) => [p.input, p.seq])).toEqual([['z', 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes ephemeral input over the registered socket too', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const tileFrames: Frame[] = [];
|
||||||
|
app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 });
|
||||||
|
|
||||||
|
app._sendInputEphemeral('other', '\x1b[<64;1;1M');
|
||||||
|
|
||||||
|
expect(tileFrames).toEqual([{ t: 'i', d: '\x1b[<64;1;1M' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ACK routing', () => {
|
||||||
|
it('drops the record from the queue of the session the ACK arrived for', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app._ws = fakeSocket([]);
|
||||||
|
app._wsSessionId = 'primary';
|
||||||
|
app._registerInputSocket('other', { ws: fakeSocket([]), lastRecvAt: 0 });
|
||||||
|
app._sendInputAsync('primary', 'p');
|
||||||
|
app._sendInputAsync('other', 'o');
|
||||||
|
|
||||||
|
// Both sessions issued seq 1. The ACK names no session, so the socket's own
|
||||||
|
// session decides which queue it drains; the primary's must be untouched.
|
||||||
|
app._onWsInputAck(1, { t: 'ia', seq: 1 }, 'other');
|
||||||
|
|
||||||
|
expect(app._pendingDeliveries.get('other')).toBeUndefined();
|
||||||
|
expect(app._pendingDeliveries.get('primary')?.map((r) => r.data)).toEqual(['p']);
|
||||||
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to the primary socket session when no session is passed', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app._ws = fakeSocket([]);
|
||||||
|
app._wsSessionId = 'primary';
|
||||||
|
app._sendInputAsync('primary', 'p');
|
||||||
|
|
||||||
|
app._onWsInputAck(1, { t: 'ia', seq: 1 });
|
||||||
|
|
||||||
|
expect(app._pendingDeliveries.get('primary')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-sends everything pending over a freshly registered socket on ready', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const first: Frame[] = [];
|
||||||
|
const firstHandle = { ws: fakeSocket(first), lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket('other', firstHandle);
|
||||||
|
app._sendInputAsync('other', 'a');
|
||||||
|
expect(first.map((f) => f.d)).toEqual(['a']);
|
||||||
|
|
||||||
|
// The socket dies without an ACK; a replacement opens and asks for a flush.
|
||||||
|
firstHandle.ws.readyState = 3;
|
||||||
|
const second: Frame[] = [];
|
||||||
|
app._registerInputSocket('other', { ws: fakeSocket(second), lastRecvAt: 0 });
|
||||||
|
app._onWsReady('other');
|
||||||
|
|
||||||
|
expect(second.map((f) => [f.d, f.seq])).toEqual([['a', 1]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registration ownership', () => {
|
||||||
|
it('a stale handle cannot unregister the socket that replaced it', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const oldHandle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||||
|
const newFrames: Frame[] = [];
|
||||||
|
const newHandle = { ws: fakeSocket(newFrames), lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket('other', oldHandle);
|
||||||
|
app._registerInputSocket('other', newHandle);
|
||||||
|
|
||||||
|
// The old socket's close lands late and tries to clean up after itself.
|
||||||
|
app._unregisterInputSocket('other', oldHandle);
|
||||||
|
|
||||||
|
app._sendInputAsync('other', 'still-here');
|
||||||
|
expect(newFrames.map((f) => f.d)).toEqual(['still-here']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the owning handle does unregister', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket('other', handle);
|
||||||
|
app._unregisterInputSocket('other', handle);
|
||||||
|
|
||||||
|
expect(app._inputSocketFor('other')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps lastRecvAt at registration so a fresh socket never looks silent', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||||
|
const before = Date.now();
|
||||||
|
app._registerInputSocket('other', handle);
|
||||||
|
|
||||||
|
expect(handle.lastRecvAt).toBeGreaterThanOrEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the redelivery sweep judges each socket by its own last frame', () => {
|
||||||
|
function staleRecord(app: App, sessionId: string) {
|
||||||
|
const rec = app._pendingDeliveries.get(sessionId)![0];
|
||||||
|
rec.sentAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('force-closes a silent registered socket with a stale record', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket('other', handle);
|
||||||
|
app._sendInputAsync('other', 'a');
|
||||||
|
staleRecord(app, 'other');
|
||||||
|
handle.lastRecvAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
|
||||||
|
// The primary socket is chatty; that must not vouch for the other one.
|
||||||
|
app._ws = fakeSocket([]);
|
||||||
|
app._wsSessionId = 'primary';
|
||||||
|
app._wsLastRecvAt = Date.now();
|
||||||
|
|
||||||
|
app._redeliverSweep();
|
||||||
|
|
||||||
|
expect(handle.ws.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app._ws.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-drives (does not close) a registered socket that is still receiving', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const frames: Frame[] = [];
|
||||||
|
const handle = { ws: fakeSocket(frames), lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket('other', handle);
|
||||||
|
app._sendInputAsync('other', 'a');
|
||||||
|
staleRecord(app, 'other');
|
||||||
|
handle.lastRecvAt = Date.now();
|
||||||
|
// A SILENT primary must not condemn the live registered socket either.
|
||||||
|
app._wsLastRecvAt = 0;
|
||||||
|
|
||||||
|
app._redeliverSweep();
|
||||||
|
|
||||||
|
expect(handle.ws.close).not.toHaveBeenCalled();
|
||||||
|
expect(frames.map((f) => f.d)).toEqual(['a', 'a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -383,6 +383,14 @@ export class MockSession extends EventEmitter {
|
|||||||
);
|
);
|
||||||
restartCli = vi.fn(async () => true);
|
restartCli = vi.fn(async () => true);
|
||||||
getCustomModelForPersist = vi.fn(() => this._mockCustomModel);
|
getCustomModelForPersist = vi.fn(() => this._mockCustomModel);
|
||||||
|
/** Mirrors Session.noteReportedModel: the statusline route records the CLI's model. */
|
||||||
|
reportedModel: { model: string; source: 'statusline' | 'screen' } | null = null;
|
||||||
|
noteReportedModel = vi.fn((source: 'statusline' | 'screen', raw: unknown): boolean => {
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) return false;
|
||||||
|
if (this.reportedModel?.model === raw && this.reportedModel.source === source) return false;
|
||||||
|
this.reportedModel = { model: raw, source };
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
/** Stub for sendInput */
|
/** Stub for sendInput */
|
||||||
sendInput = vi.fn();
|
sendInput = vi.fn();
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Fakes for driving a real TerminalTile (terminal-tile.js) in a
|
||||||
|
* `vm` context: its WebSocket, its xterm and the fit addon. A test puts them in
|
||||||
|
* the context as `WebSocket`, `Terminal` and `FitAddon.FitAddon`, and runs
|
||||||
|
* `connect()` and the socket handlers for real.
|
||||||
|
*/
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
export type Frame = { t: string; d?: string; seq?: number; cid?: string; c?: number; r?: number };
|
||||||
|
|
||||||
|
/** A WebSocket the test opens, feeds and closes by hand; `sent` holds every frame the tile sent. */
|
||||||
|
export class FakeSocket {
|
||||||
|
static OPEN = 1;
|
||||||
|
static instances: FakeSocket[] = [];
|
||||||
|
readyState = 0;
|
||||||
|
sent: Frame[] = [];
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||||
|
onclose: ((ev?: { code: number }) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
constructor(public url: string) {
|
||||||
|
FakeSocket.instances.push(this);
|
||||||
|
}
|
||||||
|
send(data: string) {
|
||||||
|
this.sent.push(JSON.parse(data) as Frame);
|
||||||
|
}
|
||||||
|
close = vi.fn(() => {
|
||||||
|
this.readyState = 3;
|
||||||
|
});
|
||||||
|
open() {
|
||||||
|
this.readyState = 1;
|
||||||
|
this.onopen?.();
|
||||||
|
}
|
||||||
|
receive(msg: object) {
|
||||||
|
this.onmessage?.({ data: JSON.stringify(msg) });
|
||||||
|
}
|
||||||
|
inputFrames() {
|
||||||
|
return this.sent.filter((f) => f.t === 'i');
|
||||||
|
}
|
||||||
|
/** The connection drops: closed, and the tile hears `code`. */
|
||||||
|
drop(code = 1006) {
|
||||||
|
this.readyState = 3;
|
||||||
|
this.onclose?.({ code });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fit addon: proposes `FakeFit.proposed` and, like the real one, resizes to it (NaN = hidden pane). */
|
||||||
|
export class FakeFit {
|
||||||
|
static proposed = { cols: 80, rows: 24 };
|
||||||
|
term: FakeTerminal | null = null;
|
||||||
|
fit() {
|
||||||
|
const { cols, rows } = FakeFit.proposed;
|
||||||
|
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
|
||||||
|
this.term?.resize(cols, rows);
|
||||||
|
}
|
||||||
|
proposeDimensions() {
|
||||||
|
return { ...FakeFit.proposed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An xterm that records writes, resizes and its handlers; `type()` feeds onData like a keystroke. */
|
||||||
|
export class FakeTerminal {
|
||||||
|
static last: FakeTerminal | null = null;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
cols = 80;
|
||||||
|
rows = 24;
|
||||||
|
dataCb: ((data: string) => void) | null = null;
|
||||||
|
buffer = { active: { type: 'normal', viewportY: 0, length: 24 } };
|
||||||
|
constructor(options: Record<string, unknown>) {
|
||||||
|
this.options = { ...options };
|
||||||
|
FakeTerminal.last = this;
|
||||||
|
}
|
||||||
|
loadAddon(addon: FakeFit) {
|
||||||
|
addon.term = this;
|
||||||
|
}
|
||||||
|
open() {}
|
||||||
|
onData(cb: (data: string) => void) {
|
||||||
|
this.dataCb = cb;
|
||||||
|
}
|
||||||
|
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
|
||||||
|
focusListeners: Array<() => void> = [];
|
||||||
|
textarea = {
|
||||||
|
addEventListener: (type: string, fn: () => void) => {
|
||||||
|
if (type === 'focus') this.focusListeners.push(fn);
|
||||||
|
},
|
||||||
|
removeEventListener: (type: string, fn: () => void) => {
|
||||||
|
if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
focusTextarea() {
|
||||||
|
for (const fn of this.focusListeners) fn();
|
||||||
|
}
|
||||||
|
attachCustomKeyEventHandler(fn: (ev: Record<string, unknown>) => boolean) {
|
||||||
|
this.keyHandler = fn;
|
||||||
|
}
|
||||||
|
registerLinkProvider() {}
|
||||||
|
writes: string[] = [];
|
||||||
|
/** Set by a test: write callbacks never run, as on a disposed xterm. */
|
||||||
|
holdParse = false;
|
||||||
|
write(data: string, cb?: () => void) {
|
||||||
|
// An empty write puts nothing on screen; the replay queues one only to hear
|
||||||
|
// (its callback) that everything before it has been parsed.
|
||||||
|
if (data) this.writes.push(data);
|
||||||
|
if (!this.holdParse) cb?.();
|
||||||
|
}
|
||||||
|
clear() {
|
||||||
|
this.writes.push('<CLEAR>');
|
||||||
|
}
|
||||||
|
resizes: Array<[number, number]> = [];
|
||||||
|
resize(cols: number, rows: number) {
|
||||||
|
this.resizes.push([cols, rows]);
|
||||||
|
this.cols = cols;
|
||||||
|
this.rows = rows;
|
||||||
|
}
|
||||||
|
scrollToLine() {}
|
||||||
|
scrollToTop() {}
|
||||||
|
dispose() {}
|
||||||
|
type(data: string) {
|
||||||
|
this.dataCb?.(data);
|
||||||
|
}
|
||||||
|
/** What a drag selected; '' is no selection. */
|
||||||
|
selection = '';
|
||||||
|
hasSelection() {
|
||||||
|
return this.selection !== '';
|
||||||
|
}
|
||||||
|
getSelection() {
|
||||||
|
return this.selection;
|
||||||
|
}
|
||||||
|
clearSelection = vi.fn(() => {
|
||||||
|
this.selection = '';
|
||||||
|
});
|
||||||
|
focus = vi.fn();
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview A `vm` harness for the tile grid (tile-grid.js) with the real
|
||||||
|
* app around it: constants.js + app.js + terminal-ui.js + terminal-split.js +
|
||||||
|
* tile-grid.js in one context, a small fake DOM (just what the grid touches) and a fake
|
||||||
|
* TerminalTile that records what the grid asks of it.
|
||||||
|
*
|
||||||
|
* `makeGridApp()` returns an app instance with everything around the grid that
|
||||||
|
* a test does not exercise stubbed as `vi.fn()`; a test overrides what it needs
|
||||||
|
* (or deletes a stub to run the real method from the prototype).
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
/** What has the keyboard: the last FakeEl focused (document.activeElement). */
|
||||||
|
let focusedEl: unknown = null;
|
||||||
|
export const activeElement = () => focusedEl;
|
||||||
|
|
||||||
|
/** Just enough DOM for tile-grid.js: elements with classes, children, styles and listeners. */
|
||||||
|
export class FakeEl {
|
||||||
|
id = '';
|
||||||
|
className = '';
|
||||||
|
dataset: Record<string, string> = {};
|
||||||
|
/** Inline style; `setProperty` (custom properties) writes into it too. */
|
||||||
|
style: Record<string, string> = Object.defineProperty({} as Record<string, string>, 'setProperty', {
|
||||||
|
value(this: Record<string, string>, name: string, value: string) {
|
||||||
|
this[name] = value;
|
||||||
|
},
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
inert = false;
|
||||||
|
children: FakeEl[] = [];
|
||||||
|
parentElement: FakeEl | null = null;
|
||||||
|
attrs: Record<string, string> = {};
|
||||||
|
listeners: Record<string, Array<(ev: unknown) => void>> = {};
|
||||||
|
classList = {
|
||||||
|
add: (...names: string[]) => names.forEach((n) => this._setClass(n, true)),
|
||||||
|
remove: (...names: string[]) => names.forEach((n) => this._setClass(n, false)),
|
||||||
|
toggle: (n: string, on?: boolean) => this._setClass(n, on ?? !this.classList.contains(n)),
|
||||||
|
contains: (n: string) => this.className.split(/\s+/).includes(n),
|
||||||
|
};
|
||||||
|
_setClass(name: string, on: boolean) {
|
||||||
|
const set = new Set(this.className.split(/\s+/).filter(Boolean));
|
||||||
|
if (on) set.add(name);
|
||||||
|
else set.delete(name);
|
||||||
|
this.className = [...set].join(' ');
|
||||||
|
return on;
|
||||||
|
}
|
||||||
|
append(...children: FakeEl[]) {
|
||||||
|
for (const child of children) this.appendChild(child);
|
||||||
|
}
|
||||||
|
replaceWith(other: FakeEl) {
|
||||||
|
const parent = this.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
other.remove();
|
||||||
|
const i = parent.children.indexOf(this);
|
||||||
|
parent.children.splice(i, 1, other);
|
||||||
|
other.parentElement = parent;
|
||||||
|
this.parentElement = null;
|
||||||
|
}
|
||||||
|
title = '';
|
||||||
|
hidden = false;
|
||||||
|
disabled = false;
|
||||||
|
checked = false;
|
||||||
|
contains(other: FakeEl | null): boolean {
|
||||||
|
for (let n: FakeEl | null = other; n; n = n.parentElement) if (n === this) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
textContent = '';
|
||||||
|
value = '';
|
||||||
|
type = '';
|
||||||
|
/** Records itself as `document.activeElement` (see activeElement()). */
|
||||||
|
focus = vi.fn(() => {
|
||||||
|
focusedEl = this;
|
||||||
|
});
|
||||||
|
select = vi.fn();
|
||||||
|
setPointerCapture = vi.fn();
|
||||||
|
releasePointerCapture = vi.fn();
|
||||||
|
removeEventListener(type: string, fn: (ev: unknown) => void) {
|
||||||
|
this.listeners[type] = (this.listeners[type] ?? []).filter((f) => f !== fn);
|
||||||
|
}
|
||||||
|
appendChild(child: FakeEl) {
|
||||||
|
child.remove();
|
||||||
|
child.parentElement = this;
|
||||||
|
this.children.push(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
insertBefore(child: FakeEl, ref: FakeEl | null) {
|
||||||
|
child.remove();
|
||||||
|
child.parentElement = this;
|
||||||
|
const i = ref ? this.children.indexOf(ref) : -1;
|
||||||
|
if (i === -1) this.children.push(child);
|
||||||
|
else this.children.splice(i, 0, child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
get firstChild() {
|
||||||
|
return this.children[0] ?? null;
|
||||||
|
}
|
||||||
|
get lastElementChild() {
|
||||||
|
return this.children.at(-1) ?? null;
|
||||||
|
}
|
||||||
|
/** In the fake document: its ancestors end at `main` or `body`. */
|
||||||
|
get isConnected(): boolean {
|
||||||
|
let n: FakeEl = this;
|
||||||
|
while (n.parentElement) n = n.parentElement;
|
||||||
|
return n === main || n === body;
|
||||||
|
}
|
||||||
|
/** A copy of this element and (deep) its subtree: classes, data, style, attributes, text; no listeners. */
|
||||||
|
cloneNode(deep = false): FakeEl {
|
||||||
|
const copy = new FakeEl();
|
||||||
|
copy.id = this.id;
|
||||||
|
copy.className = this.className;
|
||||||
|
copy.dataset = { ...this.dataset };
|
||||||
|
for (const [k, v] of Object.entries(this.style)) copy.style[k] = v;
|
||||||
|
copy.attrs = { ...this.attrs };
|
||||||
|
copy.textContent = this.textContent;
|
||||||
|
copy.title = this.title;
|
||||||
|
copy.hidden = this.hidden;
|
||||||
|
if (deep) for (const child of this.children) copy.appendChild(child.cloneNode(true));
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
get nextSibling() {
|
||||||
|
const siblings = this.parentElement?.children ?? [];
|
||||||
|
return siblings[siblings.indexOf(this) + 1] ?? null;
|
||||||
|
}
|
||||||
|
remove() {
|
||||||
|
if (!this.parentElement) return;
|
||||||
|
const siblings = this.parentElement.children;
|
||||||
|
const i = siblings.indexOf(this);
|
||||||
|
if (i !== -1) siblings.splice(i, 1);
|
||||||
|
this.parentElement = null;
|
||||||
|
}
|
||||||
|
setAttribute(k: string, v: string) {
|
||||||
|
this.attrs[k] = v;
|
||||||
|
}
|
||||||
|
getAttribute(k: string) {
|
||||||
|
return this.attrs[k] ?? null;
|
||||||
|
}
|
||||||
|
removeAttribute(k: string) {
|
||||||
|
delete this.attrs[k];
|
||||||
|
if (k === 'title') this.title = '';
|
||||||
|
}
|
||||||
|
/** Per event type, whether each listener was registered for the capture phase. */
|
||||||
|
captureFlags: Record<string, boolean[]> = {};
|
||||||
|
addEventListener(type: string, fn: (ev: unknown) => void, opts?: boolean | { capture?: boolean }) {
|
||||||
|
(this.listeners[type] ||= []).push(fn);
|
||||||
|
(this.captureFlags[type] ||= []).push(opts === true || (typeof opts === 'object' && !!opts?.capture));
|
||||||
|
}
|
||||||
|
dispatch(type: string, ev: unknown = {}) {
|
||||||
|
for (const fn of this.listeners[type] ?? []) fn(ev);
|
||||||
|
}
|
||||||
|
getBoundingClientRect() {
|
||||||
|
return { width: 2400, height: 1200, top: 0, left: 0, right: 2400, bottom: 1200 };
|
||||||
|
}
|
||||||
|
/** `.class` selectors only: the first descendant carrying that class. */
|
||||||
|
querySelector(sel: string): FakeEl | null {
|
||||||
|
if (!sel.startsWith('.') || /[\s[>:]/.test(sel)) return null;
|
||||||
|
const cls = sel.slice(1);
|
||||||
|
for (const child of this.children) {
|
||||||
|
if (child.classList.contains(cls)) return child;
|
||||||
|
const deeper = child.querySelector(sel);
|
||||||
|
if (deeper) return deeper;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A TerminalTile stand-in: records what the grid asks of it. */
|
||||||
|
export class FakeTile {
|
||||||
|
static all: FakeTile[] = [];
|
||||||
|
_wsReady = false;
|
||||||
|
_stoppedCode: number | null = null;
|
||||||
|
_destroyed = false;
|
||||||
|
fontSize: number | null;
|
||||||
|
terminal = { focus: vi.fn(), options: { fontSize: 0 } as Record<string, unknown> };
|
||||||
|
connect = vi.fn(async () => {});
|
||||||
|
reconnectNow = vi.fn();
|
||||||
|
fit = vi.fn();
|
||||||
|
localFit = vi.fn();
|
||||||
|
paneStarted = vi.fn();
|
||||||
|
destroy = vi.fn(() => {
|
||||||
|
this._destroyed = true;
|
||||||
|
});
|
||||||
|
onExit: ((code: number) => void) | null;
|
||||||
|
constructor(
|
||||||
|
public sessionId: string,
|
||||||
|
public mountEl: FakeEl,
|
||||||
|
public opts: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
this.fontSize = (opts.fontSize as number) ?? null;
|
||||||
|
this.onExit = (opts.onExit as (code: number) => void) ?? null;
|
||||||
|
FakeTile.all.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const main = new FakeEl();
|
||||||
|
main.className = 'main';
|
||||||
|
/** The main terminal's `.terminal-wrap` (Pane A while a split is open). */
|
||||||
|
export const wrap = new FakeEl();
|
||||||
|
wrap.className = 'terminal-wrap';
|
||||||
|
main.appendChild(wrap);
|
||||||
|
export const section = new FakeEl();
|
||||||
|
section.id = 'tileGrid';
|
||||||
|
section.className = 'tile-grid';
|
||||||
|
main.appendChild(section);
|
||||||
|
/** The grid's element for one tiled session (its `.tile`), found by its data-session-id. */
|
||||||
|
export const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl;
|
||||||
|
|
||||||
|
/** Extra elements `document.querySelector` finds, by exact selector (e.g. '.btn-split'). */
|
||||||
|
export const bySelector = new Map<string, FakeEl>();
|
||||||
|
export const body = new FakeEl();
|
||||||
|
/** The context's `fetch`; a test sets what it answers. */
|
||||||
|
export const fetchSpy = vi.fn(async (..._args: unknown[]) => ({ ok: true, json: async () => ({}) }));
|
||||||
|
/** `document.addEventListener`, so a test can find a listener the app installed. */
|
||||||
|
export const documentAddEventListener = vi.fn();
|
||||||
|
export const documentRemoveEventListener = vi.fn();
|
||||||
|
export const localStore = new Map<string, string>();
|
||||||
|
/** The clock behind `performance.now` inside the context; tests move it with advanceClock(). */
|
||||||
|
let clock = 100_000;
|
||||||
|
export function advanceClock(ms: number) {
|
||||||
|
clock += ms;
|
||||||
|
}
|
||||||
|
export const clockNow = () => clock;
|
||||||
|
/** Every callback the code under test handed a PerformanceObserver, newest last. */
|
||||||
|
export const perfObserverCallbacks: Array<(list: { getEntries(): unknown[] }) => void> = [];
|
||||||
|
/** Animation-frame callbacks the code under test queued (id = index + 1); a test runs them. */
|
||||||
|
export const rafCallbacks: Array<() => void> = [];
|
||||||
|
/**
|
||||||
|
* Runs every queued animation frame, and the frames those queue in turn (the
|
||||||
|
* grid builds its tiles' terminals one per frame), until none is left.
|
||||||
|
*/
|
||||||
|
export function flushFrames(limit = 100) {
|
||||||
|
for (let n = 0; n < limit && rafCallbacks.length; n++) for (const cb of rafCallbacks.splice(0)) cb();
|
||||||
|
}
|
||||||
|
/** What the code under test deferred with requestIdleCallback; a test runs them. */
|
||||||
|
export const idleCallbacks: Array<() => void> = [];
|
||||||
|
export const windowStub: Record<string, unknown> = {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
CodemanBase: { base: '' },
|
||||||
|
innerWidth: 2400,
|
||||||
|
innerHeight: 1200,
|
||||||
|
};
|
||||||
|
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../../src/web/public/${f}`), 'utf8');
|
||||||
|
const context = vm.createContext({
|
||||||
|
console: { ...console, log: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
|
performance: { now: () => clock },
|
||||||
|
PerformanceObserver: class {
|
||||||
|
constructor(cb: (list: { getEntries(): unknown[] }) => void) {
|
||||||
|
perfObserverCallbacks.push(cb);
|
||||||
|
}
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
|
||||||
|
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
|
||||||
|
requestAnimationFrame: (cb: () => void) => rafCallbacks.push(cb),
|
||||||
|
cancelAnimationFrame: (id: number) => {
|
||||||
|
if (id > 0) rafCallbacks[id - 1] = () => {};
|
||||||
|
},
|
||||||
|
requestIdleCallback: (cb: () => void) => idleCallbacks.push(cb),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
WebSocket: { OPEN: 1 },
|
||||||
|
fetch: (...args: unknown[]) => fetchSpy(...args),
|
||||||
|
navigator: { onLine: true },
|
||||||
|
location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' },
|
||||||
|
history: { replaceState: vi.fn(), state: null },
|
||||||
|
document: {
|
||||||
|
addEventListener: documentAddEventListener,
|
||||||
|
removeEventListener: documentRemoveEventListener,
|
||||||
|
documentElement: { dataset: {} },
|
||||||
|
createElement: () => new FakeEl(),
|
||||||
|
createElementNS: () => new FakeEl(),
|
||||||
|
get activeElement() {
|
||||||
|
return focusedEl;
|
||||||
|
},
|
||||||
|
getElementById: (id: string) => (id === 'tileGrid' ? section : (bySelector.get(`#${id}`) ?? null)),
|
||||||
|
body,
|
||||||
|
querySelector: (sel: string) =>
|
||||||
|
bySelector.get(sel) ?? (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null),
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
},
|
||||||
|
localStorage: {
|
||||||
|
getItem: (k: string) => localStore.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => localStore.set(k, String(v)),
|
||||||
|
removeItem: (k: string) => localStore.delete(k),
|
||||||
|
},
|
||||||
|
window: windowStub,
|
||||||
|
VoiceInput: { cleanup: vi.fn() },
|
||||||
|
MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
|
||||||
|
});
|
||||||
|
vm.runInContext(
|
||||||
|
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-split.js')}\n` +
|
||||||
|
`${read('tile-grid.js')}\n` +
|
||||||
|
'globalThis.__CodemanApp = CodemanApp;',
|
||||||
|
context
|
||||||
|
);
|
||||||
|
windowStub.TerminalTile = FakeTile;
|
||||||
|
windowStub.TileLoadQueue = class {
|
||||||
|
schedule(_t: unknown, _k: string, run: () => Promise<void>) {
|
||||||
|
return run();
|
||||||
|
}
|
||||||
|
drop() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CodemanApp = (context as unknown as { __CodemanApp: { prototype: Record<string, unknown> } }).__CodemanApp;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type GridApp = Record<string, any>;
|
||||||
|
|
||||||
|
/** Stubbed around the grid: panels, tabs, the main terminal's own select path. */
|
||||||
|
const STUBS = [
|
||||||
|
'_cleanupPreviousSession',
|
||||||
|
'hideWelcome',
|
||||||
|
'showWelcome',
|
||||||
|
'markIdleAlertSeen',
|
||||||
|
'renderSessionTabs',
|
||||||
|
'renderRalphStatePanel',
|
||||||
|
'renderProjectInsightsPanel',
|
||||||
|
'_updateActiveTabImmediate',
|
||||||
|
'_refreshSessionPanels',
|
||||||
|
'_updateSseSubscription',
|
||||||
|
'_updateConnectionIndicator',
|
||||||
|
'_activateFileBrowserSession',
|
||||||
|
'_hideWebviewLayer',
|
||||||
|
'closeSessionSidebarOnHandheld',
|
||||||
|
'updateAttachmentHistoryBadge',
|
||||||
|
'refreshHostWakeBanner',
|
||||||
|
'_connectWs',
|
||||||
|
'sendResize',
|
||||||
|
'showToast',
|
||||||
|
'_cleanupSessionData',
|
||||||
|
'stopSystemStatsPolling',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An app with sessions `ids` plus one more (`s-other`, FIRST in sessionOrder and
|
||||||
|
* never tiled), `s-a` active, nothing open.
|
||||||
|
*/
|
||||||
|
export function makeGridApp(ids: string[] = ['s-a', 's-b', 's-c']): GridApp {
|
||||||
|
const app = Object.create(CodemanApp.prototype) as GridApp;
|
||||||
|
app.sessions = new Map(
|
||||||
|
[...ids, 's-other'].map((id) => [id, { id, name: id, mode: 'claude', pid: 1, workingDir: '/w' }])
|
||||||
|
);
|
||||||
|
app.sessionOrder = ['s-other', ...ids];
|
||||||
|
app.detachedSessions = new Set();
|
||||||
|
app.isSoloWindow = false;
|
||||||
|
app.activeSessionId = ids[0];
|
||||||
|
app._selectGeneration = 0;
|
||||||
|
app._xtermSnapshots = new Map();
|
||||||
|
app.terminalBufferCache = new Map();
|
||||||
|
app._pendingDeliveries = new Map();
|
||||||
|
app._closingSessions = new Set();
|
||||||
|
app.pendingHooks = new Map();
|
||||||
|
app.terminal = { writeln: vi.fn(), clear: vi.fn(), focus: vi.fn(), options: { fontSize: 14 } };
|
||||||
|
for (const name of STUBS) app[name] = vi.fn();
|
||||||
|
app.loadAppSettingsFromStorage = () => ({});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resets the shared fake DOM and tile registry between tests. */
|
||||||
|
export function resetGridHarness() {
|
||||||
|
FakeTile.all = [];
|
||||||
|
// A test's prefers-reduced-motion answer (window.matchMedia) goes with it.
|
||||||
|
delete windowStub.matchMedia;
|
||||||
|
focusedEl = null;
|
||||||
|
idleCallbacks.length = 0;
|
||||||
|
rafCallbacks.length = 0;
|
||||||
|
localStore.clear();
|
||||||
|
windowStub.innerWidth = 2400;
|
||||||
|
// The CLI catalog the server injects (labels for the harness logos); a test sets its own.
|
||||||
|
delete windowStub.__codemanCliCatalog;
|
||||||
|
section.children = [];
|
||||||
|
main.className = 'main';
|
||||||
|
// A split a test left open moved .terminal-wrap into its container, with
|
||||||
|
// Pane A's header strip in it.
|
||||||
|
main.children = [];
|
||||||
|
wrap.children = [];
|
||||||
|
main.appendChild(wrap);
|
||||||
|
main.appendChild(section);
|
||||||
|
bySelector.clear();
|
||||||
|
body.children = [];
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ describe('the duplicate ACK carries what the client needs', () => {
|
|||||||
|
|
||||||
describe('the client lifts itself over the watermark', () => {
|
describe('the client lifts itself over the watermark', () => {
|
||||||
const handler = appSource.slice(
|
const handler = appSource.slice(
|
||||||
appSource.indexOf('_onWsInputAck(seq, msg)'),
|
appSource.indexOf('_onWsInputAck(seq, msg, sessionId'),
|
||||||
appSource.indexOf('/** Called from ws.onopen')
|
appSource.indexOf('/** Called from ws.onopen')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1037,6 +1037,62 @@ describe('session-routes', () => {
|
|||||||
expect(body.data.terminalBuffer.endsWith(`${newestMarker}${cursorRestore}`)).toBe(true);
|
expect(body.data.terminalBuffer.endsWith(`${newestMarker}${cursorRestore}`)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('`lines=` bounds the history a full capture reads, clamped to the configured limit', async () => {
|
||||||
|
// Grid tiles send it (TerminalTile._historyLinesQuery): without it a full
|
||||||
|
// capture reads the whole history limit synchronously, and `tail` cuts it
|
||||||
|
// only afterwards. Absent or malformed, the limit itself, as before.
|
||||||
|
const limit = (await harness.ctx.getTerminalHistoryConfig()).tmuxHistoryLimit;
|
||||||
|
harness.ctx._session.mode = 'claude';
|
||||||
|
const captureSpy = vi.fn((_name: string, _opts?: { historyLimitLines?: number }) => 'captured frame');
|
||||||
|
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||||
|
const linesFor = async (query: string) => {
|
||||||
|
captureSpy.mockClear();
|
||||||
|
await harness.app.inject({ method: 'GET', url: `/api/sessions/${harness.ctx._sessionId}/terminal?${query}` });
|
||||||
|
return captureSpy.mock.calls[0]?.[1]?.historyLimitLines;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(await linesFor(`full=1&tail=${1024 * 1024}&lines=10040`)).toBe(10040);
|
||||||
|
expect(await linesFor('full=1&lines=1')).toBe(1);
|
||||||
|
expect(await linesFor('full=1')).toBe(limit);
|
||||||
|
expect(await linesFor(`full=1&lines=${limit + 5}`)).toBe(limit);
|
||||||
|
for (const bad of ['0', '-5', 'abc', '1.5', '', '1e4', '9999999999']) {
|
||||||
|
expect(await linesFor(`full=1&lines=${bad}`), `lines=${bad}`).toBe(limit);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('`lines=` leaves a visible-frame capture (no full=1) exactly as it was', async () => {
|
||||||
|
harness.ctx._session.mode = 'shell';
|
||||||
|
const captureSpy = vi.fn(() => 'only the visible frame');
|
||||||
|
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||||
|
await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=${1024 * 1024}&lines=500`,
|
||||||
|
});
|
||||||
|
expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a capture bounded by `lines=` is still a full capture: rows kept, cursor restore last', async () => {
|
||||||
|
// Only how much history tmux reads changes. The row-preserving skips key
|
||||||
|
// on isFullCapture, and the relative cursor move must still end it.
|
||||||
|
const cursorRestore = '\x1b[3A\r\x1b[2C';
|
||||||
|
const capture = `\r\n${['first row', 'second row', 'last row'].join('\r\n')}${cursorRestore}`;
|
||||||
|
harness.ctx._session.mode = 'claude';
|
||||||
|
harness.ctx._session.terminalBuffer = 'byte history that must not be prepended';
|
||||||
|
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
|
||||||
|
opts?.fullHistory ? capture : 'only the visible frame'
|
||||||
|
);
|
||||||
|
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1&tail=${1024 * 1024}&lines=40`,
|
||||||
|
});
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.data.source).toBe('mux-full-history');
|
||||||
|
expect(body.data.terminalBuffer.startsWith('\r\nfirst row')).toBe(true);
|
||||||
|
expect(body.data.terminalBuffer.endsWith(`last row${cursorRestore}`)).toBe(true);
|
||||||
|
expect(body.data.terminalBuffer).not.toContain('byte history');
|
||||||
|
});
|
||||||
|
|
||||||
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
|
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
|
||||||
// The full-history capture is the rendered form of everything already in
|
// The full-history capture is the rendered form of everything already in
|
||||||
// the byte buffer; prepending the byte history would replay the whole
|
// the byte buffer; prepending the byte history would replay the whole
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
* skip, per-session change-detection (dedup), rebroadcast on a displayed change,
|
* skip, per-session change-detection (dedup), rebroadcast on a displayed change,
|
||||||
* NO rebroadcast on context-only drift, null-tolerance of Claude's undocumented
|
* NO rebroadcast on context-only drift, null-tolerance of Claude's undocumented
|
||||||
* fields (the .nullish() schema — the project's recurring .optional()/null trap),
|
* fields (the .nullish() schema — the project's recurring .optional()/null trap),
|
||||||
* and 400 on a malformed body.
|
* and 400 on a malformed body. Also the session's model (`displayModel`) the route
|
||||||
|
* records off the same payload.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||||
import { registerStatusTelemetryRoutes } from '../../src/web/routes/status-telemetry-routes.js';
|
import { registerStatusTelemetryRoutes } from '../../src/web/routes/status-telemetry-routes.js';
|
||||||
import { SessionStatusTelemetry } from '../../src/web/sse-events.js';
|
import { SessionStatusTelemetry } from '../../src/web/sse-events.js';
|
||||||
|
import { Session } from '../../src/session.js';
|
||||||
|
|
||||||
const SID = 'test-session-1'; // default id created by createMockRouteContext
|
const SID = 'test-session-1'; // default id created by createMockRouteContext
|
||||||
|
|
||||||
@@ -100,3 +102,56 @@ describe('POST /api/status-telemetry', () => {
|
|||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /api/status-telemetry: the session model (displayModel)', () => {
|
||||||
|
let h: RouteTestHarness;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
h = await createRouteTestHarness(registerStatusTelemetryRoutes, { sessionId: SID });
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = (body: unknown) => h.app.inject({ method: 'POST', url: '/api/status-telemetry', payload: body });
|
||||||
|
|
||||||
|
/** A real claude session in the route's map, so displayModel is the shipped resolver's. */
|
||||||
|
function realSession(mode: 'claude' | 'codex', id: string): Session {
|
||||||
|
const session = new Session({ id, workingDir: '/tmp', mode } as ConstructorParameters<typeof Session>[0]);
|
||||||
|
(h.ctx.sessions as unknown as Map<string, unknown>).set(id, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('records the model the statusline names, from the first render (no rate limits yet)', async () => {
|
||||||
|
const session = realSession('claude', 'claude-1');
|
||||||
|
const changed = vi.fn();
|
||||||
|
session.on('displayModelChanged', changed);
|
||||||
|
const res = await post({ sessionId: 'claude-1', data: { model: { display_name: 'Haiku 4.5' } } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(session.toState().displayModel).toEqual({ model: 'Haiku 4.5', source: 'statusline' });
|
||||||
|
expect(changed).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows a model switch, and says nothing for an unchanged one', async () => {
|
||||||
|
const session = realSession('claude', 'claude-2');
|
||||||
|
const changed = vi.fn();
|
||||||
|
session.on('displayModelChanged', changed);
|
||||||
|
await post({ sessionId: 'claude-2', data: REAL });
|
||||||
|
await post({ sessionId: 'claude-2', data: REAL });
|
||||||
|
expect(changed).toHaveBeenCalledTimes(1);
|
||||||
|
await post({ sessionId: 'claude-2', data: { ...REAL, model: { display_name: 'Sonnet 4.6' } } });
|
||||||
|
expect(session.toState().displayModel).toEqual({ model: 'Sonnet 4.6', source: 'statusline' });
|
||||||
|
expect(changed).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a model name with markup as text, and a null name changes nothing', async () => {
|
||||||
|
const session = realSession('claude', 'claude-3');
|
||||||
|
await post({ sessionId: 'claude-3', data: { model: { display_name: '<b>Opus</b>' } } });
|
||||||
|
expect(session.toState().displayModel?.model).toBe('<b>Opus</b>');
|
||||||
|
await post({ sessionId: 'claude-3', data: { model: { display_name: null } } });
|
||||||
|
expect(session.toState().displayModel?.model).toBe('<b>Opus</b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes no model from a CLI that has no statusline exporter', async () => {
|
||||||
|
const session = realSession('codex', 'codex-1');
|
||||||
|
await post({ sessionId: 'codex-1', data: { model: { display_name: 'Opus 4.8' } } });
|
||||||
|
expect(session.toState().displayModel).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Static guard for the Run menus' per-CLI logos (styles.css).
|
||||||
|
*
|
||||||
|
* Every launch surface (toolbar Run menu, phone overview picker, Custom Endpoint
|
||||||
|
* rows, model picker) renders `<span class="run-mode-dot <id>">`, and styles.css
|
||||||
|
* turns that slot into the CLI's logo through a `--run-mode-logo` data URI plus
|
||||||
|
* one of two paint groups: brand-coloured marks paint as a background image,
|
||||||
|
* monochrome marks as a mask over the row's text colour. Three ways that breaks
|
||||||
|
* silently, each pinned here:
|
||||||
|
* - a new stock CLI lands with no logo rule (its row shows a bare grey dot);
|
||||||
|
* - a logo rule exists but the id is in neither paint group (the variable is
|
||||||
|
* set and never painted, so the slot is a blank 15px gap);
|
||||||
|
* - a later `background:` shorthand on `.run-mode-dot.<id>` (e.g. a skin
|
||||||
|
* override like the ones the non-og block used to carry) resets
|
||||||
|
* background-image and wipes the logo.
|
||||||
|
*
|
||||||
|
* Port: none (pure static analysis).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||||
|
|
||||||
|
// Comments stripped: they sit between rules and carry commas, which would bleed
|
||||||
|
// into the selector lists split below.
|
||||||
|
const styles = readFileSync(resolve('src/web/public/styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
|
||||||
|
|
||||||
|
/** Every id a Run menu renders as `.run-mode-dot <id>`: the stock agents, Shell, and web tabs. */
|
||||||
|
const RUN_MENU_IDS = [
|
||||||
|
...STOCK_CLIS.filter((cli) => cli.kind === 'agent').map((cli) => cli.id as string),
|
||||||
|
'shell',
|
||||||
|
'web',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The selector list of the one rule whose body contains `marker`. */
|
||||||
|
function groupIds(marker: string): string[] {
|
||||||
|
const rule = [...styles.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter((m) => m[2].includes(marker));
|
||||||
|
expect(rule, `exactly one rule paints via "${marker}"`).toHaveLength(1);
|
||||||
|
return rule[0][1]
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().match(/^\.run-mode-dot\.([a-z0-9-]+)$/)?.[1])
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoSvg(id: string): string {
|
||||||
|
const m = styles.match(
|
||||||
|
new RegExp(`\\.run-mode-dot\\.${id} \\{ --run-mode-logo: url\\("data:image/svg\\+xml,([^"]+)"\\); \\}`)
|
||||||
|
);
|
||||||
|
expect(m, `.run-mode-dot.${id} has a --run-mode-logo rule`).not.toBeNull();
|
||||||
|
return decodeURIComponent(m![1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Run menu CLI logos', () => {
|
||||||
|
const colourIds = groupIds('background: var(--run-mode-logo)');
|
||||||
|
const maskIds = groupIds('mask: var(--run-mode-logo)');
|
||||||
|
|
||||||
|
it.each(RUN_MENU_IDS)('%s has a logo that one paint group draws', (id) => {
|
||||||
|
const svg = logoSvg(id);
|
||||||
|
expect(svg.startsWith('<svg')).toBe(true);
|
||||||
|
expect(svg).toMatch(/viewBox='[\d. ]+'/);
|
||||||
|
expect(colourIds.includes(id) !== maskIds.includes(id), `${id} is in exactly one paint group`).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines no logo that no surface renders, and no group member without a logo', () => {
|
||||||
|
expect([...colourIds, ...maskIds].sort()).toEqual([...RUN_MENU_IDS].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries no script or event handler inside a data URI', () => {
|
||||||
|
for (const id of RUN_MENU_IDS) {
|
||||||
|
const svg = logoSvg(id);
|
||||||
|
expect(svg).not.toMatch(/<script|\son[a-z]+=|javascript:|href=/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never resets a logo slot with a background shorthand outside the colour group', () => {
|
||||||
|
const offenders = [...styles.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter(
|
||||||
|
([, selector, body]) =>
|
||||||
|
/\.run-mode-dot\.[a-z]/.test(selector) &&
|
||||||
|
/(^|[;\s])background\s*:/.test(body) &&
|
||||||
|
!body.includes('var(--run-mode-logo)')
|
||||||
|
);
|
||||||
|
expect(offenders.map((m) => m[1].trim())).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* The config source of a session's `displayModel`: the model its CLI's own config pins
|
||||||
|
* (`capabilities.modelDetect.configResolver`), read at each pane start, attach or
|
||||||
|
* relaunch, ranked below any report from the running CLI.
|
||||||
|
*
|
||||||
|
* The reader is mocked here so the session's own rules are what is tested: when it
|
||||||
|
* reads, with what, and which answer wins. The reader itself (dsh-TUI's route) is
|
||||||
|
* `test/deepseek-route-config.test.ts`; one end-to-end read over a fixture dsh home
|
||||||
|
* is in `test/session-display-model.test.ts`.
|
||||||
|
*
|
||||||
|
* Port: N/A.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest';
|
||||||
|
|
||||||
|
const resolveConfigModel = vi.fn();
|
||||||
|
vi.mock('../src/model-config-resolvers.js', () => ({
|
||||||
|
resolveConfigModel: (...a: unknown[]) => resolveConfigModel(...a),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { Session } = await import('../src/session.js');
|
||||||
|
|
||||||
|
type Internals = {
|
||||||
|
_withPaneLifecycle<T>(op: () => Promise<T>): Promise<T>;
|
||||||
|
_isStopped: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A deferred answer, so a test decides when (and in which order) reads land. */
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: (v: string | null) => void;
|
||||||
|
const promise = new Promise<string | null>((r) => (resolve = r));
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(extra: Record<string, unknown> = {}, mode = 'deepseek') {
|
||||||
|
return new Session({ workingDir: '/tmp', mode, ...extra } as ConstructorParameters<typeof Session>[0]);
|
||||||
|
}
|
||||||
|
/** One pane start: what triggers the read. */
|
||||||
|
const start = (s: InstanceType<typeof Session>) => (s as unknown as Internals)._withPaneLifecycle(async () => {});
|
||||||
|
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resolveConfigModel.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a session's config model", () => {
|
||||||
|
it("is read at each pane start, with the session's own config and env", async () => {
|
||||||
|
resolveConfigModel.mockResolvedValue('qwen3.8-27b');
|
||||||
|
// The server has a dsh home of its own; the session's override is the one its CLI gets.
|
||||||
|
const before = process.env.DSH_HOME;
|
||||||
|
process.env.DSH_HOME = '/srv/server-dsh';
|
||||||
|
onTestFinished(() => {
|
||||||
|
if (before === undefined) delete process.env.DSH_HOME;
|
||||||
|
else process.env.DSH_HOME = before;
|
||||||
|
});
|
||||||
|
const s = session({ deepSeekConfig: { profile: 'dsh-tui' }, envOverrides: { DSH_HOME: '/srv/dsh-copy' } });
|
||||||
|
const changed = vi.fn();
|
||||||
|
s.on('displayModelChanged', changed);
|
||||||
|
expect(resolveConfigModel).not.toHaveBeenCalled();
|
||||||
|
await start(s);
|
||||||
|
await flush();
|
||||||
|
expect(resolveConfigModel).toHaveBeenCalledTimes(1);
|
||||||
|
const [name, ctx] = resolveConfigModel.mock.calls[0];
|
||||||
|
expect(name).toBe('deepseek-route');
|
||||||
|
expect(ctx.config).toEqual({ profile: 'dsh-tui' });
|
||||||
|
expect(ctx.env('DSH_HOME')).toBe('/srv/dsh-copy');
|
||||||
|
expect(s.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'config' });
|
||||||
|
expect(changed).toHaveBeenCalledTimes(1);
|
||||||
|
// A relaunch reads again; the same answer announces nothing new.
|
||||||
|
await start(s);
|
||||||
|
await flush();
|
||||||
|
expect(resolveConfigModel).toHaveBeenCalledTimes(2);
|
||||||
|
expect(changed).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without a session DSH_HOME the server one is what the CLI inherits', async () => {
|
||||||
|
resolveConfigModel.mockResolvedValue(null);
|
||||||
|
const before = process.env.DSH_HOME;
|
||||||
|
process.env.DSH_HOME = '/srv/server-dsh';
|
||||||
|
try {
|
||||||
|
const s = session();
|
||||||
|
await start(s);
|
||||||
|
expect(resolveConfigModel.mock.calls[0][1].env('DSH_HOME')).toBe('/srv/server-dsh');
|
||||||
|
} finally {
|
||||||
|
if (before === undefined) delete process.env.DSH_HOME;
|
||||||
|
else process.env.DSH_HOME = before;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the screen wins over the config whenever it names a model, in either order', async () => {
|
||||||
|
resolveConfigModel.mockResolvedValue('qwen3.8-27b');
|
||||||
|
const s = session();
|
||||||
|
await start(s);
|
||||||
|
await flush();
|
||||||
|
s.noteReportedModel('screen', 'deepseek-v4-flash');
|
||||||
|
expect(s.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' });
|
||||||
|
await start(s);
|
||||||
|
await flush();
|
||||||
|
expect(s.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a screen value restored after a restart outranks a fresh config read', async () => {
|
||||||
|
resolveConfigModel.mockResolvedValue('qwen3.8-27b');
|
||||||
|
const s = session({ displayModel: { model: 'deepseek-v4-flash', source: 'screen' } });
|
||||||
|
await start(s);
|
||||||
|
await flush();
|
||||||
|
expect(s.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the config outranks the launch model, and a config that pins nothing leaves it', async () => {
|
||||||
|
resolveConfigModel.mockResolvedValue('qwen-from-config');
|
||||||
|
const codex = session({ codexConfig: { model: 'gpt-5.5' } }, 'codex');
|
||||||
|
await start(codex);
|
||||||
|
await flush();
|
||||||
|
// codex declares no config reader: nothing is read, the launch model stays.
|
||||||
|
expect(resolveConfigModel).not.toHaveBeenCalled();
|
||||||
|
expect(codex.toState().displayModel).toEqual({ model: 'gpt-5.5', source: 'launch' });
|
||||||
|
resolveConfigModel.mockResolvedValue(null);
|
||||||
|
const s = session();
|
||||||
|
await start(s);
|
||||||
|
await flush();
|
||||||
|
expect(s.toState().displayModel).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a read that lands after a newer one is dropped, and so is one after the session stopped', async () => {
|
||||||
|
const first = deferred();
|
||||||
|
const second = deferred();
|
||||||
|
resolveConfigModel.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
|
||||||
|
const s = session();
|
||||||
|
await start(s);
|
||||||
|
await start(s);
|
||||||
|
second.resolve('newer');
|
||||||
|
await flush();
|
||||||
|
first.resolve('older');
|
||||||
|
await flush();
|
||||||
|
expect(s.toState().displayModel?.model).toBe('newer');
|
||||||
|
|
||||||
|
const late = deferred();
|
||||||
|
resolveConfigModel.mockReturnValueOnce(late.promise);
|
||||||
|
const stopped = session();
|
||||||
|
await start(stopped);
|
||||||
|
(stopped as unknown as Internals)._isStopped = true;
|
||||||
|
late.resolve('too late');
|
||||||
|
await flush();
|
||||||
|
expect(stopped.toState().displayModel).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a remote or docker session reads nothing locally', async () => {
|
||||||
|
resolveConfigModel.mockResolvedValue('qwen3.8-27b');
|
||||||
|
await start(session({ remote: { hostId: 'h', label: 'h', host: 'h', username: 'u', remotePath: '/w' } }));
|
||||||
|
await start(
|
||||||
|
session({
|
||||||
|
docker: {
|
||||||
|
hostId: 'd',
|
||||||
|
label: 'd',
|
||||||
|
engine: 'docker',
|
||||||
|
image: 'i',
|
||||||
|
containerName: 'c',
|
||||||
|
hostWorkspacePath: '/w',
|
||||||
|
containerWorkdir: '/w',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
expect(resolveConfigModel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a config model is untrusted text: control characters dropped, length capped', async () => {
|
||||||
|
resolveConfigModel.mockResolvedValue(`\x1b[31mqwen\x1b[0m${'x'.repeat(200)}`);
|
||||||
|
const s = session();
|
||||||
|
await start(s);
|
||||||
|
await flush();
|
||||||
|
const dm = s.toState().displayModel!;
|
||||||
|
expect(dm.source).toBe('config');
|
||||||
|
expect(dm.model.startsWith('qwenx')).toBe(true);
|
||||||
|
expect(dm.model.length).toBe(64);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
/**
|
||||||
|
* Which model a session runs (`SessionState.displayModel`), for the tile grid's and the
|
||||||
|
* split pane's headers: the pure resolver, the screen read with each CLI's registry
|
||||||
|
* pattern, and the session that feeds them.
|
||||||
|
*
|
||||||
|
* The pane fixtures are verbatim `capture-pane -p` rows (trailing blanks trimmed) from
|
||||||
|
* live panes on 2026-10-07: dsh-TUI 0.10.0-beta.1 on the owner's qwen route, and codex
|
||||||
|
* 0.147.0. The codex 0.154.0 footer is the one `session-watching.test.ts` pins.
|
||||||
|
*/
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Session } from '../src/session.js';
|
||||||
|
import { getCli } from '../src/config/cli-registry/index.js';
|
||||||
|
import { compileVersionRegex, countCaptureGroups } from '../src/config/cli-registry/patterns.js';
|
||||||
|
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
|
||||||
|
import {
|
||||||
|
MAX_DISPLAY_MODEL_CHARS,
|
||||||
|
launchModelFor,
|
||||||
|
readScreenModel,
|
||||||
|
resolveDisplayModel,
|
||||||
|
restoredReportedModel,
|
||||||
|
sanitizeModelName,
|
||||||
|
} from '../src/session-display-model.js';
|
||||||
|
import { IDLE_SILENCE_MS } from '../src/session-activity.js';
|
||||||
|
|
||||||
|
const detectOf = (mode: string) => getCli(mode)!.capabilities.modelDetect!;
|
||||||
|
const DSH = compileVersionRegex(detectOf('deepseek').screenLine)!;
|
||||||
|
const DSH_ROWS = detectOf('deepseek').screenLines;
|
||||||
|
const DSH_REJECT = detectOf('deepseek').rejectWords;
|
||||||
|
const CODEX = compileVersionRegex(detectOf('codex').screenLine)!;
|
||||||
|
const CODEX_ROWS = detectOf('codex').screenLines;
|
||||||
|
|
||||||
|
const DSH_BORDER_TOP = '╭' + '─'.repeat(95) + '╮';
|
||||||
|
const DSH_BORDER_BOTTOM = '╰' + '─'.repeat(95) + '╯';
|
||||||
|
const DSH_COMPOSER = '❯' + ' '.repeat(95) + '⛶';
|
||||||
|
/** The foot of a dsh-TUI pane: transcript, the composer's rounded box, the status line. */
|
||||||
|
function dshPane(statusLine: string | null, transcript: string[] = []): string {
|
||||||
|
return [
|
||||||
|
' Tip: Footer compact on = one merged line; off = metric…',
|
||||||
|
' Explore the uncharted!',
|
||||||
|
' ▶ (Ctrl+P to expand) Context loaded · System prompt 16 sections · Runtime context 2 items · …',
|
||||||
|
...transcript,
|
||||||
|
DSH_BORDER_TOP,
|
||||||
|
DSH_COMPOSER,
|
||||||
|
DSH_BORDER_BOTTOM,
|
||||||
|
...(statusLine === null ? [] : [statusLine]),
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
const DSH_LIVE = dshPane(' qwen3.8-27b · medium · th-scratch');
|
||||||
|
|
||||||
|
/** The foot of a codex pane: transcript, composer, then the status line on the last row. */
|
||||||
|
function codexPane(statusLine: string | null, transcript: string[] = []): string {
|
||||||
|
return [
|
||||||
|
'│ directory: ~/codeman-cases/th-scratch │',
|
||||||
|
'│ permissions: YOLO mode │',
|
||||||
|
'╰───────────────────────────────────────────────╯',
|
||||||
|
' Tip: New For a limited time, Codex is included in your',
|
||||||
|
' plan for free – let’s build together.',
|
||||||
|
...transcript,
|
||||||
|
'› Explain this codebase',
|
||||||
|
...(statusLine === null ? [] : [statusLine]),
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
const CODEX_LIVE = codexPane(' gpt-5.6-terra default · ~/codeman-cases/th-scratch');
|
||||||
|
const CODEX_154 = codexPane(
|
||||||
|
' gpt-5.6-sol medium · Context 98% left · ~/codeman-cases/codex-probe · 5h 99% left · weekly 94% left'
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('the registry patterns', () => {
|
||||||
|
it('compile through compileVersionRegex() with exactly one capture group', () => {
|
||||||
|
for (const mode of ['deepseek', 'codex']) {
|
||||||
|
const { screenLine } = detectOf(mode);
|
||||||
|
expect(compileVersionRegex(screenLine), mode).not.toBeNull();
|
||||||
|
expect(countCaptureGroups(screenLine), mode).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('are declared only where a footer was measured (claude reports through its statusline)', () => {
|
||||||
|
expect(getCli('claude')!.capabilities.modelDetect).toBeUndefined();
|
||||||
|
expect(getCli('shell')!.capabilities.modelDetect).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the schema refuses a pattern with no capture group, two of them, or one it will not run', () => {
|
||||||
|
const codex = getCli('codex')!;
|
||||||
|
const withDetect = (modelDetect: unknown) =>
|
||||||
|
CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success;
|
||||||
|
expect(withDetect({ screenLine: '^ {2}([a-z]+) · ' })).toBe(true);
|
||||||
|
expect(withDetect({ screenLine: '^ {2}[a-z]+ · ' })).toBe(false);
|
||||||
|
expect(withDetect({ screenLine: '^ {2}([a-z]+) (high) · ' })).toBe(false);
|
||||||
|
expect(withDetect({ screenLine: '(a+)+$' })).toBe(false);
|
||||||
|
expect(withDetect({ screenLine: '^(x)', screenLines: 9 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dsh declares what its footer's first field can be when it is not the model", () => {
|
||||||
|
// Every effort id dsh's adapters offer, and the shipped mode ids.
|
||||||
|
expect(DSH_REJECT).toEqual(
|
||||||
|
expect.arrayContaining(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'default', 'plan', 'full'])
|
||||||
|
);
|
||||||
|
expect(detectOf('codex').rejectWords).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the schema takes single-token reject words with a screenLine only, and bounds them', () => {
|
||||||
|
const codex = getCli('codex')!;
|
||||||
|
const withDetect = (modelDetect: unknown) =>
|
||||||
|
CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success;
|
||||||
|
const screenLine = '^ {2}([a-z]+) · ';
|
||||||
|
expect(withDetect({ screenLine, rejectWords: ['medium'] })).toBe(true);
|
||||||
|
expect(withDetect({ configResolver: 'deepseek-route', rejectWords: ['medium'] })).toBe(false);
|
||||||
|
expect(withDetect({ screenLine, rejectWords: ['two words'] })).toBe(false);
|
||||||
|
expect(withDetect({ screenLine, rejectWords: Array.from({ length: 33 }, (_, i) => `w${i}`) })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dsh also names a config reader for while its screen names no model', () => {
|
||||||
|
expect(detectOf('deepseek').configResolver).toBe('deepseek-route');
|
||||||
|
expect(detectOf('codex').configResolver).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the schema takes a known config reader alone, and refuses an empty or unknown one', () => {
|
||||||
|
const codex = getCli('codex')!;
|
||||||
|
const withDetect = (modelDetect: unknown) =>
|
||||||
|
CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success;
|
||||||
|
expect(withDetect({ configResolver: 'deepseek-route' })).toBe(true);
|
||||||
|
expect(withDetect({ configResolver: 'read-anything' })).toBe(false);
|
||||||
|
expect(withDetect({})).toBe(false);
|
||||||
|
expect(withDetect({ configResolver: 'deepseek-route', screenLines: 2 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('countCaptureGroups counts named groups and ignores non-capturing ones', () => {
|
||||||
|
expect(countCaptureGroups('a(?:b)(?<m>c)')).toBe(1);
|
||||||
|
expect(countCaptureGroups('(a)(b)')).toBe(2);
|
||||||
|
expect(countCaptureGroups('abc')).toBe(0);
|
||||||
|
expect(countCaptureGroups('(')).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readScreenModel', () => {
|
||||||
|
it("reads dsh's model off the row under its composer", () => {
|
||||||
|
expect(readScreenModel(DSH_LIVE, DSH, DSH_ROWS)).toBe('qwen3.8-27b');
|
||||||
|
// A footer with the model alone, and one with a row below it (the activity line).
|
||||||
|
expect(readScreenModel(dshPane(' deepseek-v4-flash'), DSH, DSH_ROWS)).toBe('deepseek-v4-flash');
|
||||||
|
expect(readScreenModel(dshPane(' qwen3.8-27b · medium · th-scratch') + ' ⠋ Thinking… 3s\n', DSH, DSH_ROWS)).toBe(
|
||||||
|
'qwen3.8-27b'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never reads the field after a switched-off model as the model (dsh's effort, mode, folder)", () => {
|
||||||
|
// dsh-TUI with `statusBar.model: false` (live capture: ` medium · th-config`): the
|
||||||
|
// effort id comes first, then the mode, then the folder name.
|
||||||
|
const at = (cwdBasename: string) => ({ rejectWords: DSH_REJECT, cwdBasename });
|
||||||
|
for (const effort of ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) {
|
||||||
|
expect(readScreenModel(dshPane(` ${effort} · x`), DSH, DSH_ROWS, at('x')), effort).toBeUndefined();
|
||||||
|
}
|
||||||
|
expect(readScreenModel(dshPane(' default · x'), DSH, DSH_ROWS, at('x'))).toBeUndefined();
|
||||||
|
// The words compare ignoring case (the banner capitalizes effort; the footer does not).
|
||||||
|
expect(readScreenModel(dshPane(' Medium · x'), DSH, DSH_ROWS, at('x'))).toBeUndefined();
|
||||||
|
// A mode's drawn label is two words: never one field the pattern takes.
|
||||||
|
expect(readScreenModel(dshPane(' plan mode · x'), DSH, DSH_ROWS, at('x'))).toBeUndefined();
|
||||||
|
// The session's own folder name first, with and without a digit.
|
||||||
|
expect(readScreenModel(dshPane(' th-config'), DSH, DSH_ROWS, at('th-config'))).toBeUndefined();
|
||||||
|
expect(readScreenModel(dshPane(' project2 · main'), DSH, DSH_ROWS, at('project2'))).toBeUndefined();
|
||||||
|
// A field that is none of those IS read: the same `project2` in another folder.
|
||||||
|
expect(readScreenModel(dshPane(' project2'), DSH, DSH_ROWS, at('elsewhere'))).toBe('project2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the official DeepSeek ids, which carry no digit, with the model field on', () => {
|
||||||
|
const at = { rejectWords: DSH_REJECT, cwdBasename: 'th-config' };
|
||||||
|
expect(readScreenModel(dshPane(' deepseek-chat · max · th-config'), DSH, DSH_ROWS, at)).toBe('deepseek-chat');
|
||||||
|
expect(readScreenModel(dshPane(' deepseek-reasoner · high · th-config'), DSH, DSH_ROWS, at)).toBe(
|
||||||
|
'deepseek-reasoner'
|
||||||
|
);
|
||||||
|
// The live qwen footer still reads.
|
||||||
|
expect(readScreenModel(dshPane(' qwen3.8-27b · medium · th-scratch'), DSH, DSH_ROWS, at)).toBe('qwen3.8-27b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads codex's model off its status line (0.147.0 and 0.154.0 layouts)", () => {
|
||||||
|
expect(readScreenModel(CODEX_LIVE, CODEX, CODEX_ROWS)).toBe('gpt-5.6-terra');
|
||||||
|
expect(readScreenModel(CODEX_154, CODEX, CODEX_ROWS)).toBe('gpt-5.6-sol');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never takes a transcript line shaped like the footer', () => {
|
||||||
|
// The agent printed a line exactly like each CLI's footer, and the real footer is
|
||||||
|
// hidden (a dsh status bar switched off; a codex popup over its last row). The
|
||||||
|
// transcript sits above the composer, so neither may be read as the model.
|
||||||
|
const dshForged = dshPane(null, [DSH_BORDER_BOTTOM, ' evil-model · medium · th-scratch']);
|
||||||
|
expect(readScreenModel(dshForged, DSH, DSH_ROWS)).toBeUndefined();
|
||||||
|
const codexForged = codexPane(null, [' evil-model high · ~/codeman-cases/th-scratch']);
|
||||||
|
// A codex screen whose last row is the composer: nothing to read.
|
||||||
|
expect(readScreenModel(codexForged, CODEX, CODEX_ROWS)).toBeUndefined();
|
||||||
|
// With the real footer back, the real model wins over the forged line above it.
|
||||||
|
const dshBoth = dshPane(' qwen3.8-27b · medium · th-scratch', [
|
||||||
|
DSH_BORDER_BOTTOM,
|
||||||
|
' evil-model · medium · th-scratch',
|
||||||
|
]);
|
||||||
|
expect(readScreenModel(dshBoth, DSH, DSH_ROWS)).toBe('qwen3.8-27b');
|
||||||
|
const codexBoth = codexPane(' gpt-5.6-terra default · ~/codeman-cases/th-scratch', [
|
||||||
|
' evil-model high · ~/codeman-cases/th-scratch',
|
||||||
|
]);
|
||||||
|
expect(readScreenModel(codexBoth, CODEX, CODEX_ROWS)).toBe('gpt-5.6-terra');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not read a popup under the composer as a model', () => {
|
||||||
|
const slash = dshPane(' /model Switch the model route');
|
||||||
|
expect(readScreenModel(slash, DSH, DSH_ROWS)).toBeUndefined();
|
||||||
|
const codexSlash = codexPane(' /model choose what model and reasoning effort to use');
|
||||||
|
expect(readScreenModel(codexSlash, CODEX, CODEX_ROWS)).toBeUndefined();
|
||||||
|
// A last row without codex's `<model> <effort> ·` shape names no model, even one
|
||||||
|
// whose first word could pass for a model id.
|
||||||
|
const codexNoModel = codexPane(' default · ~/codeman-cases/th-scratch');
|
||||||
|
expect(readScreenModel(codexNoModel, CODEX, CODEX_ROWS)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing about an empty or unreadable frame', () => {
|
||||||
|
expect(readScreenModel('', DSH, DSH_ROWS)).toBeUndefined();
|
||||||
|
expect(readScreenModel(null, CODEX, CODEX_ROWS)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeModelName', () => {
|
||||||
|
it('keeps markup as text: sanitizing is not escaping, the browser renders text', () => {
|
||||||
|
expect(sanitizeModelName('<img src=x onerror=alert(1)>')).toBe('<img src=x onerror=alert(1)>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops escape sequences and control characters, and collapses whitespace', () => {
|
||||||
|
expect(sanitizeModelName('\x1b[31mOpus\x1b[0m\t 4.8\n')).toBe('Opus 4.8');
|
||||||
|
expect(sanitizeModelName('\x00\x07')).toBeUndefined();
|
||||||
|
expect(sanitizeModelName(' ')).toBeUndefined();
|
||||||
|
expect(sanitizeModelName(42)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`caps the name at ${MAX_DISPLAY_MODEL_CHARS} characters`, () => {
|
||||||
|
expect(sanitizeModelName('m'.repeat(500))).toHaveLength(MAX_DISPLAY_MODEL_CHARS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveDisplayModel', () => {
|
||||||
|
const reported = { model: 'Sonnet 4.6', source: 'statusline' as const };
|
||||||
|
|
||||||
|
it('the custom endpoint wins, then the newest report, then the launch model', () => {
|
||||||
|
expect(resolveDisplayModel({ customModelId: 'qwen3.8-27b', reported, launchModel: 'opus' })).toEqual({
|
||||||
|
model: 'qwen3.8-27b',
|
||||||
|
source: 'custom-endpoint',
|
||||||
|
});
|
||||||
|
expect(resolveDisplayModel({ reported, launchModel: 'opus' })).toEqual(reported);
|
||||||
|
expect(resolveDisplayModel({ launchModel: 'opus' })).toEqual({ model: 'opus', source: 'launch' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the config ranks below the CLI's own report and above the launch model", () => {
|
||||||
|
expect(resolveDisplayModel({ reported, configModel: 'qwen3.8-27b', launchModel: 'opus' })).toEqual(reported);
|
||||||
|
expect(resolveDisplayModel({ configModel: 'qwen3.8-27b', launchModel: 'opus' })).toEqual({
|
||||||
|
model: 'qwen3.8-27b',
|
||||||
|
source: 'config',
|
||||||
|
});
|
||||||
|
expect(resolveDisplayModel({ customModelId: 'm', configModel: 'qwen3.8-27b' })?.source).toBe('custom-endpoint');
|
||||||
|
expect(resolveDisplayModel({ configModel: null, launchModel: 'opus' })?.source).toBe('launch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('knows nothing when nothing is known: no placeholder', () => {
|
||||||
|
expect(resolveDisplayModel({})).toBeUndefined();
|
||||||
|
expect(resolveDisplayModel({ customModelId: ' ', reported: null, launchModel: '' })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restoredReportedModel', () => {
|
||||||
|
it('restores what the CLI reported, never a derived answer', () => {
|
||||||
|
expect(restoredReportedModel({ model: 'qwen3.8-27b', source: 'screen' })).toEqual({
|
||||||
|
model: 'qwen3.8-27b',
|
||||||
|
source: 'screen',
|
||||||
|
});
|
||||||
|
expect(restoredReportedModel({ model: 'Opus 4.8', source: 'statusline' })?.source).toBe('statusline');
|
||||||
|
expect(restoredReportedModel({ model: 'opus', source: 'launch' })).toBeUndefined();
|
||||||
|
expect(restoredReportedModel({ model: 'qwen3.8-27b', source: 'config' })).toBeUndefined();
|
||||||
|
expect(restoredReportedModel({ model: 'x', source: 'custom-endpoint' })).toBeUndefined();
|
||||||
|
expect(restoredReportedModel({ model: '', source: 'screen' })).toBeUndefined();
|
||||||
|
expect(restoredReportedModel('screen')).toBeUndefined();
|
||||||
|
expect(restoredReportedModel(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('launchModelFor', () => {
|
||||||
|
it("reads the model param where each CLI's registry entry keeps it", () => {
|
||||||
|
expect(launchModelFor('claude', { model: 'haiku' })).toBe('haiku');
|
||||||
|
expect(launchModelFor('codex', { codexConfig: { model: 'gpt-5.5' } })).toBe('gpt-5.5');
|
||||||
|
expect(launchModelFor('grok', { grokConfig: { model: 'grok-code-fast' } })).toBe('grok-code-fast');
|
||||||
|
// Another CLI's config, or a top-level model, is not this CLI's launch model.
|
||||||
|
expect(launchModelFor('codex', { model: 'opus', grokConfig: { model: 'x' } })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has none for a CLI whose model is not a launch param', () => {
|
||||||
|
expect(launchModelFor('deepseek', { model: 'opus', deepSeekConfig: { profile: 'dsh-tui' } })).toBeUndefined();
|
||||||
|
expect(launchModelFor('shell', { model: 'opus' })).toBeUndefined();
|
||||||
|
expect(launchModelFor('no-such-cli', { model: 'opus' })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a session', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
type Internals = {
|
||||||
|
_handleTerminalOutput(data: string): void;
|
||||||
|
_detectInteractiveActivity(data: string): void;
|
||||||
|
_customModel?: { endpointId: string; modelId: string; envKeys: string[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
function withFakePane(mode: string, screen: () => string | null, extra: Record<string, unknown> = {}): Session {
|
||||||
|
const mux = { isAvailable: () => true, capturePaneText: () => screen() } as unknown as NonNullable<
|
||||||
|
ConstructorParameters<typeof Session>[0]
|
||||||
|
>['mux'];
|
||||||
|
return new Session({
|
||||||
|
workingDir: '/tmp',
|
||||||
|
mode,
|
||||||
|
mux,
|
||||||
|
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
|
||||||
|
...extra,
|
||||||
|
} as ConstructorParameters<typeof Session>[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A composer repaint (arms the idle confirmation), then quiet: the probe reads the screen. */
|
||||||
|
function settle(session: Session, glyph: string): void {
|
||||||
|
const internals = session as unknown as Internals;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const frame = `\x1b[31;1H${glyph}\xa0`;
|
||||||
|
internals._handleTerminalOutput(frame);
|
||||||
|
internals._detectInteractiveActivity(frame);
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
}
|
||||||
|
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('publishes the model its footer names, and follows the footer when it changes', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let screen = DSH_LIVE;
|
||||||
|
const session = withFakePane('deepseek', () => screen);
|
||||||
|
const changed = vi.fn();
|
||||||
|
session.on('displayModelChanged', changed);
|
||||||
|
expect(session.toState().displayModel).toBeUndefined();
|
||||||
|
settle(session, '❯');
|
||||||
|
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'screen' });
|
||||||
|
expect(changed).toHaveBeenCalledTimes(1);
|
||||||
|
// The same footer again: nothing new to say.
|
||||||
|
settle(session, '❯');
|
||||||
|
expect(changed).toHaveBeenCalledTimes(1);
|
||||||
|
// An in-session switch redraws the footer.
|
||||||
|
screen = dshPane(' deepseek-v4-flash · high · th-scratch');
|
||||||
|
settle(session, '❯');
|
||||||
|
expect(session.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' });
|
||||||
|
expect(changed).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a footer field equal to the session's folder is not its model; the official ids are", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let screen = dshPane(' th-config');
|
||||||
|
const session = withFakePane('deepseek', () => screen, { workingDir: '/w/th-config' });
|
||||||
|
settle(session, '❯');
|
||||||
|
expect(session.toState().displayModel).toBeUndefined();
|
||||||
|
screen = dshPane(' medium · th-config');
|
||||||
|
settle(session, '❯');
|
||||||
|
expect(session.toState().displayModel).toBeUndefined();
|
||||||
|
screen = dshPane(' deepseek-chat · max · th-config');
|
||||||
|
settle(session, '❯');
|
||||||
|
expect(session.toState().displayModel).toEqual({ model: 'deepseek-chat', source: 'screen' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the last model when the footer cannot be read', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let screen: string | null = CODEX_LIVE;
|
||||||
|
const session = withFakePane('codex', () => screen);
|
||||||
|
settle(session, '›');
|
||||||
|
expect(session.toState().displayModel?.model).toBe('gpt-5.6-terra');
|
||||||
|
screen = codexPane(' /model choose what model and reasoning effort to use');
|
||||||
|
settle(session, '›');
|
||||||
|
screen = null;
|
||||||
|
settle(session, '›');
|
||||||
|
expect(session.toState().displayModel).toEqual({ model: 'gpt-5.6-terra', source: 'screen' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a CLI without a footer pattern never reads one (a claude pane says nothing)', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const session = withFakePane('claude', () => '❯ \n Haiku 4.5 in:0 out:0\n');
|
||||||
|
settle(session, '❯');
|
||||||
|
expect(session.toState().displayModel).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the launch model until the CLI reports, and a custom endpoint over both', () => {
|
||||||
|
const session = withFakePane('claude', () => null, { model: 'haiku' });
|
||||||
|
expect(session.toState().displayModel).toEqual({ model: 'haiku', source: 'launch' });
|
||||||
|
expect(session.noteReportedModel('statusline', 'Haiku 4.5')).toBe(true);
|
||||||
|
expect(session.toState().displayModel).toEqual({ model: 'Haiku 4.5', source: 'statusline' });
|
||||||
|
expect(session.noteReportedModel('statusline', 'Haiku 4.5')).toBe(false);
|
||||||
|
expect(session.noteReportedModel('statusline', '')).toBe(false);
|
||||||
|
(session as unknown as Internals)._customModel = { endpointId: 'e', modelId: 'qwen3.8-27b', envKeys: [] };
|
||||||
|
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'custom-endpoint' });
|
||||||
|
(session as unknown as Internals)._customModel = undefined;
|
||||||
|
expect(session.toState().displayModel?.model).toBe('Haiku 4.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an external CLI's launch model is its own config's, never the inert top-level one", () => {
|
||||||
|
const codex = withFakePane('codex', () => null, { model: 'opus', codexConfig: { model: 'gpt-5.5' } });
|
||||||
|
expect(codex.toState().displayModel).toEqual({ model: 'gpt-5.5', source: 'launch' });
|
||||||
|
const dsh = withFakePane('deepseek', () => null, { model: 'opus', deepSeekConfig: { profile: 'dsh-tui' } });
|
||||||
|
expect(dsh.toState().displayModel).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores a reported model after a restart, until the next report replaces it', () => {
|
||||||
|
const session = withFakePane('deepseek', () => null, {
|
||||||
|
displayModel: { model: 'qwen3.8-27b', source: 'screen' },
|
||||||
|
});
|
||||||
|
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'screen' });
|
||||||
|
session.noteReportedModel('screen', 'deepseek-v4-flash');
|
||||||
|
expect(session.toState().displayModel?.model).toBe('deepseek-v4-flash');
|
||||||
|
// A launch answer from the previous run is derived again, not restored.
|
||||||
|
const claude = withFakePane('claude', () => null, { displayModel: { model: 'opus', source: 'launch' } });
|
||||||
|
expect(claude.toState().displayModel).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a dsh session over a fixture dsh home (end to end, no mocks)', () => {
|
||||||
|
it('names the route its profile pins until the screen names one, then the screen', async () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'dsh-e2e-'));
|
||||||
|
try {
|
||||||
|
const home = join(root, 'dsh');
|
||||||
|
mkdirSync(join(home, 'profiles', 'dsh-tui'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(home, 'profiles', 'dsh-tui', 'package.json'),
|
||||||
|
JSON.stringify({ dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-harness-tui/dsh-tui'] } } })
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(home, 'profiles', 'dsh-tui', 'cordis.patch.yml'),
|
||||||
|
'- id: dsh-tui\n config:\n provider: qwen5090\n model: qwen3.8-27b\n'
|
||||||
|
);
|
||||||
|
const session = new Session({
|
||||||
|
workingDir: '/tmp',
|
||||||
|
mode: 'deepseek',
|
||||||
|
deepSeekConfig: { profile: 'dsh-tui' },
|
||||||
|
envOverrides: { DSH_HOME: home },
|
||||||
|
} as ConstructorParameters<typeof Session>[0]);
|
||||||
|
await (session as unknown as { _withPaneLifecycle(op: () => Promise<void>): Promise<void> })._withPaneLifecycle(
|
||||||
|
async () => {}
|
||||||
|
);
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'config' })
|
||||||
|
);
|
||||||
|
session.noteReportedModel('screen', 'deepseek-v4-flash');
|
||||||
|
expect(session.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' });
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+330
-610
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { Session } from '../src/session.js';
|
import { Session } from '../src/session.js';
|
||||||
import { createSessionListeners } from '../src/web/session-listener-wiring.js';
|
import {
|
||||||
|
attachSessionListeners,
|
||||||
|
createSessionListeners,
|
||||||
|
detachSessionListeners,
|
||||||
|
} from '../src/web/session-listener-wiring.js';
|
||||||
import { SseEvent } from '../src/web/sse-events.js';
|
import { SseEvent } from '../src/web/sse-events.js';
|
||||||
|
|
||||||
describe('session listener wiring', () => {
|
describe('session listener wiring', () => {
|
||||||
@@ -37,6 +41,27 @@ describe('session listener wiring', () => {
|
|||||||
expect(broadcastSessionStateDebounced).toHaveBeenCalledWith('wiring-watching-test');
|
expect(broadcastSessionStateDebounced).toHaveBeenCalledWith('wiring-watching-test');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pushes and persists the session state when the reported model changes', () => {
|
||||||
|
// A model switch changes nothing the status reflects, so without its own broadcast
|
||||||
|
// every header would keep naming the old model; persisting it is what lets a restart
|
||||||
|
// restore it.
|
||||||
|
const session = new Session({ id: 'wiring-model-test', workingDir: '/tmp', mode: 'claude' });
|
||||||
|
const broadcastSessionStateDebounced = vi.fn();
|
||||||
|
const persistSessionState = vi.fn();
|
||||||
|
const deps = { broadcastSessionStateDebounced, persistSessionState } as unknown as Parameters<
|
||||||
|
typeof createSessionListeners
|
||||||
|
>[1];
|
||||||
|
|
||||||
|
const refs = createSessionListeners(session, deps);
|
||||||
|
attachSessionListeners(session, refs);
|
||||||
|
session.noteReportedModel('statusline', 'Sonnet 4.6');
|
||||||
|
|
||||||
|
expect(broadcastSessionStateDebounced).toHaveBeenCalledWith('wiring-model-test');
|
||||||
|
expect(persistSessionState).toHaveBeenCalledWith(session);
|
||||||
|
detachSessionListeners(session, refs);
|
||||||
|
expect(session.listenerCount('displayModelChanged')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
/** The listener reads the setting asynchronously; let its promise chain settle. */
|
/** The listener reads the setting asynchronously; let its promise chain settle. */
|
||||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
|
||||||
|
|||||||
@@ -92,10 +92,27 @@ describe('the exited-agent badge in a tab', () => {
|
|||||||
expect(tab.querySelector('.tab-name')?.nextElementSibling?.className).toBe('tab-exited-badge');
|
expect(tab.querySelector('.tab-name')?.nextElementSibling?.className).toBe('tab-exited-badge');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks the badge data-i18n-skip, like the other generated status text', () => {
|
it('leaves the badge to the translator (zh-CN) and remembers its English', () => {
|
||||||
const tab = makeTab();
|
const tab = makeTab();
|
||||||
applyPaneExitBadge(tab, { status: 0, at: 1 });
|
applyPaneExitBadge(tab, { status: 0, at: 1 });
|
||||||
expect(badge(tab)?.hasAttribute('data-i18n-skip')).toBe(true);
|
expect(badge(tab)?.hasAttribute('data-i18n-skip')).toBe(false);
|
||||||
|
expect((badge(tab) as unknown as HTMLElement).dataset.label).toBe('exited (0)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a translated badge and accessible name survive an unchanged pass; a change writes the new English', () => {
|
||||||
|
// The translator rewrites both in zh-CN. Comparing with the DOM would write
|
||||||
|
// the English back on every incremental pass for it to translate again.
|
||||||
|
const tab = makeTab();
|
||||||
|
applyPaneExitBadge(tab, { status: 3, at: 1 });
|
||||||
|
const el = badge(tab) as unknown as HTMLElement;
|
||||||
|
el.textContent = '已退出(3)';
|
||||||
|
tab.setAttribute('aria-label', 'w1-case 会话,智能体已退出(3)');
|
||||||
|
applyPaneExitBadge(tab, { status: 3, at: 1 });
|
||||||
|
expect(el.textContent).toBe('已退出(3)');
|
||||||
|
expect(tab.getAttribute('aria-label')).toBe('w1-case 会话,智能体已退出(3)');
|
||||||
|
applyPaneExitBadge(tab, { signal: 9, at: 2 });
|
||||||
|
expect(el.textContent).toBe('exited (signal 9)');
|
||||||
|
expect(tab.getAttribute('aria-label')).toBe('w1-case session, agent exited (signal 9)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides the badge from assistive technology, like its sibling badges', () => {
|
it('hides the badge from assistive technology, like its sibling badges', () => {
|
||||||
@@ -115,8 +132,12 @@ describe('the exited-agent badge in a tab', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('builds the full render path accessible name from the same helper', () => {
|
it('builds the full render path accessible name from the same helper', () => {
|
||||||
expect(appJs).toContain('aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}"');
|
expect(appJs).toContain(
|
||||||
expect(appJs).toContain('<span class="tab-exited-badge" data-i18n-skip aria-hidden="true">');
|
'aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" data-aria-source="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}"'
|
||||||
|
);
|
||||||
|
expect(appJs).toContain(
|
||||||
|
'<span class="tab-exited-badge" data-label="${escapeHtml(paneExitBadge)}" aria-hidden="true">'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates the text in place rather than stacking a second badge', () => {
|
it('updates the text in place rather than stacking a second badge', () => {
|
||||||
|
|||||||
@@ -110,6 +110,60 @@ describe('selectSession acknowledgement gate', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('with the tile grid open (tile-grid.js)', () => {
|
||||||
|
// A plain stand-in for the grid: the branch reads only `open` and `has(id)`.
|
||||||
|
function withGrid(app: ReturnType<typeof makeApp>, tiled: string[]) {
|
||||||
|
const a = app as Record<string, unknown>;
|
||||||
|
a._tileGrid = { open: true, has: (id: string) => tiled.includes(id) };
|
||||||
|
a._selectTiledSession = vi.fn();
|
||||||
|
a.closeTileGrid = vi.fn();
|
||||||
|
a._connectWs = vi.fn();
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('a tiled session goes to its tile, never the main terminal, and the tile decides the ack', async () => {
|
||||||
|
const app = withGrid(makeApp('other'), [SID]);
|
||||||
|
await (app.selectSession as (id: string) => Promise<void>)(SID);
|
||||||
|
expect(app._selectTiledSession).toHaveBeenCalledWith(SID, {});
|
||||||
|
expect(app._cleanupPreviousSession).not.toHaveBeenCalled();
|
||||||
|
expect(app._connectWs).not.toHaveBeenCalled();
|
||||||
|
// The branch returns before the main path's own acknowledgement line.
|
||||||
|
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes `auto` through, so an app-driven focus stays unacknowledged', async () => {
|
||||||
|
const app = withGrid(makeApp('other'), [SID]);
|
||||||
|
await (app.selectSession as (id: string, o: object) => Promise<void>)(SID, { auto: true });
|
||||||
|
expect(app._selectTiledSession).toHaveBeenCalledWith(SID, { auto: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an `auto` selection of a session that is not tiled leaves the grid open and acknowledges nothing', async () => {
|
||||||
|
const app = withGrid(makeApp('other'), []);
|
||||||
|
await (app.selectSession as (id: string, o: object) => Promise<void>)(SID, { auto: true });
|
||||||
|
expect(app.closeTileGrid).not.toHaveBeenCalled();
|
||||||
|
expect(app._cleanupPreviousSession).not.toHaveBeenCalled();
|
||||||
|
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a user-initiated one leaves the grid (remembered) and switches normally, acknowledging', async () => {
|
||||||
|
const app = withGrid(makeApp('other'), []);
|
||||||
|
await (app.selectSession as (id: string) => Promise<void>)(SID).catch(() => {});
|
||||||
|
expect(app.closeTileGrid).toHaveBeenCalledWith({ keepStored: true, reselect: false });
|
||||||
|
expect(app._cleanupPreviousSession).toHaveBeenCalled();
|
||||||
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith(SID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('`leaveTiles` leaves it too, without acknowledging an `auto` pick', async () => {
|
||||||
|
const app = withGrid(makeApp('other'), []);
|
||||||
|
await (app.selectSession as (id: string, o: object) => Promise<void>)(SID, {
|
||||||
|
auto: true,
|
||||||
|
leaveTiles: true,
|
||||||
|
}).catch(() => {});
|
||||||
|
expect(app.closeTileGrid).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('the call sites the app drives itself', () => {
|
describe('the call sites the app drives itself', () => {
|
||||||
// Source guard: these call sites are the reason the flag exists. If a refactor
|
// Source guard: these call sites are the reason the flag exists. If a refactor
|
||||||
// moves or reformats them, fail loudly rather than silently going back to
|
// moves or reformats them, fail loudly rather than silently going back to
|
||||||
@@ -119,7 +173,7 @@ describe('selectSession acknowledgement gate', () => {
|
|||||||
['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'],
|
['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'],
|
||||||
['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'],
|
['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'],
|
||||||
['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'],
|
['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'],
|
||||||
['a #session=<id> link from another page', 'this.selectSession(id, { auto: true });'],
|
['a #session=<id> link from another page', 'this.selectSession(id, { auto: true, leaveTiles: true });'],
|
||||||
])('%s passes auto: true', (_label, call) => {
|
])('%s passes auto: true', (_label, call) => {
|
||||||
expect(APP_SOURCE).toContain(call);
|
expect(APP_SOURCE).toContain(call);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
const PUBLIC = join(new URL('.', import.meta.url).pathname, '../src/web/public');
|
const PUBLIC = join(new URL('.', import.meta.url).pathname, '../src/web/public');
|
||||||
|
|
||||||
describe.each(['terminal-ui.js', 'terminal-split.js'])('%s Shift/Ctrl+Enter handler', (file) => {
|
describe.each(['terminal-ui.js', 'terminal-tile.js'])('%s Shift/Ctrl+Enter handler', (file) => {
|
||||||
const src = readFileSync(join(PUBLIC, file), 'utf8');
|
const src = readFileSync(join(PUBLIC, file), 'utf8');
|
||||||
|
|
||||||
it('swallows every event type for Shift/Ctrl+Enter', () => {
|
it('swallows every event type for Shift/Ctrl+Enter', () => {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
* worked all along because Chromium fires no keypress for it.
|
* worked all along because Chromium fires no keypress for it.
|
||||||
*
|
*
|
||||||
* The page is the real app served by a real WebServer, so the handlers under test are the ones
|
* The page is the real app served by a real WebServer, so the handlers under test are the ones
|
||||||
* terminal-ui.js (the main pane, `app.terminal`) and terminal-split.js (Pane B, a real
|
* terminal-ui.js (the main pane, `app.terminal`) and terminal-tile.js (Pane B, a real
|
||||||
* `SplitTerminalPane`) attach. Nothing restates their predicate. What stands in for the server is
|
* `TerminalTile`) attach. Nothing restates their predicate. What stands in for the server is
|
||||||
* only the edge: a fetch wrapper records the send-key POSTs instead of letting them reach tmux, and
|
* only the edge: a fetch wrapper records the send-key POSTs instead of letting them reach tmux, and
|
||||||
* no session exists behind the ids, so nothing is ever typed into a real pane.
|
* no session exists behind the ids, so nothing is ever typed into a real pane.
|
||||||
*
|
*
|
||||||
@@ -74,7 +74,7 @@ describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be sw
|
|||||||
const mount = document.createElement('div');
|
const mount = document.createElement('div');
|
||||||
mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;';
|
mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;';
|
||||||
document.body.appendChild(mount);
|
document.body.appendChild(mount);
|
||||||
const pane = new w.SplitTerminalPane(paneBId, mount, { mode: 'claude' });
|
const pane = new w.TerminalTile(paneBId, mount, { mode: 'claude' });
|
||||||
void pane.connect().catch(() => {});
|
void pane.connect().catch(() => {});
|
||||||
// What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket.
|
// What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket.
|
||||||
w.__paneBData = [] as string[];
|
w.__paneBData = [] as string[];
|
||||||
@@ -200,7 +200,7 @@ describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be sw
|
|||||||
expect(shift.sendKeys).toEqual([]);
|
expect(shift.sendKeys).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Pane B (terminal-split.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key once for Pane B's own session", async () => {
|
it("Pane B (terminal-tile.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key once for Pane B's own session", async () => {
|
||||||
const shift = await pressIn('paneB', 'Shift+Enter');
|
const shift = await pressIn('paneB', 'Shift+Enter');
|
||||||
expect(shift.focused).toBe(true);
|
expect(shift.focused).toBe(true);
|
||||||
expect(shift.data).toEqual([]);
|
expect(shift.data).toEqual([]);
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Each session in the split view names its harness and model.
|
||||||
|
*
|
||||||
|
* - Pane B's header is the tile header's strip: the harness logo (PR #532's
|
||||||
|
* `run-mode-dot <cliId>` slot), the name and the model, and the close button
|
||||||
|
* at a tile button's size.
|
||||||
|
* - Pane A is the main terminal, which has no header of its own: while the
|
||||||
|
* split is open it gets the same strip (minus the close), as the FIRST child
|
||||||
|
* of `.terminal-wrap`, and loses it when the split closes. That strip takes
|
||||||
|
* height from the main terminal, so opening fits it through sendResize (its
|
||||||
|
* first step is syncTerminalGeometry, #464) with the strip already in place,
|
||||||
|
* and closing gives the height back the same way, never with a bare
|
||||||
|
* `fitAddon.fit()`.
|
||||||
|
* - Every tab render refreshes both headers (a rename, a model switch, the
|
||||||
|
* active session changing under Pane A); an unchanged session writes nothing.
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
FakeTile,
|
||||||
|
bySelector,
|
||||||
|
main,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
windowStub,
|
||||||
|
wrap,
|
||||||
|
type GridApp,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c'];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
windowStub.__codemanCliCatalog = [
|
||||||
|
{ id: 'claude', label: 'Claude Code' },
|
||||||
|
{ id: 'deepseek', label: 'DeepSeek' },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
/** s-a (dsh on its route) in the main pane, s-b (claude) in Pane B. */
|
||||||
|
function openSplit(): GridApp {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
Object.assign(app.sessions.get('s-a'), {
|
||||||
|
name: 'review-api',
|
||||||
|
mode: 'deepseek',
|
||||||
|
displayModel: { model: 'qwen3.8-27b', source: 'screen' },
|
||||||
|
});
|
||||||
|
Object.assign(app.sessions.get('s-b'), {
|
||||||
|
name: 'docs-pass',
|
||||||
|
displayModel: { model: 'Haiku 4.5', source: 'statusline' },
|
||||||
|
});
|
||||||
|
app.syncTerminalGeometry = vi.fn();
|
||||||
|
app.fitAddon = { fit: vi.fn() };
|
||||||
|
app.openSplitPane('s-b');
|
||||||
|
// closeSplitPane finds its container by selector.
|
||||||
|
const container = main.querySelector('.terminal-split-container');
|
||||||
|
if (container) bySelector.set('.terminal-split-container', container);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerB = () => main.querySelector('.terminal-pane-b')!.querySelector('.terminal-pane-b-header') as FakeEl;
|
||||||
|
const headerA = () => wrap.querySelector('.terminal-pane-a-header');
|
||||||
|
const part = (header: FakeEl, cls: string) => header.querySelector(`.${cls}`) as FakeEl;
|
||||||
|
const modelName = (header: FakeEl) => part(header, 'split-model').children[0];
|
||||||
|
/** A tab render, through the real wrapper chain (the original returns early mid-rename). */
|
||||||
|
function renderTabs(app: GridApp) {
|
||||||
|
app._inlineRenameActive = true;
|
||||||
|
app._renderSessionTabsImmediate();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the split headers', () => {
|
||||||
|
it("Pane B's header: logo, name, model, and the close button at a tile button's size", () => {
|
||||||
|
openSplit();
|
||||||
|
const b = headerB();
|
||||||
|
expect(b.children.map((c) => c.className.split(' ')[0])).toEqual(['split-harness', 'split-title', 'tile-btn']);
|
||||||
|
expect(part(b, 'split-harness').className).toBe('split-harness run-mode-dot claude');
|
||||||
|
expect(part(b, 'session-name').textContent).toBe('docs-pass');
|
||||||
|
expect(modelName(b).textContent).toBe('Haiku 4.5');
|
||||||
|
expect(part(b, 'split-harness').title).toBe('Claude Code · Haiku 4.5');
|
||||||
|
expect(part(b, 'split-harness').getAttribute('aria-label')).toBe('Claude Code · Haiku 4.5');
|
||||||
|
const close = part(b, 'terminal-pane-b-close');
|
||||||
|
expect(close.className).toBe('tile-btn tile-remove terminal-pane-b-close');
|
||||||
|
expect(close.getAttribute('aria-label')).toBe('Close split');
|
||||||
|
expect(close.textContent).toBe('×');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Pane A gets the same strip, first in the main terminal's wrap, without a close", () => {
|
||||||
|
openSplit();
|
||||||
|
const a = headerA()!;
|
||||||
|
expect(wrap.children[0]).toBe(a);
|
||||||
|
expect(a.className).toBe('terminal-pane-b-header terminal-pane-a-header');
|
||||||
|
expect(part(a, 'split-harness').className).toBe('split-harness run-mode-dot deepseek');
|
||||||
|
expect(part(a, 'session-name').textContent).toBe('review-api');
|
||||||
|
expect(modelName(a).textContent).toBe('qwen3.8-27b');
|
||||||
|
expect(part(a, 'split-harness').title).toBe('DeepSeek · qwen3.8-27b');
|
||||||
|
expect(part(a, 'terminal-pane-b-close')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names and models are text, and the model is never translated', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.sessions.get('s-b').name = '<img src=x onerror=alert(1)>';
|
||||||
|
app.sessions.get('s-b').displayModel = { model: '<b>m</b>', source: 'statusline' };
|
||||||
|
app.syncTerminalGeometry = vi.fn();
|
||||||
|
app.openSplitPane('s-b');
|
||||||
|
expect(part(headerB(), 'session-name').textContent).toBe('<img src=x onerror=alert(1)>');
|
||||||
|
expect(part(headerB(), 'session-name').children).toHaveLength(0);
|
||||||
|
expect(modelName(headerB()).textContent).toBe('<b>m</b>');
|
||||||
|
expect(modelName(headerB()).getAttribute('data-i18n-skip')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unknown model shows the logo alone', () => {
|
||||||
|
const app = openSplit();
|
||||||
|
delete app.sessions.get('s-b').displayModel;
|
||||||
|
renderTabs(app);
|
||||||
|
expect(part(headerB(), 'split-model').hidden).toBe(true);
|
||||||
|
expect(modelName(headerB()).textContent).toBe('');
|
||||||
|
expect(part(headerB(), 'split-harness').title).toBe('Claude Code');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tab render carries a rename and a model switch into both headers', () => {
|
||||||
|
const app = openSplit();
|
||||||
|
app.sessions.set('s-b', {
|
||||||
|
...app.sessions.get('s-b'),
|
||||||
|
name: 'docs-pass-2',
|
||||||
|
displayModel: { model: 'Sonnet 4.6', source: 'statusline' },
|
||||||
|
});
|
||||||
|
app.sessions.get('s-a').displayModel = { model: 'deepseek-v4-flash', source: 'screen' };
|
||||||
|
renderTabs(app);
|
||||||
|
expect(part(headerB(), 'session-name').textContent).toBe('docs-pass-2');
|
||||||
|
expect(modelName(headerB()).textContent).toBe('Sonnet 4.6');
|
||||||
|
expect(modelName(headerA()!).textContent).toBe('deepseek-v4-flash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Pane A's header follows the active session", () => {
|
||||||
|
const app = openSplit();
|
||||||
|
app.activeSessionId = 's-c';
|
||||||
|
renderTabs(app);
|
||||||
|
expect(part(headerA()!, 'session-name').textContent).toBe('s-c');
|
||||||
|
expect(part(headerA()!, 'split-harness').className).toBe('split-harness run-mode-dot claude');
|
||||||
|
expect(part(headerA()!, 'split-model').hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unchanged session writes nothing on a refresh', () => {
|
||||||
|
const app = openSplit();
|
||||||
|
const writes: string[] = [];
|
||||||
|
for (const [which, header] of [
|
||||||
|
['a', headerA()!],
|
||||||
|
['b', headerB()],
|
||||||
|
] as Array<[string, FakeEl]>) {
|
||||||
|
for (const [node, props] of [
|
||||||
|
[part(header, 'split-harness'), ['className', 'title']],
|
||||||
|
[part(header, 'session-name'), ['textContent']],
|
||||||
|
[part(header, 'split-model'), ['title', 'hidden']],
|
||||||
|
[modelName(header), ['textContent']],
|
||||||
|
] as Array<[FakeEl, string[]]>) {
|
||||||
|
for (const prop of props) {
|
||||||
|
let value = (node as unknown as Record<string, unknown>)[prop];
|
||||||
|
Object.defineProperty(node, prop, {
|
||||||
|
get: () => value,
|
||||||
|
set: (v) => {
|
||||||
|
writes.push(`${which} ${prop}`);
|
||||||
|
value = v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const setAttribute = node.setAttribute.bind(node);
|
||||||
|
node.setAttribute = (k: string, v: string) => {
|
||||||
|
writes.push(`${which} @${k}`);
|
||||||
|
setAttribute(k, v);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderTabs(app);
|
||||||
|
renderTabs(app);
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the main terminal's height", () => {
|
||||||
|
it('opening fits Pane A with its header strip already in place, through sendResize', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.fitAddon = { fit: vi.fn() };
|
||||||
|
let stripInPlace = false;
|
||||||
|
app.sendResize = vi.fn(() => {
|
||||||
|
stripInPlace = wrap.children[0]?.classList.contains('terminal-pane-a-header') ?? false;
|
||||||
|
return Promise.resolve(true);
|
||||||
|
});
|
||||||
|
app.openSplitPane('s-b');
|
||||||
|
expect(app.sendResize).toHaveBeenCalledWith('s-a', { force: true });
|
||||||
|
expect(stripInPlace).toBe(true);
|
||||||
|
expect(app.fitAddon.fit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closing takes the strip away and gives the height back through sendResize, never a bare fit', () => {
|
||||||
|
const app = openSplit();
|
||||||
|
app.sendResize.mockClear();
|
||||||
|
let stripGone = false;
|
||||||
|
app.sendResize = vi.fn(() => {
|
||||||
|
stripGone = headerA() === null;
|
||||||
|
return Promise.resolve(true);
|
||||||
|
});
|
||||||
|
app.closeSplitPane();
|
||||||
|
expect(headerA()).toBeNull();
|
||||||
|
expect(app.sendResize).toHaveBeenCalledWith('s-a', { force: true });
|
||||||
|
expect(stripGone).toBe(true);
|
||||||
|
expect(app.fitAddon.fit).not.toHaveBeenCalled();
|
||||||
|
expect(app._splitHeaders).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a close that skips the resize (Pane A ended) still refits through syncTerminalGeometry', () => {
|
||||||
|
const app = openSplit();
|
||||||
|
app.sendResize.mockClear();
|
||||||
|
app.closeSplitPane({ skipPrimaryResize: true });
|
||||||
|
expect(headerA()).toBeNull();
|
||||||
|
expect(app.sendResize).not.toHaveBeenCalled();
|
||||||
|
expect(app.syncTerminalGeometry).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app.fitAddon.fit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Pane B's close button closes the split", () => {
|
||||||
|
const app = openSplit();
|
||||||
|
const tile = FakeTile.all.at(-1) as FakeTile;
|
||||||
|
part(headerB(), 'terminal-pane-b-close').dispatch('click');
|
||||||
|
expect(app._splitPane).toBeNull();
|
||||||
|
expect(tile.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(headerA()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/** @fileoverview Real Chromium + real WebSocket coverage for SplitTerminalPane (Task 4 of the split-pane-sessions plan). */
|
/** @fileoverview Real Chromium + real WebSocket coverage for TerminalTile (Task 4 of the split-pane-sessions plan). */
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import { chromium, type Browser, type Page } from 'playwright';
|
import { chromium, type Browser, type Page } from 'playwright';
|
||||||
import { WebServer } from '../src/web/server.js';
|
import { WebServer } from '../src/web/server.js';
|
||||||
@@ -6,7 +6,7 @@ import { WebServer } from '../src/web/server.js';
|
|||||||
const PORT = 3175;
|
const PORT = 3175;
|
||||||
const BASE_URL = `http://localhost:${PORT}`;
|
const BASE_URL = `http://localhost:${PORT}`;
|
||||||
|
|
||||||
describe('SplitTerminalPane in a real browser', () => {
|
describe('TerminalTile in a real browser', () => {
|
||||||
let server: WebServer;
|
let server: WebServer;
|
||||||
let browser: Browser;
|
let browser: Browser;
|
||||||
let page: Page;
|
let page: Page;
|
||||||
@@ -37,7 +37,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
// Testing section) — the shell PTY only spawns once this is called, and
|
// Testing section) — the shell PTY only spawns once this is called, and
|
||||||
// without it the WS opens but no bytes ever flow, and the echo assertion
|
// without it the WS opens but no bytes ever flow, and the echo assertion
|
||||||
// below would hang until its own timeout for reasons unrelated to
|
// below would hang until its own timeout for reasons unrelated to
|
||||||
// SplitTerminalPane.
|
// TerminalTile.
|
||||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
@@ -48,7 +48,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
mount.style.height = '300px';
|
mount.style.height = '300px';
|
||||||
document.body.appendChild(mount);
|
document.body.appendChild(mount);
|
||||||
|
|
||||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
const pane = new (window as any).TerminalTile(id, mount);
|
||||||
pane.connect();
|
pane.connect();
|
||||||
|
|
||||||
// Wait for the WS to open, then send a real input frame — testMode's
|
// Wait for the WS to open, then send a real input frame — testMode's
|
||||||
@@ -107,7 +107,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
});
|
});
|
||||||
const id = (await res.json()).data.session.id;
|
const id = (await res.json()).data.session.id;
|
||||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||||
// Write directly to the session (not through SplitTerminalPane, which
|
// Write directly to the session (not through TerminalTile, which
|
||||||
// does not exist yet). Poll the real ?full=1 capture (same endpoint
|
// does not exist yet). Poll the real ?full=1 capture (same endpoint
|
||||||
// connect() below will use) rather than a fixed delay — the shell's
|
// connect() below will use) rather than a fixed delay — the shell's
|
||||||
// own startup can race an early write and, on this box, a startup
|
// own startup can race an early write and, on this box, a startup
|
||||||
@@ -135,7 +135,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
mount.style.height = '300px';
|
mount.style.height = '300px';
|
||||||
document.body.appendChild(mount);
|
document.body.appendChild(mount);
|
||||||
|
|
||||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
const pane = new (window as any).TerminalTile(id, mount);
|
||||||
await pane.connect();
|
await pane.connect();
|
||||||
|
|
||||||
// xterm's write() parses asynchronously (it queues data and processes it
|
// xterm's write() parses asynchronously (it queues data and processes it
|
||||||
@@ -191,7 +191,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
mount.style.height = '300px';
|
mount.style.height = '300px';
|
||||||
document.body.appendChild(mount);
|
document.body.appendChild(mount);
|
||||||
|
|
||||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
const pane = new (window as any).TerminalTile(id, mount);
|
||||||
await pane.connect();
|
await pane.connect();
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
|
const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The server's side of the tile grid's SSE filter (live server,
|
||||||
|
* multi-user mode, port 3287).
|
||||||
|
*
|
||||||
|
* While tiles own the terminal the page subscribes with TILE_GRID_SSE_FILTER
|
||||||
|
* (constants.js), an id that names no session, so the server sends it no
|
||||||
|
* session:terminal batches (test/tile-grid-sse-filter.test.ts covers the page).
|
||||||
|
* That holds only while:
|
||||||
|
* - the server takes an id it does not know, on the connect query and on
|
||||||
|
* POST /api/events/subscribe (a validation change that refused it would
|
||||||
|
* leave the grid's stream unfiltered, or with no live filter updates at all);
|
||||||
|
* - the filter gates nothing but terminal batches: in multi-user mode SSE
|
||||||
|
* routing is fail-closed, so this also checks that session:updated and hook
|
||||||
|
* events still reach their owner, and only their owner, through the filter.
|
||||||
|
*
|
||||||
|
* Every server read of the filter, for the record: the connect route parses
|
||||||
|
* `?sessions=` (server.ts, GET /api/events), POST /api/events/subscribe
|
||||||
|
* replaces it (SseStreamManager.updateClientFilter), and its ONE use is
|
||||||
|
* flushSessionTerminalBatch. broadcast(), the ownership check (canDeliver), the
|
||||||
|
* heartbeat, the order and tab-layout frames and the shutdown notice never read it.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { WebServer } from '../src/web/server.js';
|
||||||
|
import { TmuxManager } from '../src/tmux-manager.js';
|
||||||
|
import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
||||||
|
|
||||||
|
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||||
|
|
||||||
|
const PORT = 3287;
|
||||||
|
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
||||||
|
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
|
||||||
|
const alice = { Authorization: basic('alice', 'alicepass1') };
|
||||||
|
|
||||||
|
/** The page's own constant, read from constants.js, so the two can never drift. */
|
||||||
|
function gridFilter(): string {
|
||||||
|
const window: Record<string, { TILE_GRID_SSE_FILTER?: string }> = {};
|
||||||
|
const context = vm.createContext({ window, globalThis: {} });
|
||||||
|
vm.runInContext(readFileSync(path.resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'), context);
|
||||||
|
return window.CodemanTileGrid.TILE_GRID_SSE_FILTER as string;
|
||||||
|
}
|
||||||
|
const FILTER = gridFilter();
|
||||||
|
|
||||||
|
type Received = { event: string; data: unknown };
|
||||||
|
/** An open SSE stream for `headers`, collecting every event until close(). */
|
||||||
|
async function openStream(query: string, headers: Record<string, string>) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const received: Received[] = [];
|
||||||
|
const res = await fetch(url(`/api/events${query}`), { headers, signal: controller.signal });
|
||||||
|
let text = '';
|
||||||
|
const reading = (async () => {
|
||||||
|
const reader = res.body!.getReader();
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
text += new TextDecoder().decode(value);
|
||||||
|
let cut: number;
|
||||||
|
while ((cut = text.indexOf('\n\n')) !== -1) {
|
||||||
|
const frame = text.slice(0, cut);
|
||||||
|
text = text.slice(cut + 2);
|
||||||
|
const event = /^event: (.*)$/m.exec(frame)?.[1];
|
||||||
|
const data = /^data: (.*)$/m.exec(frame)?.[1];
|
||||||
|
if (event && data !== undefined) {
|
||||||
|
try {
|
||||||
|
received.push({ event, data: JSON.parse(data) });
|
||||||
|
} catch {
|
||||||
|
received.push({ event, data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* aborted */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return {
|
||||||
|
status: res.status,
|
||||||
|
received,
|
||||||
|
close: async () => {
|
||||||
|
controller.abort();
|
||||||
|
await reading;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const until = async (fn: () => boolean, ms = 3000) => {
|
||||||
|
const end = Date.now() + ms;
|
||||||
|
while (!fn() && Date.now() < end) await wait(20);
|
||||||
|
};
|
||||||
|
|
||||||
|
let server: WebServer;
|
||||||
|
let dataDir: string;
|
||||||
|
let spacesDir: string;
|
||||||
|
const saved: Record<string, string | undefined> = {};
|
||||||
|
type Internals = {
|
||||||
|
sessions: Map<string, unknown>;
|
||||||
|
broadcast(event: string, data: unknown): void;
|
||||||
|
batchTerminalData(sessionId: string, data: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sse-grid-data-'));
|
||||||
|
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sse-grid-spaces-'));
|
||||||
|
for (const k of [
|
||||||
|
'CODEMAN_DATA_DIR',
|
||||||
|
'CODEMAN_USER_SPACES_DIR',
|
||||||
|
'CODEMAN_MULTIUSER',
|
||||||
|
'CODEMAN_PASSWORD',
|
||||||
|
'CODEMAN_USERNAME',
|
||||||
|
]) {
|
||||||
|
saved[k] = process.env[k];
|
||||||
|
}
|
||||||
|
process.env.CODEMAN_DATA_DIR = dataDir;
|
||||||
|
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
|
||||||
|
process.env.CODEMAN_MULTIUSER = '1';
|
||||||
|
delete process.env.CODEMAN_PASSWORD;
|
||||||
|
delete process.env.CODEMAN_USERNAME;
|
||||||
|
invalidateUsersCache();
|
||||||
|
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
|
||||||
|
await createUser({ username: 'alice', role: 'user', password: 'alicepass1' });
|
||||||
|
await createUser({ username: 'bob', role: 'user', password: 'bobpass1234' });
|
||||||
|
server = new WebServer(PORT, false, true);
|
||||||
|
await server.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await server?.stop();
|
||||||
|
for (const [k, v] of Object.entries(saved)) {
|
||||||
|
if (v === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = v;
|
||||||
|
}
|
||||||
|
invalidateUsersCache();
|
||||||
|
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the tile grid SSE filter, multi-user', () => {
|
||||||
|
it('is taken on a live re-subscribe though it names no session, and then withholds terminal output', async () => {
|
||||||
|
// A tile focus re-subscribes the open stream (POST /api/events/subscribe).
|
||||||
|
// A 204 alone would not do: a validation that dropped the id would still
|
||||||
|
// answer 204 with the filter cleared, and the stream would carry every
|
||||||
|
// session's output. So the filter is checked by what it withholds.
|
||||||
|
expect(FILTER).toBe('tile-grid');
|
||||||
|
const internals = server as unknown as Internals;
|
||||||
|
const grid = await openStream('?clientId=grid-tolerance-1', alice);
|
||||||
|
const control = await openStream('?clientId=control-1', alice);
|
||||||
|
expect(grid.status).toBe(200);
|
||||||
|
await until(() => [grid, control].every((s) => s.received.some((r) => r.event === 'init')));
|
||||||
|
const res = await fetch(url('/api/events/subscribe'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...alice, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ clientId: 'grid-tolerance-1', sessions: [FILTER] }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
internals.sessions.set('alice-2', { id: 'alice-2', owner: 'alice', toState: () => ({ id: 'alice-2' }) });
|
||||||
|
try {
|
||||||
|
internals.batchTerminalData('alice-2', 'output of alice-2');
|
||||||
|
await until(() => control.received.some((r) => r.event === 'session:terminal'));
|
||||||
|
await wait(150);
|
||||||
|
} finally {
|
||||||
|
internals.sessions.delete('alice-2');
|
||||||
|
}
|
||||||
|
expect(control.received.some((r) => r.event === 'session:terminal')).toBe(true);
|
||||||
|
expect(grid.received.some((r) => r.event === 'session:terminal')).toBe(false);
|
||||||
|
await grid.close();
|
||||||
|
await control.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withholds only terminal output: the subscriber still gets its own sessions' updates and hooks", async () => {
|
||||||
|
const internals = server as unknown as Internals;
|
||||||
|
const grid = await openStream(`?clientId=grid-1&sessions=${FILTER}`, alice);
|
||||||
|
const plain = await openStream('?clientId=plain-1', alice);
|
||||||
|
await until(() => [grid, plain].every((s) => s.received.some((r) => r.event === 'init')));
|
||||||
|
|
||||||
|
// One session of alice's and one of bob's (spawning is a no-op under test).
|
||||||
|
const fake = (id: string, owner: string) => ({ id, owner, toState: () => ({ id, owner }) });
|
||||||
|
internals.sessions.set('alice-1', fake('alice-1', 'alice'));
|
||||||
|
internals.sessions.set('bob-1', fake('bob-1', 'bob'));
|
||||||
|
try {
|
||||||
|
internals.broadcast('session:updated', { id: 'alice-1', session: { id: 'alice-1', status: 'busy' } });
|
||||||
|
internals.broadcast('session:updated', { id: 'bob-1', session: { id: 'bob-1', status: 'busy' } });
|
||||||
|
internals.broadcast('hook:idle_prompt', { sessionId: 'alice-1' });
|
||||||
|
internals.batchTerminalData('alice-1', 'output of alice-1');
|
||||||
|
await until(() => plain.received.some((r) => r.event === 'session:terminal'));
|
||||||
|
await wait(150);
|
||||||
|
} finally {
|
||||||
|
internals.sessions.delete('alice-1');
|
||||||
|
internals.sessions.delete('bob-1');
|
||||||
|
}
|
||||||
|
// Only what names the two sessions above (heartbeats and anything else the
|
||||||
|
// server sends meanwhile are not this test's business).
|
||||||
|
const kinds = (s: { received: Received[] }) =>
|
||||||
|
s.received
|
||||||
|
.map((r) => {
|
||||||
|
const d = (r.data ?? {}) as { id?: string; sessionId?: string };
|
||||||
|
return `${r.event} ${d.id ?? d.sessionId}`;
|
||||||
|
})
|
||||||
|
.filter((k) => k.endsWith(' alice-1') || k.endsWith(' bob-1'));
|
||||||
|
|
||||||
|
expect(kinds(grid)).toEqual(['session:updated alice-1', 'hook:idle_prompt alice-1']);
|
||||||
|
// The same user without the filter got the terminal batch: it was sent.
|
||||||
|
expect(kinds(plain)).toContain('session:terminal alice-1');
|
||||||
|
expect(kinds(plain)).not.toContain('session:updated bob-1');
|
||||||
|
await grid.close();
|
||||||
|
await plain.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,7 +63,13 @@ function fakeTerminal(options: Record<string, unknown> = {}) {
|
|||||||
return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } };
|
return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTerminal> | null } = {}) {
|
function makeApp(
|
||||||
|
opts: {
|
||||||
|
teammates?: number;
|
||||||
|
terminal?: ReturnType<typeof fakeTerminal> | null;
|
||||||
|
splitPane?: { terminal: ReturnType<typeof fakeTerminal>; fit: () => void } | null;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
const fit = vi.fn();
|
const fit = vi.fn();
|
||||||
const teammateFits: ReturnType<typeof vi.fn>[] = [];
|
const teammateFits: ReturnType<typeof vi.fn>[] = [];
|
||||||
const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>();
|
const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>();
|
||||||
@@ -74,6 +80,10 @@ function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTe
|
|||||||
}
|
}
|
||||||
const app = {
|
const app = {
|
||||||
applyTerminalFontWeights: mixin.applyTerminalFontWeights,
|
applyTerminalFontWeights: mixin.applyTerminalFontWeights,
|
||||||
|
// Secondary panes (the split pane's second terminal) are reached through
|
||||||
|
// this helper, so it is wired for real like the geometry chain below.
|
||||||
|
_forEachTile: mixin._forEachTile,
|
||||||
|
_splitPane: opts.splitPane ?? null,
|
||||||
// The REAL geometry chain, not stubs. A font change moves the cell size, so
|
// The REAL geometry chain, not stubs. A font change moves the cell size, so
|
||||||
// it moves cols/rows, and `applyTerminalFontWeights` now routes its refit
|
// it moves cols/rows, and `applyTerminalFontWeights` now routes its refit
|
||||||
// through the one function that floors the result and reports it (#464).
|
// through the one function that floors the result and reports it (#464).
|
||||||
@@ -98,6 +108,23 @@ function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTe
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('applyTerminalFontWeights', () => {
|
describe('applyTerminalFontWeights', () => {
|
||||||
|
it('reaches an open split pane: same weights, refit AND reported to its PTY', () => {
|
||||||
|
const splitTerminal = fakeTerminal();
|
||||||
|
// fit(), not localFit(): a weight change can move the cell size, and the
|
||||||
|
// split pane's PTY must hear about the new geometry like the primary's does.
|
||||||
|
const fit = vi.fn();
|
||||||
|
const { app } = makeApp({ splitPane: { terminal: splitTerminal, fit } });
|
||||||
|
|
||||||
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||||
|
terminalFontWeight: '300',
|
||||||
|
terminalFontWeightBold: '700',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(splitTerminal.options.fontWeight).toBe(300);
|
||||||
|
expect(splitTerminal.options.fontWeightBold).toBe(700);
|
||||||
|
expect(fit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('writes both slots to the live terminal', () => {
|
it('writes both slots to the live terminal', () => {
|
||||||
const { app, fit } = makeApp();
|
const { app, fit } = makeApp();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The file-path / URL link provider can serve a second terminal.
|
||||||
|
*
|
||||||
|
* `registerFilePathLinkProvider()` (terminal-ui.js) was hardwired to the
|
||||||
|
* primary terminal and the ACTIVE session: a path clicked anywhere opened
|
||||||
|
* against `activeSessionId`. It now takes an optional target `{ terminal,
|
||||||
|
* getSessionId, setHovered }`, so the split pane's second terminal (and later a
|
||||||
|
* grid tile) gets clickable paths that open against ITS session. With no target
|
||||||
|
* it behaves exactly as before, and only the primary registration is kept on
|
||||||
|
* `_terminalLinkProvider`, which the touch-tap path reads.
|
||||||
|
*
|
||||||
|
* Real code under test: constants.js + terminal-ui.js in a `vm` context.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
type Link = { text: string; activate: (ev: unknown, text: string) => void; hover: () => void; leave: () => void };
|
||||||
|
type Provider = { provideLinks: (line: number, cb: (links: Link[] | undefined) => void) => void };
|
||||||
|
|
||||||
|
function loadHarness() {
|
||||||
|
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||||
|
const windowRef: Record<string, unknown> = {};
|
||||||
|
const context = vm.createContext({
|
||||||
|
window: windowRef,
|
||||||
|
document: {
|
||||||
|
body: { classList: { contains: () => false } },
|
||||||
|
activeElement: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
getElementById: () => null,
|
||||||
|
},
|
||||||
|
CodemanApp,
|
||||||
|
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
|
||||||
|
_crashDiag: { log: vi.fn() },
|
||||||
|
performance: { now: () => 0 },
|
||||||
|
requestAnimationFrame: () => 1,
|
||||||
|
setTimeout: () => 1,
|
||||||
|
MobileDetection: { isTouchDevice: () => false },
|
||||||
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
|
});
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||||
|
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||||
|
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
||||||
|
return new (CodemanApp as unknown as new () => Record<string, any>)();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A one-screen xterm stand-in holding `lines`, recording every provider registered on it. */
|
||||||
|
function fakeTerminal(lines: string[]) {
|
||||||
|
const providers: Provider[] = [];
|
||||||
|
return {
|
||||||
|
cols: 80,
|
||||||
|
providers,
|
||||||
|
registerLinkProvider: (p: Provider) => providers.push(p),
|
||||||
|
buffer: {
|
||||||
|
active: {
|
||||||
|
length: lines.length,
|
||||||
|
getLine: (row: number) =>
|
||||||
|
row >= 0 && row < lines.length
|
||||||
|
? {
|
||||||
|
isWrapped: false,
|
||||||
|
translateToString: (trim?: boolean) => (trim === false ? lines[row].padEnd(80) : lines[row]),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function linksOn(provider: Provider, line: number): Link[] {
|
||||||
|
let out: Link[] = [];
|
||||||
|
provider.provideLinks(line, (links) => {
|
||||||
|
out = links || [];
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeApp() {
|
||||||
|
const app = loadHarness();
|
||||||
|
app.activeSessionId = 'primary-session';
|
||||||
|
app.openFilePreview = vi.fn();
|
||||||
|
app.openLogViewerWindow = vi.fn();
|
||||||
|
app._isExternalPreviewPath = () => false;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('registerFilePathLinkProvider with no target (the primary pane)', () => {
|
||||||
|
it('registers on the primary terminal, keeps the provider for the tap path, opens against the active session', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app.terminal = fakeTerminal(['wrote /tmp/shot.png']);
|
||||||
|
|
||||||
|
const provider = app.registerFilePathLinkProvider();
|
||||||
|
|
||||||
|
expect(app.terminal.providers).toEqual([provider]);
|
||||||
|
expect(app._terminalLinkProvider).toBe(provider);
|
||||||
|
const [link] = linksOn(provider, 1);
|
||||||
|
link.activate({}, link.text);
|
||||||
|
expect(app.openFilePreview).toHaveBeenCalledWith('/tmp/shot.png', 'primary-session');
|
||||||
|
link.hover();
|
||||||
|
expect(app._linkHovered).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerFilePathLinkProvider with a target (a second pane)', () => {
|
||||||
|
it("registers on the target terminal and opens against the target's session, not the active one", () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app.terminal = fakeTerminal([]);
|
||||||
|
const primaryProvider = app.registerFilePathLinkProvider();
|
||||||
|
const paneTerminal = fakeTerminal(['tail -f /var/log/app.log']);
|
||||||
|
const setHovered = vi.fn();
|
||||||
|
|
||||||
|
const provider = app.registerFilePathLinkProvider({
|
||||||
|
terminal: paneTerminal,
|
||||||
|
getSessionId: () => 'pane-session',
|
||||||
|
setHovered,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(paneTerminal.providers).toEqual([provider]);
|
||||||
|
expect(app.terminal.providers).toEqual([primaryProvider]);
|
||||||
|
// The tap path's provider is still the primary one.
|
||||||
|
expect(app._terminalLinkProvider).toBe(primaryProvider);
|
||||||
|
|
||||||
|
const [link] = linksOn(provider, 1);
|
||||||
|
link.activate({}, link.text);
|
||||||
|
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/var/log/app.log', 'pane-session');
|
||||||
|
|
||||||
|
link.hover();
|
||||||
|
link.leave();
|
||||||
|
expect(setHovered.mock.calls).toEqual([[true], [false]]);
|
||||||
|
expect(app._linkHovered).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the session at click time, so a pane rebound to another session follows it', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
let sessionId = 'first';
|
||||||
|
const paneTerminal = fakeTerminal(['see /tmp/a.pdf']);
|
||||||
|
const provider = app.registerFilePathLinkProvider({ terminal: paneTerminal, getSessionId: () => sessionId });
|
||||||
|
|
||||||
|
sessionId = 'second';
|
||||||
|
const [link] = linksOn(provider, 1);
|
||||||
|
link.activate({}, link.text);
|
||||||
|
|
||||||
|
expect(app.openFilePreview).toHaveBeenCalledWith('/tmp/a.pdf', 'second');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,686 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview TerminalTile input rides the app's exactly-once queue, and only
|
||||||
|
* what a human typed is queued.
|
||||||
|
*
|
||||||
|
* Before, the split pane's second terminal sent every xterm `onData` chunk as a
|
||||||
|
* bare `{t:'i', d}` frame: no seq, no ACK, dropped while the socket was down,
|
||||||
|
* never acknowledged an idle alert. It now goes through `app._sendInputAsync`,
|
||||||
|
* over the pane's own socket registered in the app's input-socket map. That
|
||||||
|
* queue PERSISTS and REDELIVERS, so it must never hold what xterm generates on
|
||||||
|
* its own: a query reply (DA/CPR/OSC) is dropped, exactly as the primary pane
|
||||||
|
* drops it, and a focus or mouse report goes out once, ephemeral.
|
||||||
|
*
|
||||||
|
* The socket's lifecycle is pinned here too: a transient drop reconnects on the
|
||||||
|
* primary pane's backoff and refreshes the buffer without leaving a stale
|
||||||
|
* "disconnected" marker under a healthy pane; codes that cannot get better stop
|
||||||
|
* the pane once (`onExit`); a late close from a REPLACED socket is ignored; and
|
||||||
|
* destroy() cancels a pending reconnect.
|
||||||
|
*
|
||||||
|
* Real code under test: constants.js + app.js (the queue) + terminal-ui.js (the
|
||||||
|
* shared input predicates) + terminal-tile.js, in one `vm` context. xterm, the
|
||||||
|
* fit addon and WebSocket are fakes (test/mocks/terminal-tile-fakes.ts);
|
||||||
|
* `connect()` runs for real.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { FakeFit, FakeSocket, FakeTerminal } from './mocks/terminal-tile-fakes.js';
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
function loadContext() {
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||||
|
const windowStub: Record<string, unknown> = {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
CodemanBase: { base: '' },
|
||||||
|
};
|
||||||
|
const context = vm.createContext({
|
||||||
|
console: { ...console, log: vi.fn(), debug: vi.fn() },
|
||||||
|
performance,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
// Late-bound, so vi.useFakeTimers() (which swaps the globals) reaches code
|
||||||
|
// running inside this context.
|
||||||
|
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
|
||||||
|
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
WebSocket: FakeSocket,
|
||||||
|
Terminal: FakeTerminal,
|
||||||
|
FitAddon: { FitAddon: FakeFit },
|
||||||
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
|
location: { protocol: 'http:', host: 'codeman.test' },
|
||||||
|
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
|
||||||
|
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||||
|
window: windowStub,
|
||||||
|
MobileDetection: {
|
||||||
|
isTouchDevice: () => false,
|
||||||
|
isHandheldDevice: () => false,
|
||||||
|
getDeviceType: () => 'desktop',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vm.runInContext(
|
||||||
|
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
|
||||||
|
'globalThis.__CodemanApp = CodemanApp;',
|
||||||
|
context
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
CodemanApp: (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp,
|
||||||
|
windowStub,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { CodemanApp, windowStub } = loadContext();
|
||||||
|
|
||||||
|
type App = Record<string, unknown> & {
|
||||||
|
_pendingDeliveries: Map<string, Array<{ seq: number; data: string }>>;
|
||||||
|
markIdleAlertSeen: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeApp(): App {
|
||||||
|
const app = Object.create(CodemanApp.prototype) as App;
|
||||||
|
app._clientId = 'c-test';
|
||||||
|
app._wsTabNonce = 'nonce-1';
|
||||||
|
app._seqCounters = new Map();
|
||||||
|
app._pendingDeliveries = new Map();
|
||||||
|
app._postDraining = new Set();
|
||||||
|
app._extraInputSockets = new Map();
|
||||||
|
app._persistReliableState = vi.fn();
|
||||||
|
app._persistReliableNow = vi.fn();
|
||||||
|
app._updateConnectionIndicator = vi.fn();
|
||||||
|
app.markIdleAlertSeen = vi.fn();
|
||||||
|
app.showToast = vi.fn();
|
||||||
|
// The key handler's chord gates read the shortcut registry, which reads these.
|
||||||
|
app.loadAppSettingsFromStorage = () => ({});
|
||||||
|
app._ws = null;
|
||||||
|
app._wsSessionId = null;
|
||||||
|
app._estimateReplayRows = (text: string) => text.split('\n').length;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tile = {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
destroy(): void;
|
||||||
|
reconnectNow(): void;
|
||||||
|
fit(opts?: { force?: boolean }): void;
|
||||||
|
detachedSessions?: Set<string>;
|
||||||
|
ws: FakeSocket | null;
|
||||||
|
_reconnectAttempts: number;
|
||||||
|
};
|
||||||
|
const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every tile a test creates, destroyed after it. A tile closed with real timers
|
||||||
|
* schedules a real reconnect, and one firing during a LATER test opens a socket
|
||||||
|
* there (FakeSocket.instances is shared), which flaked under full-suite load.
|
||||||
|
*/
|
||||||
|
const liveTiles: Tile[] = [];
|
||||||
|
|
||||||
|
async function connectTile(app: App, opts: Record<string, unknown> = {}) {
|
||||||
|
windowStub.app = app;
|
||||||
|
const tile = new TerminalTile(
|
||||||
|
's-tile',
|
||||||
|
{ addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
{ mode: 'claude', ...opts }
|
||||||
|
);
|
||||||
|
liveTiles.push(tile);
|
||||||
|
await tile.connect();
|
||||||
|
const ws = FakeSocket.instances.at(-1)!;
|
||||||
|
return { tile, ws, term: FakeTerminal.last! };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const tile of liveTiles.splice(0)) tile.destroy();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
FakeFit.proposed = { cols: 80, rows: 24 };
|
||||||
|
FakeSocket.instances = [];
|
||||||
|
fetchMock.mockReset();
|
||||||
|
fetchMock.mockImplementation(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ data: { terminalBuffer: '' } }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile socket identity', () => {
|
||||||
|
it('connects with the tab identity plus a :tile suffix, never the primary pane cid', async () => {
|
||||||
|
const { ws } = await connectTile(makeApp());
|
||||||
|
|
||||||
|
const cid = new URL(ws.url).searchParams.get('cid');
|
||||||
|
expect(cid).toBe('c-test:nonce-1:tile');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile input through the exactly-once queue', () => {
|
||||||
|
it('sends typed input as seq-tagged frames with the bare clientId once the socket opens', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
term.type('h');
|
||||||
|
term.type('i');
|
||||||
|
|
||||||
|
expect(ws.inputFrames().map((f) => [f.d, f.seq, f.cid])).toEqual([
|
||||||
|
['h', 1, 'c-test'],
|
||||||
|
['i', 2, 'c-test'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a record on its ACK and acknowledges the session idle alert', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
term.type('x');
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')).toHaveLength(1);
|
||||||
|
|
||||||
|
ws.receive({ t: 'ia', seq: 1 });
|
||||||
|
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
|
||||||
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-tile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes input typed before the socket opened, in order, once it does', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
// POSTs fail, as they would while the server restarts, so the input waits.
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
fetchMock.mockImplementation(async () => ({ ok: false, status: 503, json: async () => ({}) }));
|
||||||
|
term.type('a');
|
||||||
|
term.type('b');
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(ws.inputFrames()).toEqual([]);
|
||||||
|
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
expect(ws.inputFrames().map((f) => [f.d, f.seq])).toEqual([
|
||||||
|
['a', 1],
|
||||||
|
['b', 2],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps queuing after the socket closes (HTTP fallback), instead of dropping keystrokes', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
ws.readyState = 3;
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
const posts: Array<{ input: string; seq: number }> = [];
|
||||||
|
fetchMock.mockImplementation(async (_url: string, init?: { body?: string }) => {
|
||||||
|
if (init?.body) posts.push(JSON.parse(init.body));
|
||||||
|
return { ok: true, status: 200, json: async () => ({}) };
|
||||||
|
});
|
||||||
|
|
||||||
|
term.type('z');
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(posts.map((p) => [p.input, p.seq])).toEqual([['z', 1]]);
|
||||||
|
expect(ws.inputFrames()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('what xterm generates never enters the durable queue', () => {
|
||||||
|
it('drops a DA query reply entirely', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
term.type('\x1b[?1;2c');
|
||||||
|
|
||||||
|
expect(ws.inputFrames()).toEqual([]);
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a mouse report once, without a seq, and never persists it', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
term.type('\x1b[<0;10;5M');
|
||||||
|
|
||||||
|
expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[<0;10;5M' }]);
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a focus report once, without a seq', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
term.type('\x1b[I');
|
||||||
|
|
||||||
|
expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[I' }]);
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile leaves the input-socket map', () => {
|
||||||
|
it('on close, so a later keystroke cannot be written into a dead socket', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
expect((app._extraInputSockets as Map<string, unknown>).has('s-tile')).toBe(true);
|
||||||
|
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
|
||||||
|
expect((app._extraInputSockets as Map<string, unknown>).has('s-tile')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on destroy, while pending input stays queued for the HTTP sweep', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { tile, ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
term.type('q');
|
||||||
|
|
||||||
|
tile.destroy();
|
||||||
|
|
||||||
|
expect((app._extraInputSockets as Map<string, unknown>).has('s-tile')).toBe(false);
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(['q']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMarker = (data: string) => data.includes('[disconnected');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the async buffer refresh settle: the fetch, the body read, the chunked
|
||||||
|
* write AND the load's finally block, which is where a stale marker would be
|
||||||
|
* stamped. Too few turns here and that assertion passes vacuously.
|
||||||
|
*/
|
||||||
|
async function settle() {
|
||||||
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TerminalTile reconnects after a transient drop', () => {
|
||||||
|
it('reopens on the backoff, refreshes the buffer, and leaves no stale marker', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const app = makeApp();
|
||||||
|
const { tile, ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
fetchMock.mockImplementation(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ data: { terminalBuffer: 'fresh screen' } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
ws.readyState = 3;
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
expect(term.writes.filter(isMarker)).toEqual([expect.stringContaining('[disconnected, reconnecting')]);
|
||||||
|
expect(FakeSocket.instances).toHaveLength(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
expect(FakeSocket.instances).toHaveLength(2);
|
||||||
|
const ws2 = FakeSocket.instances[1];
|
||||||
|
expect(ws2.url).toBe(ws.url);
|
||||||
|
|
||||||
|
ws2.open();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// The refresh cleared the pane and replayed the current screen, and nothing
|
||||||
|
// after that clear is a marker: the pane is healthy again.
|
||||||
|
const lastClear = term.writes.lastIndexOf('<CLEAR>');
|
||||||
|
expect(lastClear).toBeGreaterThan(-1);
|
||||||
|
expect(term.writes.slice(lastClear)).toContain('fresh screen');
|
||||||
|
expect(term.writes.slice(lastClear).some(isMarker)).toBe(false);
|
||||||
|
expect(tile._reconnectAttempts).toBe(0);
|
||||||
|
expect(tile.ws).toBe(ws2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts failed attempts and resets the count only on a successful open', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { tile, ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
FakeSocket.instances[1].onclose?.({ code: 1006 }); // the retry fails too
|
||||||
|
expect(tile._reconnectAttempts).toBe(2);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
FakeSocket.instances[2].open();
|
||||||
|
expect(tile._reconnectAttempts).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconnects after the redelivery sweep force-closes a silent socket (1005)', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
ws.onclose?.({ code: 1005 });
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
expect(FakeSocket.instances).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconnectNow() skips the backoff, but never replaces an open socket', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { tile, ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
tile.reconnectNow();
|
||||||
|
expect(FakeSocket.instances).toHaveLength(1);
|
||||||
|
|
||||||
|
ws.readyState = 3;
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
tile.reconnectNow();
|
||||||
|
expect(FakeSocket.instances).toHaveLength(2);
|
||||||
|
// The backoff timer it pre-empted must not open a third socket later.
|
||||||
|
await vi.advanceTimersByTimeAsync(20_000);
|
||||||
|
expect(FakeSocket.instances).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile stops for good on codes that cannot get better', () => {
|
||||||
|
it.each([
|
||||||
|
[4004, 'the session ended'],
|
||||||
|
[4009, 'the session ended'],
|
||||||
|
[4003, 'the server refused this connection'],
|
||||||
|
[4010, 'another connection took over this pane'],
|
||||||
|
])('close %i: no reconnect, onExit once, marker says why', async (code, reason) => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onExit = vi.fn();
|
||||||
|
const { tile, ws, term } = await connectTile(makeApp(), { onExit });
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
ws.onclose?.({ code });
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
tile.reconnectNow();
|
||||||
|
|
||||||
|
expect(FakeSocket.instances).toHaveLength(1);
|
||||||
|
expect(onExit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onExit).toHaveBeenCalledWith(code);
|
||||||
|
expect(term.writes.filter(isMarker)).toEqual([expect.stringContaining(reason)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile ignores a socket it already replaced', () => {
|
||||||
|
it('a late close (4010) from the old socket neither stops the pane nor unregisters its successor', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onExit = vi.fn();
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app, { onExit });
|
||||||
|
ws.open();
|
||||||
|
const lateClose = ws.onclose!;
|
||||||
|
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
const ws2 = FakeSocket.instances[1];
|
||||||
|
ws2.open();
|
||||||
|
|
||||||
|
// The server supersedes the old socket by cid; its close arrives late.
|
||||||
|
lateClose({ code: 4010 });
|
||||||
|
|
||||||
|
expect(onExit).not.toHaveBeenCalled();
|
||||||
|
term.type('still typing');
|
||||||
|
expect(ws2.inputFrames().map((f) => f.d)).toEqual(['still typing']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile destroy()', () => {
|
||||||
|
it('cancels a pending reconnect and never reports an exit afterwards', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onExit = vi.fn();
|
||||||
|
const { tile, ws } = await connectTile(makeApp(), { onExit });
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
tile.destroy();
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
|
||||||
|
expect(FakeSocket.instances).toHaveLength(1);
|
||||||
|
expect(onExit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile geometry (#464: the pane and its PTY never disagree)', () => {
|
||||||
|
const resizeFrames = (ws: FakeSocket) => ws.sent.filter((f) => f.t === 'z');
|
||||||
|
|
||||||
|
it('announces its size as a desktop viewer when the socket opens', async () => {
|
||||||
|
const { ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
expect(resizeFrames(ws)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resend an unchanged size, sends a changed one, and force resends', async () => {
|
||||||
|
const { tile, ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
tile.fit();
|
||||||
|
expect(resizeFrames(ws)).toHaveLength(1);
|
||||||
|
|
||||||
|
FakeFit.proposed = { cols: 100, rows: 30 };
|
||||||
|
tile.fit();
|
||||||
|
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 100, r: 30, v: 'desktop' });
|
||||||
|
|
||||||
|
tile.fit({ force: true });
|
||||||
|
expect(resizeFrames(ws)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-announces an unchanged size on a reconnected socket', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
const ws2 = FakeSocket.instances[1];
|
||||||
|
|
||||||
|
ws2.open();
|
||||||
|
|
||||||
|
expect(resizeFrames(ws2)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies no 40-column floor: a pane at the divider clamp gets its real width on both sides', async () => {
|
||||||
|
const { tile, ws, term } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
FakeFit.proposed = { cols: 28, rows: 30 };
|
||||||
|
tile.fit();
|
||||||
|
|
||||||
|
expect(term.cols).toBe(28);
|
||||||
|
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 28, r: 30, v: 'desktop' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paneStarted() resends an unchanged size: the first one went out before there was a PTY', async () => {
|
||||||
|
const { tile, ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
tile.paneStarted();
|
||||||
|
expect(resizeFrames(ws)).toEqual([
|
||||||
|
{ t: 'z', c: 80, r: 24, v: 'desktop' },
|
||||||
|
{ t: 'z', c: 80, r: 24, v: 'desktop' },
|
||||||
|
]);
|
||||||
|
// Only once: the size is recorded again, so a plain fit does not repeat it.
|
||||||
|
tile.fit();
|
||||||
|
expect(resizeFrames(ws)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paneStarted() on a hidden tile sends nothing, and its next fit sends the size', async () => {
|
||||||
|
const { tile, ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
FakeFit.proposed = { cols: NaN, rows: NaN };
|
||||||
|
tile.paneStarted();
|
||||||
|
expect(resizeFrames(ws)).toHaveLength(1);
|
||||||
|
// Shown again (a zoom ends) at the same size it had: still sent.
|
||||||
|
FakeFit.proposed = { cols: 80, rows: 24 };
|
||||||
|
tile.fit();
|
||||||
|
expect(resizeFrames(ws)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paneStarted() before the socket opens sends nothing; the open sends the size once', async () => {
|
||||||
|
const { tile, ws } = await connectTile(makeApp());
|
||||||
|
tile.paneStarted();
|
||||||
|
expect(resizeFrames(ws)).toHaveLength(0);
|
||||||
|
ws.open();
|
||||||
|
expect(resizeFrames(ws)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports nothing while hidden (the fit addon measures NaN)', async () => {
|
||||||
|
const { tile, ws, term } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
FakeFit.proposed = { cols: NaN, rows: NaN };
|
||||||
|
tile.fit();
|
||||||
|
|
||||||
|
expect(resizeFrames(ws)).toHaveLength(1);
|
||||||
|
expect([term.cols, term.rows]).toEqual([80, 24]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stands aside for a session detached into its own window', async () => {
|
||||||
|
const { tile, ws } = await connectTile(makeApp(), { detachedSessions: new Set(['s-tile']) });
|
||||||
|
ws.open();
|
||||||
|
FakeFit.proposed = { cols: 120, rows: 40 };
|
||||||
|
|
||||||
|
tile.fit();
|
||||||
|
|
||||||
|
expect(resizeFrames(ws)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adopts the column count the PTY reports, keeping its own rows', async () => {
|
||||||
|
const { ws, term } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
ws.receive({ t: 'zc', c: 132, r: 50 });
|
||||||
|
|
||||||
|
expect([term.cols, term.rows]).toEqual([132, 24]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the pane alone when the PTY agrees on width', async () => {
|
||||||
|
const { ws, term } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
const before = term.resizes.length;
|
||||||
|
|
||||||
|
ws.receive({ t: 'zc', c: 80, r: 60 });
|
||||||
|
|
||||||
|
expect(term.resizes.length).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile links and paste follow THIS pane', () => {
|
||||||
|
it('registers the shared link provider on its own terminal, resolving its own session', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const register = vi.fn();
|
||||||
|
app.registerFilePathLinkProvider = register;
|
||||||
|
|
||||||
|
const { term } = await connectTile(app);
|
||||||
|
|
||||||
|
expect(register).toHaveBeenCalledTimes(1);
|
||||||
|
const target = register.mock.calls[0][0] as { terminal: unknown; getSessionId: () => string };
|
||||||
|
expect(target.terminal).toBe(term);
|
||||||
|
expect(target.getSessionId()).toBe('s-tile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes Ctrl+V into the paste trap with this pane's terminal and session", async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const paste = vi.fn();
|
||||||
|
app._handleImagePaste = paste;
|
||||||
|
const { term } = await connectTile(app);
|
||||||
|
|
||||||
|
const handled = term.keyHandler!({ type: 'keydown', key: 'v', ctrlKey: true, code: 'KeyV' });
|
||||||
|
|
||||||
|
expect(handled).toBe(false);
|
||||||
|
expect(paste).toHaveBeenCalledWith({ terminal: term, sessionId: 's-tile' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves Ctrl+Shift+V (voice input) out of the paste trap', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const paste = vi.fn();
|
||||||
|
app._handleImagePaste = paste;
|
||||||
|
const { term } = await connectTile(app);
|
||||||
|
|
||||||
|
term.keyHandler!({ type: 'keydown', key: 'V', ctrlKey: true, shiftKey: true, code: 'KeyV' });
|
||||||
|
|
||||||
|
expect(paste).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TerminalTile Ctrl+C copies through the primary pane's copy helpers", () => {
|
||||||
|
const ctrlC = (extra: Record<string, unknown> = {}) => ({
|
||||||
|
type: 'keydown',
|
||||||
|
key: 'c',
|
||||||
|
code: 'KeyC',
|
||||||
|
ctrlKey: true,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies THIS pane's selection; a failed write keeps it and focus returns to this pane", async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const copyText = vi.fn(async () => false);
|
||||||
|
app._copyText = copyText;
|
||||||
|
const { term } = await connectTile(app);
|
||||||
|
term.selection = 'npm run build';
|
||||||
|
|
||||||
|
const ev = ctrlC();
|
||||||
|
expect(term.keyHandler!(ev)).toBe(false);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(ev.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(copyText).toHaveBeenCalledWith('npm run build');
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith('Failed to copy', 'error');
|
||||||
|
// As in the primary pane: nothing was copied, so the selection stays for a retry.
|
||||||
|
expect(term.clearSelection).not.toHaveBeenCalled();
|
||||||
|
expect(term.selection).toBe('npm run build');
|
||||||
|
// The execCommand fallback focuses a temporary textarea; the keyboard comes back here.
|
||||||
|
expect(term.focus).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a successful write clears the selection (a second Ctrl+C interrupts) and refocuses this pane', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app._copyText = vi.fn(async () => true);
|
||||||
|
const { term } = await connectTile(app);
|
||||||
|
term.selection = 'npm run build';
|
||||||
|
|
||||||
|
expect(term.keyHandler!(ctrlC())).toBe(false);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith('Copied to clipboard', 'success');
|
||||||
|
expect(term.clearSelection).toHaveBeenCalled();
|
||||||
|
expect(term.focus).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with nothing selected, Ctrl+C reaches the PTY and Ctrl+Shift+C does not', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const copyText = vi.fn(async () => true);
|
||||||
|
app._copyText = copyText;
|
||||||
|
const { term } = await connectTile(app);
|
||||||
|
|
||||||
|
const plain = ctrlC();
|
||||||
|
expect(term.keyHandler!(plain)).toBe(true);
|
||||||
|
expect(plain.preventDefault).not.toHaveBeenCalled();
|
||||||
|
const shifted = ctrlC({ key: 'C', shiftKey: true });
|
||||||
|
expect(term.keyHandler!(shifted)).toBe(false);
|
||||||
|
expect(shifted.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(copyText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile claims the keyboard for the app-level shortcuts', () => {
|
||||||
|
it('focusing its terminal makes it the focused pane; destroy() hands the keyboard back', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { tile, term } = await connectTile(app);
|
||||||
|
expect(app._focusedTile ?? null).toBeNull();
|
||||||
|
|
||||||
|
term.focusTextarea();
|
||||||
|
expect(app._focusedTile).toBe(tile);
|
||||||
|
|
||||||
|
tile.destroy();
|
||||||
|
expect(app._focusedTile).toBeNull();
|
||||||
|
expect(term.focusListeners).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the server coming back kicks Pane B', () => {
|
||||||
|
it("handleInit's reconnect branch asks the split pane's tile to reconnect without waiting out its backoff", () => {
|
||||||
|
// handleInit needs a whole app to run, so the wiring is pinned by source;
|
||||||
|
// reconnectNow() itself is exercised above.
|
||||||
|
const appSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||||
|
const start = appSource.indexOf('if (keepTerminal && restoreId === previousActiveId');
|
||||||
|
const branch = appSource.slice(start, appSource.indexOf('} else {', start));
|
||||||
|
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
expect(branch).toContain('this._splitPane?.reconnectNow?.();');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
// test/split-pane-terminal-unit.test.ts
|
// test/terminal-tile-unit.test.ts
|
||||||
// Port: N/A (no server/browser; SplitTerminalPane is loaded via `vm`, like
|
// Port: N/A (no server/browser; TerminalTile is loaded via `vm`, like
|
||||||
// split-pane-auto-collapse-unit.test.ts loads the CodemanApp patches).
|
// split-pane-auto-collapse-unit.test.ts loads the CodemanApp patches).
|
||||||
//
|
//
|
||||||
// Unit coverage for the two SplitTerminalPane (terminal-split.js) fixes from
|
// Unit coverage for the two TerminalTile (terminal-tile.js, the split pane's
|
||||||
|
// Pane B until it moved out of terminal-split.js) fixes from
|
||||||
// the final review of #453 that need no browser: destroy() nulling EVERY socket
|
// the final review of #453 that need no browser: destroy() nulling EVERY socket
|
||||||
// handler (onclose used to survive it and fire its "disconnected" write into a
|
// handler (onclose used to survive it and fire its "disconnected" write into a
|
||||||
// pane already torn down), and the `{t:'r'}` server-refresh path being
|
// pane already torn down), and the `{t:'r'}` server-refresh path being
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
// The last block covers the scroll-to-top history pull: a burst of output leaves
|
// The last block covers the scroll-to-top history pull: a burst of output leaves
|
||||||
// a shell pane's xterm with about one screen of scrollback while tmux holds every
|
// a shell pane's xterm with about one screen of scrollback while tmux holds every
|
||||||
// line, and Pane B (a separate xterm from the primary pane) never went back to
|
// line, and Pane B (a separate xterm from the primary pane) never went back to
|
||||||
// ask. See _maybeLoadMoreHistory / _pullHistory in terminal-split.js.
|
// ask. See _maybeLoadMoreHistory / _pullHistory in terminal-tile.js.
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
@@ -69,13 +70,11 @@ type PaneUnderTest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
/** requestAnimationFrame stand-in: chunked writes queue here and are drained by hand. */
|
|
||||||
const rafQueue: Array<() => void> = [];
|
|
||||||
/** Recorded deadline timers (see the context's setTimeout); `fn` aborts the request. */
|
/** Recorded deadline timers (see the context's setTimeout); `fn` aborts the request. */
|
||||||
const deadlines: Array<{ fn: () => void; ms: number; cleared: boolean }> = [];
|
const deadlines: Array<{ fn: () => void; ms: number; cleared: boolean }> = [];
|
||||||
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-split.js'), 'utf8');
|
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-tile.js'), 'utf8');
|
||||||
|
|
||||||
function loadSplitTerminalPane() {
|
function loadTerminalTile() {
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
// The primary pane's row estimator, reduced to a line count: the pull only
|
// The primary pane's row estimator, reduced to a line count: the pull only
|
||||||
@@ -104,25 +103,24 @@ function loadSplitTerminalPane() {
|
|||||||
else clearTimeout(id as Parameters<typeof clearTimeout>[0]);
|
else clearTimeout(id as Parameters<typeof clearTimeout>[0]);
|
||||||
},
|
},
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
requestAnimationFrame: (fn: () => void) => rafQueue.push(fn),
|
|
||||||
// The constants.js globals the module reads at call time.
|
// The constants.js globals the module reads at call time.
|
||||||
TERMINAL_CHUNK_SIZE,
|
TERMINAL_CHUNK_SIZE,
|
||||||
TERMINAL_TAIL_SIZE,
|
TERMINAL_TAIL_SIZE,
|
||||||
});
|
});
|
||||||
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
|
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
|
||||||
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context);
|
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context);
|
||||||
return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
return (context.window as { TerminalTile: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
||||||
.SplitTerminalPane;
|
.TerminalTile;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SplitTerminalPane = loadSplitTerminalPane();
|
const TerminalTile = loadTerminalTile();
|
||||||
|
|
||||||
function makePane(
|
function makePane(
|
||||||
mode = 'claude',
|
mode = 'claude',
|
||||||
mount: unknown = {},
|
mount: unknown = {},
|
||||||
opts: { detachedSessions?: Set<string> } = {}
|
opts: { detachedSessions?: Set<string> } = {}
|
||||||
): PaneUnderTest & { terminal: FakeTerminal } {
|
): PaneUnderTest & { terminal: FakeTerminal } {
|
||||||
const pane = new SplitTerminalPane('s1', mount, { mode, ...opts });
|
const pane = new TerminalTile('s1', mount, { mode, ...opts });
|
||||||
pane.terminal = {
|
pane.terminal = {
|
||||||
// xterm invokes a write's callback once everything before it is parsed.
|
// xterm invokes a write's callback once everything before it is parsed.
|
||||||
write: vi.fn((_data: string, done?: () => void) => done?.()),
|
write: vi.fn((_data: string, done?: () => void) => done?.()),
|
||||||
@@ -175,19 +173,44 @@ function deferred<T>() {
|
|||||||
return { promise, resolve };
|
return { promise, resolve };
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMarker = (data: unknown) => typeof data === 'string' && data.includes('Pane B disconnected');
|
// Every marker variant (reconnecting, session ended, refused, taken over) starts the same way.
|
||||||
|
const isMarker = (data: unknown) => typeof data === 'string' && data.includes('[disconnected');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What reached the pane's screen. A replay also queues an empty write, only to
|
||||||
|
* hear through its callback that everything before it has been parsed
|
||||||
|
* (writeChunked); it puts nothing on screen, so it is left out.
|
||||||
|
*/
|
||||||
|
const screenWrites = (pane: { terminal: FakeTerminal }) =>
|
||||||
|
pane.terminal.write.mock.calls.map((call) => call[0]).filter((data) => data !== '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds xterm's write callbacks, as a real xterm still parsing a replay does:
|
||||||
|
* the replay stays in progress until `parse()` runs the ones held so far.
|
||||||
|
*/
|
||||||
|
function holdParses(pane: { terminal: FakeTerminal }) {
|
||||||
|
const held: Array<() => void> = [];
|
||||||
|
pane.terminal.write = vi.fn((_data: string, done?: () => void) => {
|
||||||
|
if (done) held.push(done);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
held,
|
||||||
|
parse: () => {
|
||||||
|
for (const done of held.splice(0)) done();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Lets every microtask the vm-side promise chain queued run. */
|
/** Lets every microtask the vm-side promise chain queued run. */
|
||||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchMock.mockReset();
|
fetchMock.mockReset();
|
||||||
rafQueue.length = 0;
|
|
||||||
deadlines.length = 0;
|
deadlines.length = 0;
|
||||||
clock = 0;
|
clock = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SplitTerminalPane.destroy()', () => {
|
describe('TerminalTile.destroy()', () => {
|
||||||
it('nulls every WebSocket handler, onclose included, before closing the socket', () => {
|
it('nulls every WebSocket handler, onclose included, before closing the socket', () => {
|
||||||
const pane = makePane();
|
const pane = makePane();
|
||||||
const terminal = pane.terminal;
|
const terminal = pane.terminal;
|
||||||
@@ -210,7 +233,7 @@ describe('SplitTerminalPane.destroy()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SplitTerminalPane server-refresh single-flight', () => {
|
describe('TerminalTile server-refresh single-flight', () => {
|
||||||
it('a refresh with nothing in flight clears and fetches straight away', async () => {
|
it('a refresh with nothing in flight clears and fetches straight away', async () => {
|
||||||
const pane = makePane();
|
const pane = makePane();
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse('one'));
|
fetchMock.mockResolvedValueOnce(jsonResponse('one'));
|
||||||
@@ -219,7 +242,8 @@ describe('SplitTerminalPane server-refresh single-flight', () => {
|
|||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1');
|
// The second argument carries the load's deadline (an AbortSignal).
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1', expect.anything());
|
||||||
expect(pane.terminal.write).toHaveBeenCalledWith('one');
|
expect(pane.terminal.write).toHaveBeenCalledWith('one');
|
||||||
expect(pane._bufferLoading).toBe(false);
|
expect(pane._bufferLoading).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -231,7 +255,7 @@ describe('SplitTerminalPane server-refresh single-flight', () => {
|
|||||||
pane._refreshBuffer();
|
pane._refreshBuffer();
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?tail=${1024 * 1024}`);
|
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?tail=${1024 * 1024}`, expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refreshes arriving mid-fetch neither clear nor fetch again, and run ONCE after the replay lands', async () => {
|
it('refreshes arriving mid-fetch neither clear nor fetch again, and run ONCE after the replay lands', async () => {
|
||||||
@@ -262,40 +286,88 @@ describe('SplitTerminalPane server-refresh single-flight', () => {
|
|||||||
second.resolve(jsonResponse('replay-2'));
|
second.resolve(jsonResponse('replay-2'));
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
expect(pane.terminal.write).toHaveBeenLastCalledWith('replay-2');
|
expect(screenWrites(pane).at(-1)).toBe('replay-2');
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(pane._bufferLoading).toBe(false);
|
expect(pane._bufferLoading).toBe(false);
|
||||||
expect(pane._bufferRefreshPending).toBe(false);
|
expect(pane._bufferRefreshPending).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('holds the flag across the chunked write, not just the fetch', async () => {
|
it('queues the whole replay at once and holds the flag until xterm has parsed it', async () => {
|
||||||
const pane = makePane();
|
const pane = makePane();
|
||||||
// Three chunks: two full ones plus a tail, so the last two are queued on
|
const xterm = holdParses(pane);
|
||||||
// requestAnimationFrame and the replay is mid-write after the fetch lands.
|
// Three slices: two full ones plus a tail.
|
||||||
const big = 'x'.repeat(TERMINAL_CHUNK_SIZE * 2 + 5);
|
const big = 'x'.repeat(TERMINAL_CHUNK_SIZE * 2 + 5);
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse(big));
|
fetchMock.mockResolvedValueOnce(jsonResponse(big));
|
||||||
|
|
||||||
pane._refreshBuffer();
|
pane._refreshBuffer();
|
||||||
await settle();
|
await settle();
|
||||||
expect(pane.terminal.write).toHaveBeenCalledTimes(1);
|
// Every slice queued at once, then the empty write whose callback ends the
|
||||||
expect(rafQueue).toHaveLength(1);
|
// replay: nothing waits for an animation frame.
|
||||||
|
expect(pane.terminal.write.mock.calls.map((call) => call[0].length)).toEqual([
|
||||||
|
TERMINAL_CHUNK_SIZE,
|
||||||
|
TERMINAL_CHUNK_SIZE,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
// xterm is still parsing: the replay, and with it the flag, is not done.
|
||||||
expect(pane._bufferLoading).toBe(true);
|
expect(pane._bufferLoading).toBe(true);
|
||||||
|
|
||||||
// A refresh mid-write must not clear the terminal under the chunks still
|
// A refresh mid-parse must not clear the terminal under the replay, nor
|
||||||
// to come, nor start a second fetch.
|
// start a second fetch.
|
||||||
pane._refreshBuffer();
|
pane._refreshBuffer();
|
||||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse('after'));
|
fetchMock.mockResolvedValueOnce(jsonResponse('after'));
|
||||||
rafQueue.shift()!();
|
xterm.parse();
|
||||||
rafQueue.shift()!();
|
|
||||||
await settle();
|
await settle();
|
||||||
|
// Parsed: the coalesced refresh runs now, once.
|
||||||
expect(pane.terminal.write).toHaveBeenCalledTimes(4);
|
|
||||||
expect(pane.terminal.write).toHaveBeenLastCalledWith('after');
|
|
||||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
|
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(pane._bufferLoading).toBe(true);
|
||||||
|
|
||||||
|
xterm.parse();
|
||||||
|
await settle();
|
||||||
|
expect(screenWrites(pane).at(-1)).toBe('after');
|
||||||
|
expect(pane._bufferLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queues a replay larger than the window one window at a time', async () => {
|
||||||
|
// xterm's write queue throws past 50 MB, and an unbounded `full=1` capture
|
||||||
|
// can reach the server's 32 MB: at most 1 MiB is queued before xterm has
|
||||||
|
// parsed what came before it.
|
||||||
|
const pane = makePane();
|
||||||
|
const xterm = holdParses(pane);
|
||||||
|
fetchMock.mockResolvedValueOnce(jsonResponse('z'.repeat(TERMINAL_TAIL_SIZE + 5)));
|
||||||
|
|
||||||
|
pane._refreshBuffer();
|
||||||
|
await settle();
|
||||||
|
const queued = () => pane.terminal.write.mock.calls.reduce((n, call) => n + call[0].length, 0);
|
||||||
|
expect(queued()).toBe(TERMINAL_TAIL_SIZE);
|
||||||
|
|
||||||
|
xterm.parse();
|
||||||
|
await settle();
|
||||||
|
expect(queued()).toBe(TERMINAL_TAIL_SIZE + 5);
|
||||||
|
expect(pane._bufferLoading).toBe(true);
|
||||||
|
|
||||||
|
xterm.parse();
|
||||||
|
await settle();
|
||||||
|
expect(pane._bufferLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a replay still parsing when the pane is destroyed settles at once', async () => {
|
||||||
|
// A disposed xterm never runs a write callback: without destroy() settling
|
||||||
|
// the replay, the flag (and in the grid the one load queue) would wait forever.
|
||||||
|
const pane = makePane();
|
||||||
|
holdParses(pane);
|
||||||
|
fetchMock.mockResolvedValueOnce(jsonResponse('replay'));
|
||||||
|
|
||||||
|
pane._refreshBuffer();
|
||||||
|
await settle();
|
||||||
|
expect(pane._bufferLoading).toBe(true);
|
||||||
|
|
||||||
|
pane.destroy();
|
||||||
|
await settle();
|
||||||
expect(pane._bufferLoading).toBe(false);
|
expect(pane._bufferLoading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -329,7 +401,7 @@ describe('SplitTerminalPane server-refresh single-flight', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SplitTerminalPane scroll-to-top history pull', () => {
|
describe('TerminalTile scroll-to-top history pull', () => {
|
||||||
it('a shell pane at the top pulls a bounded window of full history and replays it', async () => {
|
it('a shell pane at the top pulls a bounded window of full history and replays it', async () => {
|
||||||
const pane = makePane('shell');
|
const pane = makePane('shell');
|
||||||
const term = pane.terminal;
|
const term = pane.terminal;
|
||||||
@@ -500,6 +572,7 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
|||||||
it('holds live output during the replay and replays only what arrived after the capture', async () => {
|
it('holds live output during the replay and replays only what arrived after the capture', async () => {
|
||||||
const pane = makePane('shell');
|
const pane = makePane('shell');
|
||||||
const term = pane.terminal;
|
const term = pane.terminal;
|
||||||
|
const xterm = holdParses(pane);
|
||||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||||
fetchMock.mockReturnValueOnce(response.promise);
|
fetchMock.mockReturnValueOnce(response.promise);
|
||||||
|
|
||||||
@@ -515,24 +588,26 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
|||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
// 200 rows (more than the pane holds, so it replays) of 400 columns each:
|
// 200 rows (more than the pane holds, so it replays) of 400 columns each:
|
||||||
// three chunks, which leaves the replay mid-write once the fetch lands.
|
// three chunks, still being parsed once the fetch lands.
|
||||||
const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n');
|
const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n');
|
||||||
expect(bigReplay.length).toBeGreaterThan(TERMINAL_CHUNK_SIZE * 2);
|
expect(bigReplay.length).toBeGreaterThan(TERMINAL_CHUNK_SIZE * 2);
|
||||||
clock = 2; // the response arrives: this is the cutoff
|
clock = 2; // the response arrives: this is the cutoff
|
||||||
response.resolve(jsonResponse(bigReplay));
|
response.resolve(jsonResponse(bigReplay));
|
||||||
await settle();
|
await settle();
|
||||||
expect(rafQueue).toHaveLength(1);
|
expect(xterm.held).toHaveLength(1);
|
||||||
|
|
||||||
// Arrives while the snapshot is still being written: must not land under it.
|
// Arrives while the snapshot is still being parsed: must not land under it.
|
||||||
clock = 3;
|
clock = 3;
|
||||||
pane._onLiveOutput('late');
|
pane._onLiveOutput('late');
|
||||||
expect(term.write).not.toHaveBeenCalledWith('late');
|
expect(term.write).not.toHaveBeenCalledWith('late');
|
||||||
|
|
||||||
rafQueue.shift()!();
|
// The replay parsed, then the pull's own settle write before it scrolls.
|
||||||
rafQueue.shift()!();
|
xterm.parse();
|
||||||
|
await settle();
|
||||||
|
xterm.parse();
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
const written = term.write.mock.calls.map((call) => call[0]);
|
const written = screenWrites(pane);
|
||||||
// 'early' went out before the reset, so the replay wiped it and it is not repeated.
|
// 'early' went out before the reset, so the replay wiped it and it is not repeated.
|
||||||
expect(written.indexOf('early')).toBeLessThan(written.indexOf('\x1bc'));
|
expect(written.indexOf('early')).toBeLessThan(written.indexOf('\x1bc'));
|
||||||
expect(written.filter((w) => w === 'early')).toHaveLength(1);
|
expect(written.filter((w) => w === 'early')).toHaveLength(1);
|
||||||
@@ -717,12 +792,21 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
|||||||
it('connect() installs the wheel listener (static guard)', () => {
|
it('connect() installs the wheel listener (static guard)', () => {
|
||||||
// connect() needs a whole xterm to run, so its wiring is pinned by source
|
// connect() needs a whole xterm to run, so its wiring is pinned by source
|
||||||
// rather than executed; the listener's behaviour is exercised above.
|
// rather than executed; the listener's behaviour is exercised above.
|
||||||
const connect = SOURCE.slice(SOURCE.indexOf('async connect()'), SOURCE.indexOf('async _loadBuffer()'));
|
const start = SOURCE.indexOf('async connect()');
|
||||||
|
const end = SOURCE.indexOf('async _loadBuffer(');
|
||||||
|
// Both anchors must resolve, or the slice runs to the end of the file and
|
||||||
|
// every check below passes against code outside connect().
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
expect(end).toBeGreaterThan(start);
|
||||||
|
const connect = SOURCE.slice(start, end);
|
||||||
expect(connect).toContain('this._installWheelListener();');
|
expect(connect).toContain('this._installWheelListener();');
|
||||||
expect(connect).toContain('this._onLiveClear();');
|
expect(connect).toContain('this._onLiveClear();');
|
||||||
expect(connect).not.toContain('this.terminal.clear();');
|
expect(connect).not.toContain('this.terminal.clear();');
|
||||||
// The tests below drive the close through _onSocketClosed() directly.
|
// The tests below drive the close through _onSocketClosed() directly; the
|
||||||
expect(connect).toContain('this.ws.onclose = () => this._onSocketClosed();');
|
// socket's own handler forwards the close event (and its code) there, and
|
||||||
|
// only for the current socket (_openSocket).
|
||||||
|
expect(connect).toContain('this._onSocketClosed(event);');
|
||||||
|
expect(connect).toMatch(/ws\.onclose = \(event\) => \{\s*if \(ws !== this\.ws\) return;/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a close with no pull running writes the marker straight away', () => {
|
it('a close with no pull running writes the marker straight away', () => {
|
||||||
@@ -746,9 +830,9 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
|||||||
void pane._pullHistory();
|
void pane._pullHistory();
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
const marker = expect.stringContaining('Pane B disconnected');
|
const marker = expect.stringContaining('[disconnected');
|
||||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||||
expect(writes.at(-1)).toEqual(expect.stringMatching(/Pane B disconnected/));
|
expect(writes.at(-1)).toEqual(expect.stringMatching(/\[disconnected/));
|
||||||
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
|
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -825,7 +909,7 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
|||||||
second.resolve(jsonResponse('second'));
|
second.resolve(jsonResponse('second'));
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
const writes = screenWrites(pane);
|
||||||
expect(writes.at(-1)).toSatisfy(isMarker);
|
expect(writes.at(-1)).toSatisfy(isMarker);
|
||||||
expect(writes.lastIndexOf('second')).toBe(writes.length - 2);
|
expect(writes.lastIndexOf('second')).toBe(writes.length - 2);
|
||||||
expect(writes.filter(isMarker)).toHaveLength(1);
|
expect(writes.filter(isMarker)).toHaveLength(1);
|
||||||
@@ -881,25 +965,27 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
it('a close during the chunked replay writes exactly one marker, at the end', async () => {
|
it('a close during the replay writes exactly one marker, at the end', async () => {
|
||||||
const pane = makePane('shell');
|
const pane = makePane('shell');
|
||||||
|
const xterm = holdParses(pane);
|
||||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||||
fetchMock.mockReturnValueOnce(response.promise);
|
fetchMock.mockReturnValueOnce(response.promise);
|
||||||
|
|
||||||
const pull = pane._pullHistory();
|
const pull = pane._pullHistory();
|
||||||
// Three chunks, so the replay is still mid-write once the fetch lands.
|
// Three chunks, still being parsed once the fetch lands.
|
||||||
const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n');
|
const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n');
|
||||||
response.resolve(jsonResponse(bigReplay));
|
response.resolve(jsonResponse(bigReplay));
|
||||||
await settle();
|
await settle();
|
||||||
expect(rafQueue).toHaveLength(1);
|
expect(xterm.held).toHaveLength(1);
|
||||||
|
|
||||||
// Written now, the marker would land between two chunks of recovered history.
|
// Written now, the marker would land above the recovered history's end.
|
||||||
pane._onSocketClosed();
|
pane._onSocketClosed();
|
||||||
rafQueue.shift()!();
|
xterm.parse();
|
||||||
rafQueue.shift()!();
|
await settle();
|
||||||
|
xterm.parse();
|
||||||
await pull;
|
await pull;
|
||||||
|
|
||||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
const writes = screenWrites(pane);
|
||||||
expect(writes[0]).toBe('\x1bc');
|
expect(writes[0]).toBe('\x1bc');
|
||||||
expect(writes.filter(isMarker)).toHaveLength(1);
|
expect(writes.filter(isMarker)).toHaveLength(1);
|
||||||
expect(isMarker(writes.at(-1))).toBe(true);
|
expect(isMarker(writes.at(-1))).toBe(true);
|
||||||
@@ -1003,7 +1089,7 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
|||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
for (const call of pane.terminal.write.mock.calls) {
|
for (const call of pane.terminal.write.mock.calls) {
|
||||||
expect(call[0]).toEqual(expect.not.stringMatching(/Pane B disconnected/));
|
expect(call[0]).toEqual(expect.not.stringMatching(/\[disconnected/));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview A tile whose session is not attached shows an Attach overlay.
|
||||||
|
*
|
||||||
|
* Three ways a tile has no live terminal: the session has no PTY attached
|
||||||
|
* (`pid === null`, e.g. restored after a server restart), the agent exited in a
|
||||||
|
* live pane (`paneExit`), or the server closed the tile's socket because the
|
||||||
|
* session exited (close code 4009). The tile body then shows why, with an
|
||||||
|
* Attach button, over the terminal (never resizing it).
|
||||||
|
*
|
||||||
|
* Attach is exactly the single view's automatic re-attach: `POST /interactive`
|
||||||
|
* (or `/shell` for a shell) with NO body, at most one in flight per session
|
||||||
|
* (the route has no in-flight guard of its own). A tripped PTY-exit breaker
|
||||||
|
* (`respawnBlocked`) goes through the same confirm before `clearBreaker: true`,
|
||||||
|
* and nothing automatic ever sends that. On success the tile is remounted onto
|
||||||
|
* the new pane (a socket stopped for good cannot reconnect).
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeTile,
|
||||||
|
fetchSpy,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
windowStub,
|
||||||
|
type GridApp,
|
||||||
|
tileEl,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c'];
|
||||||
|
const overlayOf = (id: string) => tileEl(id).children[1].children.find((c) => c.className === 'tile-attach') ?? null;
|
||||||
|
const visible = (id: string) => !!overlayOf(id) && !overlayOf(id)!.hidden;
|
||||||
|
const textOf = (id: string) => overlayOf(id)!.children[0].textContent;
|
||||||
|
const attachButton = (id: string) => overlayOf(id)!.children[1];
|
||||||
|
const tilesFor = (id: string) => FakeTile.all.filter((t) => t.sessionId === id);
|
||||||
|
|
||||||
|
function gridWith(setup: (app: GridApp) => void = () => {}): GridApp {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
setup(app);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
fetchSpy.mockReset();
|
||||||
|
fetchSpy.mockImplementation(async () => ({ ok: true, json: async () => ({}) }));
|
||||||
|
windowStub.confirm = vi.fn(() => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the overlay shows', () => {
|
||||||
|
it('a session with no PTY attached', () => {
|
||||||
|
gridWith((app) => (app.sessions.get('s-b').pid = null));
|
||||||
|
expect(visible('s-b')).toBe(true);
|
||||||
|
expect(textOf('s-b')).toBe('Not attached');
|
||||||
|
expect(attachButton('s-b').textContent).toBe('Attach');
|
||||||
|
expect(visible('s-a')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an agent that exited in a live pane: the reason, and no Attach (the server cannot restart it in place)', async () => {
|
||||||
|
const app = gridWith((a) => (a.sessions.get('s-c').paneExit = { status: 2 }));
|
||||||
|
expect(visible('s-c')).toBe(true);
|
||||||
|
expect(textOf('s-c')).toBe('The agent exited (2)');
|
||||||
|
expect(attachButton('s-c').hidden).toBe(true);
|
||||||
|
expect(overlayOf('s-c')!.children[2].hidden).toBe(false);
|
||||||
|
expect(await app.attachTileSession('s-c')).toBe(false);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a session with no PTY gets the button, not the close hint', () => {
|
||||||
|
gridWith((app) => (app.sessions.get('s-b').pid = null));
|
||||||
|
expect(attachButton('s-b').hidden).toBe(false);
|
||||||
|
expect(overlayOf('s-b')!.children[2].hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a socket closed because the session exited (4009) keeps the tile and shows it', () => {
|
||||||
|
const app = gridWith();
|
||||||
|
const tile = tilesFor('s-b')[0];
|
||||||
|
tile._stoppedCode = 4009;
|
||||||
|
tile.onExit?.(4009);
|
||||||
|
expect(app._tileGrid.ids).toContain('s-b');
|
||||||
|
expect(visible('s-b')).toBe(true);
|
||||||
|
expect(textOf('s-b')).toBe('The session ended');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a live session shows none, and the overlay leaves once the session is back', () => {
|
||||||
|
const app = gridWith((a) => (a.sessions.get('s-b').pid = null));
|
||||||
|
app.sessions.get('s-b').pid = 4242;
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(visible('s-b')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sits over the body, never inside the header (the body keeps its size)', () => {
|
||||||
|
gridWith((app) => (app.sessions.get('s-b').pid = null));
|
||||||
|
expect(tileEl('s-b').children[1].className).toBe('tile-body');
|
||||||
|
expect(overlayOf('s-b')?.parentElement).toBe(tileEl('s-b').children[1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Attach', () => {
|
||||||
|
it('POSTs /interactive with NO body, then remounts the tile onto the new pane', async () => {
|
||||||
|
const app = gridWith((a) => (a.sessions.get('s-b').pid = null));
|
||||||
|
const before = tilesFor('s-b')[0];
|
||||||
|
attachButton('s-b').dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith('/api/sessions/s-b/interactive', { method: 'POST' });
|
||||||
|
expect(before.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
const after = tilesFor('s-b')[1];
|
||||||
|
expect(after.connect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app._tileFor('s-b')).toBe(after);
|
||||||
|
// The server has not reported the new pid yet: no overlay meanwhile.
|
||||||
|
expect(visible('s-b')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a shell session attaches through /shell', async () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.sessions.get('s-c').mode = 'shell';
|
||||||
|
app.sessions.get('s-c').pid = null;
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
await app.attachTileSession('s-c');
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith('/api/sessions/s-c/shell', { method: 'POST' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('at most one attach in flight per session', async () => {
|
||||||
|
const app = gridWith((a) => (a.sessions.get('s-b').pid = null));
|
||||||
|
let release: (v: unknown) => void = () => {};
|
||||||
|
fetchSpy.mockImplementation(() => new Promise((r) => (release = r)));
|
||||||
|
const first = app.attachTileSession('s-b');
|
||||||
|
attachButton('s-b').dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
void app.attachTileSession('s-b');
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(textOf('s-b')).toBe('Attaching…');
|
||||||
|
expect(attachButton('s-b').disabled).toBe(true);
|
||||||
|
release({ ok: true });
|
||||||
|
await first;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a refusal in the envelope of a 200 is a failure, not a success', async () => {
|
||||||
|
const app = gridWith((a) => (a.sessions.get('s-b').pid = null));
|
||||||
|
fetchSpy.mockImplementation(async () => ({ ok: true, json: async () => ({ success: false, error: 'busy' }) }));
|
||||||
|
expect(await app.attachTileSession('s-b')).toBe(false);
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith('Could not attach the session', 'error');
|
||||||
|
expect(tilesFor('s-b')).toHaveLength(1);
|
||||||
|
expect(visible('s-b')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a failed attach keeps the overlay and says so', async () => {
|
||||||
|
const app = gridWith((a) => (a.sessions.get('s-b').pid = null));
|
||||||
|
fetchSpy.mockImplementation(async () => ({ ok: false, json: async () => ({}) }));
|
||||||
|
expect(await app.attachTileSession('s-b')).toBe(false);
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith('Could not attach the session', 'error');
|
||||||
|
expect(visible('s-b')).toBe(true);
|
||||||
|
expect(textOf('s-b')).toBe('Not attached');
|
||||||
|
expect(tilesFor('s-b')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the PTY-exit breaker', () => {
|
||||||
|
function tripped() {
|
||||||
|
return gridWith((a) => {
|
||||||
|
a.sessions.get('s-b').pid = null;
|
||||||
|
a.sessions.get('s-b').respawnBlocked = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('asks first, and only a yes sends clearBreaker', async () => {
|
||||||
|
const app = tripped();
|
||||||
|
await app.attachTileSession('s-b');
|
||||||
|
expect(windowStub.confirm).toHaveBeenCalledWith('s-b was stopped after crashing repeatedly. Restart it?');
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith('/api/sessions/s-b/interactive', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ clearBreaker: true }),
|
||||||
|
});
|
||||||
|
expect(app.sessions.get('s-b').respawnBlocked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a no sends nothing at all', async () => {
|
||||||
|
const app = tripped();
|
||||||
|
windowStub.confirm = vi.fn(() => false);
|
||||||
|
await app.attachTileSession('s-b');
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
expect(visible('s-b')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nothing automatic attaches: opening the grid and refreshing headers POST nothing', () => {
|
||||||
|
const app = tripped();
|
||||||
|
app._renderTileChrome();
|
||||||
|
app._reconcileTileGrid();
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
expect(windowStub.confirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview New sessions started from THIS tab's Run join the open grid.
|
||||||
|
*
|
||||||
|
* Every Run path calls `_ensureCreatedSessionVisible` for each session it
|
||||||
|
* created, then `selectSession(firstId)` (a human selection). With the grid open,
|
||||||
|
* `_joinTileGridFromRun` (called from there) adds each new session to the next
|
||||||
|
* free slot, so that selection focuses its tile instead of leaving the grid.
|
||||||
|
* Sessions created elsewhere (agents, other devices, cron) arrive only by
|
||||||
|
* `session:created` and never join. A grid already holding what the window
|
||||||
|
* fits does not take it: Run's selection then shows it alone, with a hint.
|
||||||
|
*
|
||||||
|
* A tile that joined before its pane existed resends its size once the pid
|
||||||
|
* appears (the server spawned the pane at its own default size).
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts); the Run
|
||||||
|
* hook in session-ui.js is pinned at the source. Port: N/A.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { FakeTile, makeGridApp, resetGridHarness, section, type GridApp, tileEl } from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b'];
|
||||||
|
const addSession = (app: GridApp, id: string, workingDir = '/w') =>
|
||||||
|
app.sessions.set(id, { id, name: id, mode: 'claude', pid: null, workingDir });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Run from this tab', () => {
|
||||||
|
it('the Run hook is where every Run path makes its new session visible', () => {
|
||||||
|
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||||
|
const helper = src.slice(src.indexOf('async _ensureCreatedSessionVisible('), src.indexOf('async run() {'));
|
||||||
|
expect(helper).toContain('this._joinTileGridFromRun?.(sessionId);');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with the grid open, the new session joins the next free slot; Run then focuses its tile', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
addSession(app, 's-new');
|
||||||
|
expect(app._joinTileGridFromRun('s-new')).toBe(true);
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-new']);
|
||||||
|
// What Run does next: a human selection of the session it created.
|
||||||
|
app.selectSession('s-new');
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
expect(app.activeSessionId).toBe('s-new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no Attach overlay flashes on the new tile while Run starts its pane', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
addSession(app, 's-new');
|
||||||
|
app._joinTileGridFromRun('s-new');
|
||||||
|
const tile = tileEl('s-new');
|
||||||
|
const overlay = tile.children[1].children.find((c) => c.className === 'tile-attach');
|
||||||
|
expect(!overlay || overlay.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with the grid closed, nothing joins', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
addSession(app, 's-new');
|
||||||
|
expect(app._joinTileGridFromRun('s-new')).toBe(false);
|
||||||
|
expect(app._tileGrid?.open ?? false).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a grid that already holds what the window fits does not take it, and says so', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
addSession(app, 's-new');
|
||||||
|
section.getBoundingClientRect = () => ({ width: 1000, height: 400, top: 0, left: 0, right: 1000, bottom: 400 });
|
||||||
|
expect(app._joinTileGridFromRun('s-new')).toBe(false);
|
||||||
|
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||||
|
expect(app._tileGrid.ids).toEqual(IDS);
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('opens on its own'), 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a session created ELSEWHERE (session:created) never joins', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app.renderSessionTabs = vi.fn();
|
||||||
|
app.saveSessionOrder = vi.fn();
|
||||||
|
app.updateCost = vi.fn();
|
||||||
|
app._onSessionCreated({ id: 's-agent', name: 's-agent', mode: 'claude', pid: 1, workingDir: '/w' });
|
||||||
|
expect(app.sessions.has('s-agent')).toBe(true);
|
||||||
|
expect(app._tileGrid.ids).toEqual(IDS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a pane that starts after its tile connected (#464)', () => {
|
||||||
|
// The tile sent its size before there was a PTY; the server dropped it and
|
||||||
|
// spawned the pane at its default size, so the size must go out again.
|
||||||
|
const tileOf = (id: string) => FakeTile.all.filter((t) => t.sessionId === id).at(-1)!;
|
||||||
|
const setPid = (app: GridApp, id: string, pid: number | null) =>
|
||||||
|
app.sessions.set(id, { ...app.sessions.get(id), pid });
|
||||||
|
|
||||||
|
it('resends the size once when the pid appears, and again only for a new PTY', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
addSession(app, 's-new');
|
||||||
|
app._joinTileGridFromRun('s-new');
|
||||||
|
const tile = tileOf('s-new');
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(tile.paneStarted).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
setPid(app, 's-new', 4242);
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(tile.paneStarted).toHaveBeenCalledTimes(1);
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(tile.paneStarted).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The pane went away and a new one started (an Attach, a respawned pane).
|
||||||
|
setPid(app, 's-new', null);
|
||||||
|
app._renderTileChrome();
|
||||||
|
setPid(app, 's-new', 4343);
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(tile.paneStarted).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tile made for a session that already runs never asks', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app._renderTileChrome();
|
||||||
|
app._renderTileChrome();
|
||||||
|
for (const id of IDS) expect(tileOf(id).paneStarted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The grid holds at most TILE_GRID_MAX tiles: 6, owner decision 7
|
||||||
|
* (docs/tile-grid-plan.md). Six was tested smooth on a real desktop; nine missed
|
||||||
|
* the headless frame bar. The layout table still covers 7 to 9 (unreachable).
|
||||||
|
*
|
||||||
|
* Every way into the grid stops at the cap even where the window would fit
|
||||||
|
* nine (the harness window is 2400x1200): opening, adding, a session Run
|
||||||
|
* makes, Ctrl/Cmd+click, the count menu and "Open group as tiles".
|
||||||
|
* The texts say which limit binds: "at most 6" for the cap, "what this window
|
||||||
|
* fits" for a smaller window.
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { FakeEl, body, bySelector, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const EIGHT = Array.from({ length: 8 }, (_, i) => `s-${i + 1}`);
|
||||||
|
const SIX = EIGHT.slice(0, 6);
|
||||||
|
const AT_MOST = 'The grid holds at most 6 tiles';
|
||||||
|
|
||||||
|
let wrapRect = { width: 2400, height: 1200 };
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
wrapRect = { width: 2400, height: 1200 };
|
||||||
|
const wrap = new FakeEl();
|
||||||
|
wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height });
|
||||||
|
bySelector.set('.terminal-wrap', wrap);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fullGrid(): GridApp {
|
||||||
|
const app = makeGridApp(EIGHT);
|
||||||
|
app.openTileGrid(EIGHT);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the cap', () => {
|
||||||
|
it('is 6, worded as the cap where the window would fit more', () => {
|
||||||
|
const app = makeGridApp(EIGHT);
|
||||||
|
expect(app._tileGridLimit()).toEqual({ capacity: 6, full: AT_MOST });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a smaller window is worded by the window', () => {
|
||||||
|
const app = makeGridApp(EIGHT);
|
||||||
|
wrapRect = { width: 1200, height: 900 };
|
||||||
|
expect(app._tileGridLimit()).toEqual({
|
||||||
|
capacity: 4,
|
||||||
|
full: 'The grid already holds what this window fits (4)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opening on eight sessions shows the first six', () => {
|
||||||
|
const app = fullGrid();
|
||||||
|
expect(app._tileGrid.ids).toEqual(SIX);
|
||||||
|
expect(app._tileGrid.tiles.size).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a full grid takes no more tiles', () => {
|
||||||
|
const app = fullGrid();
|
||||||
|
expect(app.addTile('s-7')).toBe(false);
|
||||||
|
expect(app._tileGrid.ids).toEqual(SIX);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('every way in stops at the cap', () => {
|
||||||
|
it('a session Run makes opens on its own, with a toast', () => {
|
||||||
|
const app = fullGrid();
|
||||||
|
expect(app._joinTileGridFromRun('s-7')).toBe(false);
|
||||||
|
expect(app._tileGrid.ids).toEqual(SIX);
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith(`${AT_MOST}: the new session opens on its own`, 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl/Cmd+click on another tab says the grid is full', () => {
|
||||||
|
const app = fullGrid();
|
||||||
|
expect(app.addSessionToTiles('s-7')).toBe(true);
|
||||||
|
expect(app._tileGrid.ids).toEqual(SIX);
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith(AT_MOST, 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the count menu offers nothing past the cap, and its 6 opens six of eight', () => {
|
||||||
|
const app = makeGridApp(EIGHT);
|
||||||
|
app.openTileCountMenu({ preventDefault: vi.fn() });
|
||||||
|
const menu = body.children.find((c) => c.id === 'tileCountMenu')!;
|
||||||
|
const items = menu.children.filter((c) => c.attrs.role === 'menuitemradio');
|
||||||
|
expect(items.map((i) => Number(i.dataset.count))).toEqual([2, 4, 6]);
|
||||||
|
// The cap binds, not the window: nothing greyed out, no "fits" line.
|
||||||
|
expect(items.every((i) => !i.disabled)).toBe(true);
|
||||||
|
expect(menu.children.some((c) => c.className === 'tile-count-hint')).toBe(false);
|
||||||
|
items.at(-1)!.dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
expect(app._tileGrid.ids).toHaveLength(6);
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-other', ...EIGHT.slice(0, 5)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Open group as tiles" shows the first six of a larger group', () => {
|
||||||
|
const app = makeGridApp(EIGHT);
|
||||||
|
app.tabLayout = { groups: [{ id: 'g', name: 'G', refs: EIGHT.map((id) => ({ kind: 'session', id })) }] };
|
||||||
|
app.openGroupAsTiles('g');
|
||||||
|
expect(app._tileGrid.ids).toEqual(SIX);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The grid as cells (owner: "the empty tab doesnt always have to
|
||||||
|
* be the last one ... it can also be tab nr 4 or 3"). `grid.cells` holds a
|
||||||
|
* session id or `null` per cell and is the one source of truth; `grid.ids` is
|
||||||
|
* the tiles in reading order, derived. The shape still comes from the tile
|
||||||
|
* count (the layout table), and the cap counts tiles, never empty cells.
|
||||||
|
*
|
||||||
|
* - Removing a tile without a shape change leaves its cell empty where it was.
|
||||||
|
* - Adding one without a shape change takes the first empty cell (or the cell a
|
||||||
|
* tab was dropped on: tile-grid-move.test.ts).
|
||||||
|
* - A shape change keeps each tile's row and column when all fit (2x2 growing
|
||||||
|
* to 3x2), else the tiles pack in reading order (fitTileCells).
|
||||||
|
* - Focus never lands on an empty cell: the Alt+Shift+Arrow chords skip it,
|
||||||
|
* and Ctrl+Tab / Alt+[ ] cycle through the tiles only.
|
||||||
|
* - `codeman:tile-grid` stores the cells (ids, `null` for a hole); a reload
|
||||||
|
* brings the holes back when the shape is the same, a session gone by then
|
||||||
|
* leaves its cell empty, a different shape packs, and the old packed format
|
||||||
|
* reads unchanged. A followed `#session=` link keeps the holes.
|
||||||
|
* - A divider drag refits only the tiles in its two tracks, holes skipped.
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
FakeTile,
|
||||||
|
documentAddEventListener,
|
||||||
|
localStore,
|
||||||
|
makeGridApp,
|
||||||
|
rafCallbacks,
|
||||||
|
resetGridHarness,
|
||||||
|
section,
|
||||||
|
type GridApp,
|
||||||
|
tileEl,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
|
||||||
|
const FIVE = SIX.slice(0, 5);
|
||||||
|
const KEY = 'codeman:tile-grid';
|
||||||
|
const slots = () => section.children.filter((el) => el.className.split(' ').includes('tile-slot'));
|
||||||
|
const stored = () => JSON.parse(localStore.get(KEY) ?? 'null');
|
||||||
|
const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed) as FakeTile;
|
||||||
|
const place = (id: string) => [tileEl(id).style.gridColumn, tileEl(id).style.gridRow];
|
||||||
|
|
||||||
|
// Under 1800px wide three tiles take a 2x2 (over it, 3x1).
|
||||||
|
const narrow = () => {
|
||||||
|
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
function openGrid(ids: string[], focusedId = ids[0], all: string[] = SIX): GridApp {
|
||||||
|
const app = makeGridApp(all);
|
||||||
|
app.openTileGrid(ids, { focusedId });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCells(app: GridApp, cells: Array<string | null>) {
|
||||||
|
app._tileGrid.cells = cells.slice();
|
||||||
|
app._applyTileLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
narrow();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the cells are the source of truth', () => {
|
||||||
|
it('ids are the tiles in reading order, derived from the cells (holes skipped)', () => {
|
||||||
|
const app = openGrid(FIVE);
|
||||||
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
||||||
|
expect(app._tileGrid.ids).toEqual(FIVE);
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
||||||
|
// A tile sits in its cell, the slot in the empty one.
|
||||||
|
expect(place('s-b')).toEqual(['5', '1']);
|
||||||
|
expect([slots()[0].style.gridColumn, slots()[0].style.gridRow]).toEqual(['3', '1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opening packs from the first cell and pads the shape with empty cells', () => {
|
||||||
|
const app = openGrid(FIVE);
|
||||||
|
expect(app._tileGrid.cells).toEqual([...FIVE, null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removing a tile', () => {
|
||||||
|
it('without a shape change (6 to 5 stays 3x2): its cell is left empty where it was', () => {
|
||||||
|
const app = openGrid(SIX);
|
||||||
|
app.removeTile('s-b');
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', null, 's-c', 's-d', 's-e', 's-f']);
|
||||||
|
expect(place('s-c')).toEqual(['5', '1']);
|
||||||
|
expect(slots()).toHaveLength(1);
|
||||||
|
expect(slots()[0].dataset.cell).toBe('1');
|
||||||
|
// Nothing else was remounted.
|
||||||
|
expect(FakeTile.all.filter((t) => t._destroyed).map((t) => t.sessionId)).toEqual(['s-b']);
|
||||||
|
expect(stored().ids).toEqual(['s-a', null, 's-c', 's-d', 's-e', 's-f']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a shape change (5 to 4 is 3x2 to 2x2) keeps the tiles when all fit, else packs them', () => {
|
||||||
|
let app = openGrid(FIVE);
|
||||||
|
setCells(app, ['s-a', 's-b', null, 's-d', 's-e', 's-c']);
|
||||||
|
// Removing the only tile in the third column: the rest keep their places.
|
||||||
|
app.removeTile('s-c');
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-d', 's-e']);
|
||||||
|
resetGridHarness();
|
||||||
|
narrow();
|
||||||
|
app = openGrid(FIVE);
|
||||||
|
setCells(app, ['s-a', null, 's-c', 's-d', 's-e', 's-b']);
|
||||||
|
// A tile is left in the third column: packed in reading order.
|
||||||
|
app.removeTile('s-e');
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-c', 's-d', 's-b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the focused tile leaving hands focus to the next tile in reading order, past the hole', () => {
|
||||||
|
const app = openGrid(SIX, 's-a');
|
||||||
|
setCells(app, ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f']);
|
||||||
|
app.removeTile('s-b');
|
||||||
|
app.removeTile('s-a');
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('adding a tile', () => {
|
||||||
|
it('without a shape change: the first empty cell in reading order', () => {
|
||||||
|
const app = openGrid(FIVE);
|
||||||
|
setCells(app, ['s-a', 's-b', null, 's-c', 's-d', 's-e']);
|
||||||
|
app.addTile('s-f');
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-f', 's-c', 's-d', 's-e']);
|
||||||
|
expect(slots()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl/Cmd+click on a tab and a session Run from this tab fill the first hole too', () => {
|
||||||
|
const app = openGrid(['s-a', 's-b', 's-c', 's-d'], 's-a', [...SIX, 's-g']);
|
||||||
|
// 2x2 [a b / c d]; the 5th makes it 3x2 with the four kept in place.
|
||||||
|
app.addSessionToTiles('s-e');
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-e', 's-c', 's-d', null]);
|
||||||
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
||||||
|
app._joinTileGridFromRun('s-f');
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-f', 's-b', 's-c', 's-d', 's-e']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a shape change (4 to 5 is 2x2 to 3x2): every tile keeps its row and column', () => {
|
||||||
|
const app = openGrid(['s-a', 's-b', 's-c', 's-d']);
|
||||||
|
app.addTile('s-e');
|
||||||
|
// [a b / c d] -> [a b e / c d _]
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-e', 's-c', 's-d', null]);
|
||||||
|
expect(place('s-c')).toEqual(['1', '3']);
|
||||||
|
expect(place('s-e')).toEqual(['5', '1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('into the cell named when it is empty, not just the first one (two holes, a state the cap never reaches)', () => {
|
||||||
|
const app = openGrid(['s-a', 's-b']);
|
||||||
|
// A 2x2 with two holes, set up by hand: the third tile still keeps the 2x2.
|
||||||
|
app._tileGrid.cells = ['s-a', null, 's-b', null];
|
||||||
|
app._tileGrid.cols = 2;
|
||||||
|
app._tileGrid.rows = 2;
|
||||||
|
app.addTile('s-c', { cell: 3 });
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-c']);
|
||||||
|
// A cell that is taken is not: the first empty one instead.
|
||||||
|
resetGridHarness();
|
||||||
|
narrow();
|
||||||
|
const other = openGrid(['s-a', 's-b']);
|
||||||
|
other._tileGrid.cells = ['s-a', null, 's-b', null];
|
||||||
|
other._tileGrid.cols = 2;
|
||||||
|
other._tileGrid.rows = 2;
|
||||||
|
other.addTile('s-c', { cell: 2 });
|
||||||
|
expect(other._tileGrid.cells).toEqual(['s-a', 's-c', 's-b', null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the cap counts tiles, never empty cells', () => {
|
||||||
|
const app = openGrid(FIVE);
|
||||||
|
setCells(app, [null, 's-a', 's-b', 's-c', 's-d', 's-e']);
|
||||||
|
expect(app.addTile('s-f')).toBe(true);
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-f', 's-a', 's-b', 's-c', 's-d', 's-e']);
|
||||||
|
expect(app.addTile('s-other')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a shape change from the window (three tiles: 2x2 narrow, 3x1 wide)', () => {
|
||||||
|
it('positions do not map between shapes: the tiles keep their reading order, packed', () => {
|
||||||
|
const app = openGrid(['s-a', 's-b', 's-c']);
|
||||||
|
setCells(app, ['s-a', null, 's-b', 's-c']);
|
||||||
|
section.getBoundingClientRect = () => ({ width: 2400, height: 1000, top: 0, left: 0, right: 2400, bottom: 1000 });
|
||||||
|
app._applyTileLayout();
|
||||||
|
expect([app._tileGrid.cols, app._tileGrid.rows]).toEqual([3, 1]);
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c']);
|
||||||
|
narrow();
|
||||||
|
app._applyTileLayout();
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('focus never lands on an empty cell', () => {
|
||||||
|
function handlerFor(app: GridApp) {
|
||||||
|
app.$ = () => null;
|
||||||
|
app.setupColorPicker = vi.fn();
|
||||||
|
const before = (documentAddEventListener.mock.calls as unknown[]).length;
|
||||||
|
app.setupEventListeners();
|
||||||
|
const added = (documentAddEventListener.mock.calls as Array<[string, (e: unknown) => void, boolean]>).slice(before);
|
||||||
|
return added.find(([type, , capture]) => type === 'keydown' && capture === true)![1];
|
||||||
|
}
|
||||||
|
const chord = (key: string, mods: Record<string, boolean>) => ({
|
||||||
|
type: 'keydown',
|
||||||
|
key,
|
||||||
|
code: key,
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: false,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
target: { closest: () => null },
|
||||||
|
...mods,
|
||||||
|
});
|
||||||
|
const focusKey = (dir: string) => chord(`Arrow${dir}`, { altKey: true, shiftKey: true });
|
||||||
|
|
||||||
|
it('Alt+Shift+Arrows skip a hole to the next tile, and do nothing when there is none', () => {
|
||||||
|
const app = openGrid(FIVE, 's-a');
|
||||||
|
// [a _ b / c d e]
|
||||||
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
||||||
|
delete app.selectSession;
|
||||||
|
const onKeydown = handlerFor(app);
|
||||||
|
onKeydown(focusKey('Right'));
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
onKeydown(focusKey('Right'));
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
app._selectTiledSession('s-d', { auto: true });
|
||||||
|
// Up from d (the hole above): the nearest tile in that row, the lower column on a tie.
|
||||||
|
onKeydown(focusKey('Up'));
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
|
expect(app._tileGrid.focusedId).toBe('s-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Tab and Alt+[ / ] cycle through the tiles in reading order, never a hole', () => {
|
||||||
|
const app = openGrid(FIVE, 's-a');
|
||||||
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
||||||
|
delete app.selectSession;
|
||||||
|
const seen: string[] = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
app._cycleTileFocus(1);
|
||||||
|
seen.push(app.activeSessionId);
|
||||||
|
}
|
||||||
|
expect(seen).toEqual(['s-b', 's-c', 's-d', 's-e', 's-a']);
|
||||||
|
app._cycleTileFocus(-1);
|
||||||
|
expect(app.activeSessionId).toBe('s-e');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('persistence: the cells, holes included', () => {
|
||||||
|
function reload(ids: string[] = SIX): GridApp {
|
||||||
|
// The page goes away; a fresh one on the same device restores the grid.
|
||||||
|
section.children = [];
|
||||||
|
FakeTile.all = [];
|
||||||
|
const app = makeGridApp(ids);
|
||||||
|
expect(app._restoreTileGrid()).toBe(true);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('a hole comes back where it was when the shape is the same', () => {
|
||||||
|
const app = openGrid(FIVE, 's-c');
|
||||||
|
setCells(app, ['s-a', 's-b', null, 's-c', 's-d', 's-e']);
|
||||||
|
expect(stored().ids).toEqual(['s-a', 's-b', null, 's-c', 's-d', 's-e']);
|
||||||
|
app._tileGrid.open = false;
|
||||||
|
const again = reload();
|
||||||
|
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-c', 's-d', 's-e']);
|
||||||
|
expect(again.activeSessionId).toBe('s-c');
|
||||||
|
expect(slots()[0].dataset.cell).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a session gone by then leaves its cell empty if the shape still fits', () => {
|
||||||
|
const app = openGrid(SIX);
|
||||||
|
app._tileGrid.open = false;
|
||||||
|
const again = reload(['s-a', 's-b', 's-d', 's-e', 's-f']);
|
||||||
|
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-d', 's-e', 's-f']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('otherwise (another shape) the grid packs', () => {
|
||||||
|
const app = openGrid(FIVE);
|
||||||
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
||||||
|
app._tileGrid.open = false;
|
||||||
|
// s-d gone: four tiles take a 2x2.
|
||||||
|
const again = reload(['s-a', 's-b', 's-c', 's-e']);
|
||||||
|
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-e']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('another shape packs even when the stored holes would fit the new one', () => {
|
||||||
|
// Stored as 3x2 [a _ b / c d e]; d and e gone: three tiles take a 2x2.
|
||||||
|
localStore.set(KEY, JSON.stringify({ v: 1, open: true, ids: ['s-a', null, 's-b', 's-c', 's-d', 's-e'] }));
|
||||||
|
const again = reload(['s-a', 's-b', 's-c']);
|
||||||
|
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the old packed format reads unchanged', () => {
|
||||||
|
localStore.set(KEY, JSON.stringify({ v: 1, open: true, ids: FIVE, focused: 's-b' }));
|
||||||
|
const again = reload();
|
||||||
|
expect(again._tileGrid.cells).toEqual([...FIVE, null]);
|
||||||
|
expect(again.activeSessionId).toBe('s-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a followed #session= link closes the stored grid with its holes kept', () => {
|
||||||
|
const app = openGrid(FIVE);
|
||||||
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
||||||
|
const fresh = makeGridApp(SIX);
|
||||||
|
fresh._closeStoredTileGrid();
|
||||||
|
expect(stored()).toMatchObject({ open: false, ids: ['s-a', null, 's-b', 's-c', 's-d', 's-e'] });
|
||||||
|
expect(stored()).not.toHaveProperty('cells');
|
||||||
|
void app;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a move keeps every tile', () => {
|
||||||
|
it('cells that lose or duplicate a tile are refused, and nothing changes', () => {
|
||||||
|
const app = openGrid(FIVE);
|
||||||
|
const before = app._tileGrid.cells.slice();
|
||||||
|
expect(app._reorderTiles(['s-a', 's-b', 's-c', 's-d', null, null])).toBe(false);
|
||||||
|
expect(app._reorderTiles(['s-a', 's-a', 's-c', 's-d', 's-e', null])).toBe(false);
|
||||||
|
expect(app._reorderTiles(['s-a', 's-b', 's-c', 's-d', 's-e'])).toBe(false);
|
||||||
|
expect(app._tileGrid.cells).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a divider drag with a hole', () => {
|
||||||
|
it('refits only the tiles in its two columns, the hole skipped', () => {
|
||||||
|
const app = openGrid(FIVE);
|
||||||
|
// [a _ b / c d e]: column 0 holds a, c; column 1 holds d.
|
||||||
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
||||||
|
for (const t of FakeTile.all) t.fit.mockClear();
|
||||||
|
const d = app._tileGrid.dividers.get('col-0') as FakeEl;
|
||||||
|
d.dispatch('pointerdown', {
|
||||||
|
button: 0,
|
||||||
|
clientX: 500,
|
||||||
|
clientY: 100,
|
||||||
|
pointerId: 1,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
stopPropagation: vi.fn(),
|
||||||
|
});
|
||||||
|
d.dispatch('pointermove', { clientX: 560, clientY: 100, pointerId: 1 });
|
||||||
|
for (const cb of rafCallbacks.splice(0)) cb();
|
||||||
|
d.dispatch('pointerup', { clientX: 560, clientY: 100, pointerId: 1 });
|
||||||
|
const fits = Object.fromEntries(FIVE.map((id) => [id, tile(id).fit.mock.calls.length]));
|
||||||
|
expect(fits).toEqual({ 's-a': 1, 's-b': 0, 's-c': 1, 's-d': 1, 's-e': 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview A tile's header: `● [logo] name · model ..... ⋯ ⤢ ×`, and the tab marker.
|
||||||
|
*
|
||||||
|
* - The logo is PR #532's `run-mode-dot <cliId>` slot (the id is data); the
|
||||||
|
* logo's tooltip and accessible name carry the harness and the model, with
|
||||||
|
* where the model came from when the CLI did not report it. The model is
|
||||||
|
* text (never markup, never translated), and an unknown model shows nothing.
|
||||||
|
* An unchanged session writes nothing on a refresh.
|
||||||
|
* - The dot uses the six-state classifier the tab rows and both home screens
|
||||||
|
* share (`_sidebarRichRow`), with the existing `.home-sessions-dot--*`
|
||||||
|
* classes; a `needs` tile gets the pulsing red border; hovering shows the
|
||||||
|
* state and how long ("working 3m").
|
||||||
|
* - The name is text, never markup, and carries `data-i18n-skip`; a
|
||||||
|
* double-click renames through the tab rename's own write queue.
|
||||||
|
* - `⋯` is the tab rail's session menu; `×` removes the tile ONLY (the
|
||||||
|
* session keeps running), and neither button focuses a tile that is not
|
||||||
|
* focused (which would spend its idle alert).
|
||||||
|
* - Every tab render refreshes the headers, so they follow session changes.
|
||||||
|
* - Tabs of tiled sessions carry `.in-tiles`.
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
FakeTile,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
type GridApp,
|
||||||
|
tileEl,
|
||||||
|
windowStub,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c'];
|
||||||
|
|
||||||
|
/** A grid on IDS with the shared classifier stand-ins mobile-overview.js would provide. */
|
||||||
|
function openGrid(): GridApp {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app._mobileOverviewState = (session: { status?: string }, hooks?: Set<string>) =>
|
||||||
|
hooks?.has('permission_prompt') ? 'needs' : session.status === 'busy' ? 'working' : 'idle';
|
||||||
|
app._mobileOverviewSince = (_state: string, session: { lastActivityAt?: number }) => ({
|
||||||
|
key: 'x',
|
||||||
|
at: session.lastActivityAt || 0,
|
||||||
|
});
|
||||||
|
app._mobileOverviewExit = () => null;
|
||||||
|
app._mobileOverviewStampText = (at: number) => (at ? '3m' : '');
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app.markIdleAlertSeen.mockClear();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerOf = (id: string) => tileEl(id).children[0];
|
||||||
|
/** A header part by its class, wherever the header nests it. */
|
||||||
|
const partOf = (id: string, cls: string) => headerOf(id).querySelector(`.${cls}`) as FakeEl;
|
||||||
|
const buttonOf = (id: string, cls: string) =>
|
||||||
|
partOf(id, 'tile-actions').children.find((b) => b.className.includes(cls)) as FakeEl;
|
||||||
|
/** Where the name sits: the name itself, or the rename input in its place. */
|
||||||
|
const nameSlotOf = (id: string) => partOf(id, 'tile-title').children[0];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the header', () => {
|
||||||
|
it('shows the session name as text, skipped by the translator', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.sessions.get('s-b').name = '<b>Sessions</b>';
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
const name = partOf('s-b', 'tile-name');
|
||||||
|
expect(name.textContent).toBe('<b>Sessions</b>');
|
||||||
|
expect(name.getAttribute('data-i18n-skip')).toBe('');
|
||||||
|
expect(name.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('header and body are siblings, the body holding the terminal', () => {
|
||||||
|
openGrid();
|
||||||
|
const el = tileEl('s-a');
|
||||||
|
expect(el.children.map((c) => c.className)).toEqual(['tile-header', 'tile-body']);
|
||||||
|
expect(FakeTile.all.find((t) => t.sessionId === 's-a')?.mountEl).toBe(el.children[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the dot follows the session state, a permission prompt marks the whole tile', () => {
|
||||||
|
const app = openGrid();
|
||||||
|
app.sessions.get('s-b').status = 'busy';
|
||||||
|
app.pendingHooks.set('s-c', new Set(['permission_prompt']));
|
||||||
|
app._renderTileChrome();
|
||||||
|
|
||||||
|
expect(partOf('s-a', 'tile-dot').className).toContain('home-sessions-dot--idle');
|
||||||
|
expect(partOf('s-b', 'tile-dot').className).toContain('home-sessions-dot--working');
|
||||||
|
expect(partOf('s-c', 'tile-dot').className).toContain('home-sessions-dot--needs');
|
||||||
|
expect(tileEl('s-c').classList.contains('tile--needs')).toBe(true);
|
||||||
|
expect(tileEl('s-b').classList.contains('tile--needs')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hovering says the state and for how long', () => {
|
||||||
|
const app = openGrid();
|
||||||
|
app.sessions.get('s-b').status = 'busy';
|
||||||
|
app.sessions.get('s-b').lastActivityAt = Date.now() - 180_000;
|
||||||
|
app._renderTileChrome();
|
||||||
|
// While tiles can move, a second line says the header drags.
|
||||||
|
expect(headerOf('s-b').title).toBe('working 3m\nDrag to move the tile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every tab render refreshes the headers', () => {
|
||||||
|
const app = openGrid();
|
||||||
|
app._renderTileChrome = vi.fn();
|
||||||
|
// The original returns at once during an inline tab rename; the headers still refresh.
|
||||||
|
app._inlineRenameActive = true;
|
||||||
|
app._renderSessionTabsImmediate();
|
||||||
|
expect(app._renderTileChrome).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the harness logo and the model', () => {
|
||||||
|
const CATALOG = [
|
||||||
|
{ id: 'claude', label: 'Claude Code' },
|
||||||
|
{ id: 'deepseek', label: 'DeepSeek' },
|
||||||
|
{ id: 'shell', label: 'Shell' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** s-a on dsh's route, s-b claude on its statusline's model, s-c a shell. */
|
||||||
|
function harnessGrid(): GridApp {
|
||||||
|
windowStub.__codemanCliCatalog = CATALOG;
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.sessions.get('s-a').mode = 'deepseek';
|
||||||
|
app.sessions.get('s-a').displayModel = { model: 'qwen3.8-27b', source: 'screen' };
|
||||||
|
app.sessions.get('s-b').displayModel = { model: 'Haiku 4.5', source: 'statusline' };
|
||||||
|
app.sessions.get('s-c').mode = 'shell';
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
const logoOf = (id: string) => partOf(id, 'tile-harness');
|
||||||
|
const modelOf = (id: string) => partOf(id, 'tile-model');
|
||||||
|
const modelNameOf = (id: string) => modelOf(id).children[0];
|
||||||
|
|
||||||
|
it('sits between the dot and the name: `● [logo] name · model ... ⋯ ⤢ ×`', () => {
|
||||||
|
harnessGrid();
|
||||||
|
expect(headerOf('s-a').children.map((c) => c.className.split(' ')[0])).toEqual([
|
||||||
|
'tile-dot',
|
||||||
|
'tile-harness',
|
||||||
|
'tile-title',
|
||||||
|
'tile-actions',
|
||||||
|
]);
|
||||||
|
expect(partOf('s-a', 'tile-title').children.map((c) => c.className)).toEqual(['tile-name', 'tile-model']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws the session's CLI as PR #532's logo slot, the id as data", () => {
|
||||||
|
harnessGrid();
|
||||||
|
expect(logoOf('s-a').className).toBe('tile-harness run-mode-dot deepseek');
|
||||||
|
expect(logoOf('s-b').className).toBe('tile-harness run-mode-dot claude');
|
||||||
|
expect(logoOf('s-c').className).toBe('tile-harness run-mode-dot shell');
|
||||||
|
expect(logoOf('s-a').getAttribute('role')).toBe('img');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the harness and the model in the tooltip and the accessible name', () => {
|
||||||
|
harnessGrid();
|
||||||
|
expect(logoOf('s-a').title).toBe('DeepSeek \u00B7 qwen3.8-27b');
|
||||||
|
expect(logoOf('s-a').getAttribute('aria-label')).toBe('DeepSeek \u00B7 qwen3.8-27b');
|
||||||
|
expect(modelOf('s-a').title).toBe('DeepSeek \u00B7 qwen3.8-27b');
|
||||||
|
expect(modelNameOf('s-a').textContent).toBe('qwen3.8-27b');
|
||||||
|
expect(modelOf('s-a').hidden).toBe(false);
|
||||||
|
// Said once to a screen reader: the model's box is hidden from it.
|
||||||
|
expect(modelOf('s-a').getAttribute('aria-hidden')).toBe('true');
|
||||||
|
// The model name is never translated; its tooltip may be.
|
||||||
|
expect(modelNameOf('s-a').getAttribute('data-i18n-skip')).toBe('');
|
||||||
|
expect(modelOf('s-a').getAttribute('data-i18n-skip')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unknown model shows the logo alone: no text, no placeholder', () => {
|
||||||
|
harnessGrid();
|
||||||
|
expect(modelOf('s-c').hidden).toBe(true);
|
||||||
|
expect(modelNameOf('s-c').textContent).toBe('');
|
||||||
|
expect(logoOf('s-c').title).toBe('Shell');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says in the tooltip where a model came from when the CLI did not report it', () => {
|
||||||
|
const app = harnessGrid();
|
||||||
|
app.sessions.get('s-b').displayModel = { model: 'haiku', source: 'launch' };
|
||||||
|
app.sessions.get('s-c').mode = 'claude';
|
||||||
|
app.sessions.get('s-c').displayModel = { model: 'qwen3.8-27b', source: 'custom-endpoint' };
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(logoOf('s-b').title).toBe('Claude Code \u00B7 haiku (set at launch)');
|
||||||
|
expect(logoOf('s-c').title).toBe('Claude Code \u00B7 qwen3.8-27b (custom endpoint)');
|
||||||
|
app.sessions.get('s-a').displayModel = { model: 'qwen3.8-27b', source: 'config' };
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(logoOf('s-a').title).toBe('DeepSeek \u00B7 qwen3.8-27b (from config)');
|
||||||
|
expect(modelNameOf('s-a').textContent).toBe('qwen3.8-27b');
|
||||||
|
expect(modelNameOf('s-b').textContent).toBe('haiku');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a model change (session:updated) updates the header', () => {
|
||||||
|
const app = harnessGrid();
|
||||||
|
app.sessions.set('s-b', {
|
||||||
|
...app.sessions.get('s-b'),
|
||||||
|
displayModel: { model: 'Sonnet 4.6', source: 'statusline' },
|
||||||
|
});
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(modelNameOf('s-b').textContent).toBe('Sonnet 4.6');
|
||||||
|
expect(logoOf('s-b').title).toBe('Claude Code \u00B7 Sonnet 4.6');
|
||||||
|
// And the model going away takes the text with it.
|
||||||
|
delete app.sessions.get('s-b').displayModel;
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(modelOf('s-b').hidden).toBe(true);
|
||||||
|
expect(modelNameOf('s-b').textContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a model with markup stays text', () => {
|
||||||
|
const app = harnessGrid();
|
||||||
|
app.sessions.get('s-b').displayModel = { model: '<img src=x onerror=alert(1)>', source: 'statusline' };
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(modelNameOf('s-b').textContent).toBe('<img src=x onerror=alert(1)>');
|
||||||
|
expect(modelNameOf('s-b').children).toHaveLength(0);
|
||||||
|
expect(modelOf('s-b').children).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unchanged session writes nothing on a refresh', () => {
|
||||||
|
const app = harnessGrid();
|
||||||
|
const writes: string[] = [];
|
||||||
|
for (const id of IDS) {
|
||||||
|
for (const [node, props] of [
|
||||||
|
[logoOf(id), ['className', 'title']],
|
||||||
|
[modelOf(id), ['title', 'hidden']],
|
||||||
|
[modelNameOf(id), ['textContent']],
|
||||||
|
] as Array<[FakeEl, string[]]>) {
|
||||||
|
for (const prop of props) {
|
||||||
|
let value = (node as unknown as Record<string, unknown>)[prop];
|
||||||
|
Object.defineProperty(node, prop, {
|
||||||
|
get: () => value,
|
||||||
|
set: (v) => {
|
||||||
|
writes.push(`${id} ${prop}`);
|
||||||
|
value = v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const setAttribute = node.setAttribute.bind(node);
|
||||||
|
node.setAttribute = (k: string, v: string) => {
|
||||||
|
writes.push(`${id} @${k}`);
|
||||||
|
setAttribute(k, v);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app._renderTileChrome();
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an id that is not a CLI id is not a class name; without a catalog the label is the id', () => {
|
||||||
|
const app = harnessGrid();
|
||||||
|
delete windowStub.__codemanCliCatalog;
|
||||||
|
app.sessions.get('s-a').mode = 'bad id" onclick';
|
||||||
|
app.sessions.get('s-b').mode = 'my-cli';
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(logoOf('s-a').className).toBe('tile-harness run-mode-dot');
|
||||||
|
expect(logoOf('s-a').title).toBe('qwen3.8-27b');
|
||||||
|
expect(logoOf('s-b').className).toBe('tile-harness run-mode-dot my-cli');
|
||||||
|
expect(logoOf('s-b').title).toBe('my-cli \u00B7 Haiku 4.5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('header buttons', () => {
|
||||||
|
it('are ⋯ ⤢ × and nothing else: no + (owner decision 9)', () => {
|
||||||
|
const app = openGrid();
|
||||||
|
const tile = tileEl('s-a');
|
||||||
|
const actions = tile.children[0].children.find((c) => c.className === 'tile-actions') as FakeEl;
|
||||||
|
expect(actions.children.map((b) => b.className)).toEqual([
|
||||||
|
'tile-btn tile-menu',
|
||||||
|
'tile-btn tile-zoom',
|
||||||
|
'tile-btn tile-remove',
|
||||||
|
]);
|
||||||
|
expect(actions.children.map((b) => b.textContent)).toEqual(['\u22EF', '\u2922', '\u00D7']);
|
||||||
|
// The + menu and its "New session in this case" went with it.
|
||||||
|
for (const gone of ['openTileAddMenu', 'closeTileAddMenu', 'runInCaseForTiles']) {
|
||||||
|
expect(gone in app, gone).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('× removes the tile only: the session keeps running, a neighbour takes focus unacknowledged', () => {
|
||||||
|
const app = openGrid();
|
||||||
|
app._apiDelete = vi.fn();
|
||||||
|
buttonOf('s-a', 'tile-remove').dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
|
||||||
|
expect(app.sessions.has('s-a')).toBe(true);
|
||||||
|
expect(app._apiDelete).not.toHaveBeenCalled();
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('× on the last tile shows that session in the single view', () => {
|
||||||
|
const app = makeGridApp(['s-a']);
|
||||||
|
app.openTileGrid(['s-a']);
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
buttonOf('s-a', 'tile-remove').dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(app.selectSession).toHaveBeenCalledWith('s-a', { forceReload: true, auto: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('⋯ opens the tab rail session menu for that session', () => {
|
||||||
|
const app = openGrid();
|
||||||
|
app.openTabRailActionMenu = vi.fn();
|
||||||
|
const ev = { stopPropagation: vi.fn() };
|
||||||
|
buttonOf('s-c', 'tile-menu').dispatch('click', ev);
|
||||||
|
expect(app.openTabRailActionMenu).toHaveBeenCalledWith(ev, 's-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pressing a header button never focuses the tile (no selection, no acknowledgement)', () => {
|
||||||
|
const app = openGrid();
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
const stop = vi.fn();
|
||||||
|
buttonOf('s-c', 'tile-remove').dispatch('pointerdown', { stopPropagation: stop });
|
||||||
|
expect(stop).toHaveBeenCalled();
|
||||||
|
// The tile's own pointerdown (a focus) only runs if the event reaches it.
|
||||||
|
tileEl('s-c').dispatch('pointerdown', {});
|
||||||
|
expect(app.selectSession).toHaveBeenCalledWith('s-c');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('header button size (owner feedback: the 12px glyphs read as tiny)', () => {
|
||||||
|
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
||||||
|
const rule = (selector: string) => {
|
||||||
|
const at = css.indexOf(`${selector} {`);
|
||||||
|
return at === -1 ? '' : css.slice(at, css.indexOf('}', at));
|
||||||
|
};
|
||||||
|
|
||||||
|
it("buttons are the app header's icon size: 26px targets, a 16px glyph, never the header's inherited 12px", () => {
|
||||||
|
const btn = rule('.tile-btn');
|
||||||
|
expect(btn).toContain('min-width: 26px;');
|
||||||
|
expect(btn).toContain('height: 26px;');
|
||||||
|
expect(btn).toContain('font-size: 16px;');
|
||||||
|
expect(btn).not.toMatch(/\bfont: inherit;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the thin glyphs (ellipsis, cross) get a step more, and the header holds the buttons', () => {
|
||||||
|
expect(rule('.tile-btn.tile-menu,\n.tile-btn.tile-remove')).toContain('font-size: 19px;');
|
||||||
|
const header = rule('.tile-header');
|
||||||
|
expect(header).toContain('flex: 0 0 28px;');
|
||||||
|
expect(header).toContain('height: 28px;');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a translated label survives a refresh (zh-CN)', () => {
|
||||||
|
// The i18n observer writes the translation into the DOM. Comparing the DOM
|
||||||
|
// with the English source would never match again, so each refresh would
|
||||||
|
// rewrite English for the observer to translate again. The guards compare
|
||||||
|
// with the last English value set instead.
|
||||||
|
it('the header tooltip, the Attach overlay text and the zoom title stay translated until they change', () => {
|
||||||
|
const app = makeGridApp(['s-a', 's-b']);
|
||||||
|
let pill = 'idle';
|
||||||
|
app._sidebarRichRow = () => ({ state: pill, pill, since: { at: 1 } });
|
||||||
|
app._mobileOverviewStampText = () => '3m';
|
||||||
|
app.sessions.get('s-b').pid = null;
|
||||||
|
app.openTileGrid(['s-a', 's-b']);
|
||||||
|
const a = app._tileGrid.tiles.get('s-a');
|
||||||
|
const b = app._tileGrid.tiles.get('s-b');
|
||||||
|
expect(a.header.title).toBe('idle 3m\nDrag to move the tile');
|
||||||
|
expect(b.overlayText.textContent).toBe('Not attached');
|
||||||
|
app.zoomTile('s-a');
|
||||||
|
expect(a.zoomBtn.title).toBe('Restore the grid');
|
||||||
|
// Zoomed, nothing moves: the drag hint goes.
|
||||||
|
expect(a.header.title).toBe('idle 3m');
|
||||||
|
|
||||||
|
// What the translator does to them.
|
||||||
|
a.header.title = '空闲 3m';
|
||||||
|
b.overlayText.textContent = '未附加';
|
||||||
|
a.zoomBtn.title = '恢复平铺网格';
|
||||||
|
app._renderTileChrome();
|
||||||
|
app._applyTileLayout();
|
||||||
|
expect(a.header.title).toBe('空闲 3m');
|
||||||
|
expect(b.overlayText.textContent).toBe('未附加');
|
||||||
|
expect(a.zoomBtn.title).toBe('恢复平铺网格');
|
||||||
|
|
||||||
|
// A real change still writes the new English, for the translator to take.
|
||||||
|
pill = 'working';
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(a.header.title).toBe('working 3m');
|
||||||
|
app.zoomTile('s-a');
|
||||||
|
expect(a.zoomBtn.title).toBe('Zoom this tile');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rename', () => {
|
||||||
|
function startRename(app: GridApp, id: string) {
|
||||||
|
nameSlotOf(id).dispatch('dblclick', { stopPropagation: vi.fn() });
|
||||||
|
return nameSlotOf(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('double-click puts an input in place of the name; Enter renames through the write queue', () => {
|
||||||
|
const app = openGrid();
|
||||||
|
// The real queue records the name in flight before its PUT lands.
|
||||||
|
app._inlineRenamePending = new Map();
|
||||||
|
app._queueInlineSessionName = vi.fn(async (id: string, name: string) => {
|
||||||
|
app._inlineRenamePending.set(id, name);
|
||||||
|
return { status: 'confirmed' };
|
||||||
|
});
|
||||||
|
const input = startRename(app, 's-b');
|
||||||
|
expect(input.className).toBe('tile-rename-input');
|
||||||
|
expect(input.value).toBe('s-b');
|
||||||
|
|
||||||
|
input.value = 'renamed';
|
||||||
|
input.dispatch('keydown', { key: 'Enter', preventDefault: vi.fn() });
|
||||||
|
expect(app._queueInlineSessionName).toHaveBeenCalledWith('s-b', 'renamed');
|
||||||
|
expect(nameSlotOf('s-b').className).toBe('tile-name');
|
||||||
|
expect(nameSlotOf('s-b').textContent).toBe('renamed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape cancels without a write', () => {
|
||||||
|
const app = openGrid();
|
||||||
|
app._queueInlineSessionName = vi.fn();
|
||||||
|
const input = startRename(app, 's-b');
|
||||||
|
input.value = 'nope';
|
||||||
|
input.dispatch('keydown', { key: 'Escape', preventDefault: vi.fn() });
|
||||||
|
expect(app._queueInlineSessionName).not.toHaveBeenCalled();
|
||||||
|
expect(nameSlotOf('s-b').textContent).toBe('s-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a header refresh while renaming leaves the input alone', () => {
|
||||||
|
const app = openGrid();
|
||||||
|
const input = startRename(app, 's-b');
|
||||||
|
input.value = 'half-typed';
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(nameSlotOf('s-b')).toBe(input);
|
||||||
|
expect(input.value).toBe('half-typed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an IME composition owns Enter', () => {
|
||||||
|
const app = openGrid();
|
||||||
|
app._queueInlineSessionName = vi.fn();
|
||||||
|
const input = startRename(app, 's-b');
|
||||||
|
input.value = 'x';
|
||||||
|
input.dispatch('keydown', { key: 'Enter', isComposing: true, preventDefault: vi.fn() });
|
||||||
|
expect(app._queueInlineSessionName).not.toHaveBeenCalled();
|
||||||
|
expect(nameSlotOf('s-b')).toBe(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the tab marker', () => {
|
||||||
|
// Tab rendering needs the whole strip; the class is pinned at both render paths.
|
||||||
|
const app = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||||
|
|
||||||
|
it('the full render puts .in-tiles on a tiled session tab', () => {
|
||||||
|
expect(app).toContain("${this._tileGrid?.has(id) ? ' in-tiles' : ''}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the incremental render toggles it', () => {
|
||||||
|
expect(app).toContain("tab.classList.toggle('in-tiles', !!this._tileGrid?.has(id));");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('it has a style', () => {
|
||||||
|
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
||||||
|
expect(css).toMatch(/\.session-tab\.in-tiles/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Closing or deleting a tiled session keeps the grid open and
|
||||||
|
* moves focus to the NEIGHBOURING tile.
|
||||||
|
*
|
||||||
|
* `closeSession()` normally falls back to the first remaining `sessionOrder`
|
||||||
|
* entry with `auto: true`, and that entry is often NOT tiled: an app-driven
|
||||||
|
* pick that, with the grid open, would be refused (auto never collapses the
|
||||||
|
* grid) and leave nothing focused. So the fallback is grid-aware, and it lives
|
||||||
|
* IN closeSession: the delete broadcast routinely lands while the request is in
|
||||||
|
* flight, and the delete handlers skip ids in `_closingSessions`. The neighbour
|
||||||
|
* is captured BEFORE the await, like `wasActive`, because that broadcast may
|
||||||
|
* already have removed the tile.
|
||||||
|
*
|
||||||
|
* - closing the focused tile: next tile in grid order, else the previous one;
|
||||||
|
* `s-other` is FIRST in sessionOrder and never tiled, so the old pick would
|
||||||
|
* have collapsed the grid;
|
||||||
|
* - the same with the broadcast arriving mid-request and after it;
|
||||||
|
* - closing the last tile closes the grid and falls back to the normal pick;
|
||||||
|
* - a tiled session deleted ELSEWHERE: its tile goes, a neighbour takes focus
|
||||||
|
* with `auto` (no idle alert spent); the last one leaves the welcome screen.
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { FakeTile, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c'];
|
||||||
|
|
||||||
|
/** A grid on `ids` focused on `focus`, with the DELETE request held open until `finish()`. */
|
||||||
|
function setup(ids = IDS, focus = ids[0]) {
|
||||||
|
const app = makeGridApp(ids);
|
||||||
|
app.openTileGrid(ids, { focusedId: focus });
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
app.markIdleAlertSeen.mockClear();
|
||||||
|
let finish: () => void = () => {};
|
||||||
|
app._apiDelete = vi.fn(() => new Promise<void>((r) => (finish = r)));
|
||||||
|
// The real cleanup touches a lot of panels; what the fallback reads is the session list.
|
||||||
|
app._cleanupSessionData = vi.fn((id: string) => {
|
||||||
|
app.sessions.delete(id);
|
||||||
|
app.sessionOrder = app.sessionOrder.filter((s: string) => s !== id);
|
||||||
|
});
|
||||||
|
return { app: app as GridApp, finish: () => finish() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('closeSession on the focused tile', () => {
|
||||||
|
it('keeps the grid open and focuses the next tile, never the untiled first sessionOrder entry', async () => {
|
||||||
|
const { app, finish } = setup(IDS, 's-a');
|
||||||
|
const closing = app.closeSession('s-a');
|
||||||
|
finish();
|
||||||
|
await closing;
|
||||||
|
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
expect(app.selectSession).not.toHaveBeenCalled();
|
||||||
|
// The app chose the neighbour: no idle alert spent.
|
||||||
|
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the last tile in grid order hands focus back to the previous one', async () => {
|
||||||
|
const { app, finish } = setup(IDS, 's-c');
|
||||||
|
const closing = app.closeSession('s-c');
|
||||||
|
finish();
|
||||||
|
await closing;
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the delete broadcast arriving DURING the request changes nothing about the outcome', async () => {
|
||||||
|
const { app, finish } = setup(IDS, 's-b');
|
||||||
|
const closing = app.closeSession('s-b');
|
||||||
|
await settle();
|
||||||
|
app._onSessionDeleted({ id: 's-b' });
|
||||||
|
// Only the tile went; closeSession owns the follow-up (as the split's
|
||||||
|
// wrapper does for ids in _closingSessions), so focus has not moved yet.
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
expect(app.showWelcome).not.toHaveBeenCalled();
|
||||||
|
finish();
|
||||||
|
await closing;
|
||||||
|
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
expect(app.showWelcome).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the delete broadcast arriving AFTER the request is a no-op for the grid', async () => {
|
||||||
|
const { app, finish } = setup(IDS, 's-a');
|
||||||
|
const closing = app.closeSession('s-a');
|
||||||
|
finish();
|
||||||
|
await closing;
|
||||||
|
app._onSessionDeleted({ id: 's-a' });
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closing the LAST tile closes the grid and falls back to the normal pick', async () => {
|
||||||
|
const { app, finish } = setup(['s-a'], 's-a');
|
||||||
|
const closing = app.closeSession('s-a');
|
||||||
|
finish();
|
||||||
|
await closing;
|
||||||
|
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(FakeTile.all[0].destroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app.selectSession).toHaveBeenCalledWith('s-other', { auto: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closing a tile that is NOT focused removes it and leaves focus alone', async () => {
|
||||||
|
const { app, finish } = setup(IDS, 's-a');
|
||||||
|
const closing = app.closeSession('s-c');
|
||||||
|
finish();
|
||||||
|
await closing;
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a tiled session deleted elsewhere', () => {
|
||||||
|
it('removes its tile and moves focus to the neighbour with `auto`', () => {
|
||||||
|
const { app } = setup(IDS, 's-b');
|
||||||
|
app._onSessionDeleted({ id: 's-b' });
|
||||||
|
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||||
|
expect(app.showWelcome).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a deleted tile that was not focused just goes', () => {
|
||||||
|
const { app } = setup(IDS, 's-a');
|
||||||
|
app._onSessionDeleted({ id: 's-c' });
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the last tile deleted closes the grid and lands on the welcome screen, as in the single view', () => {
|
||||||
|
const { app } = setup(['s-a'], 's-a');
|
||||||
|
app._onSessionDeleted({ id: 's-a' });
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(app.activeSessionId).toBeNull();
|
||||||
|
expect(app.showWelcome).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The tile count (owner decision 10): the pure helpers behind the
|
||||||
|
* Tiles button's 2 / 4 / 6 right-click menu.
|
||||||
|
*
|
||||||
|
* - `sanitizeTileCount`: a remembered count is one of TILE_GRID_COUNTS, else
|
||||||
|
* the default (6).
|
||||||
|
* - `tileGridSetForCount`: what the grid opens (or an open grid shows) trimmed
|
||||||
|
* or filled to N: trimmed from the end with the session to focus always
|
||||||
|
* kept, filled from the open sessions in tab order; fewer sessions than N
|
||||||
|
* give fewer tiles; never past the cap.
|
||||||
|
* - `tileCellCols`: the column count a stored cell list was laid out with.
|
||||||
|
* - `reformTileCells`: a count change is a shape change: kept tiles stay in
|
||||||
|
* their cells, the cell model's rule (fitTileCells) reshapes, joining tiles
|
||||||
|
* fill the empty cells in reading order, holes first.
|
||||||
|
*
|
||||||
|
* The helpers from constants.js as the shared harness loads them.
|
||||||
|
* Port: N/A.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { windowStub } from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
type Cells = (string | null)[];
|
||||||
|
type Helpers = {
|
||||||
|
sanitizeTileCount(raw: unknown): number;
|
||||||
|
tileCellCols(length: number): number;
|
||||||
|
reformTileCells(cells: Cells, oldCols: number, keep: string[], add: string[], cols: number, rows: number): Cells;
|
||||||
|
computeTileLayout(p: { count: number; width?: number }): { cols: number; rows: number };
|
||||||
|
tileGridSetForCount(base: string[], all: string[], n: number, keepId?: string | null): string[];
|
||||||
|
TILE_GRID_COUNTS: number[];
|
||||||
|
TILE_GRID_COUNT_DEFAULT: number;
|
||||||
|
TILE_GRID_MAX: number;
|
||||||
|
};
|
||||||
|
const T = windowStub.CodemanTileGrid as Helpers;
|
||||||
|
const all = ['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8'];
|
||||||
|
|
||||||
|
describe('sanitizeTileCount', () => {
|
||||||
|
it('offers 2, 4 and 6, default 6', () => {
|
||||||
|
expect(T.TILE_GRID_COUNTS).toEqual([2, 4, 6]);
|
||||||
|
expect(T.TILE_GRID_COUNT_DEFAULT).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a valid count, as a number or as stored text', () => {
|
||||||
|
expect(T.sanitizeTileCount(2)).toBe(2);
|
||||||
|
expect(T.sanitizeTileCount('4')).toBe(4);
|
||||||
|
expect(T.sanitizeTileCount('6')).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anything else is the default', () => {
|
||||||
|
for (const raw of [null, undefined, '', '3', 5, 9, 0, -2, 'six', '{}', NaN]) {
|
||||||
|
expect(T.sanitizeTileCount(raw)).toBe(6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tileGridSetForCount', () => {
|
||||||
|
it('fills from tab order after the base, skipping ones already in it', () => {
|
||||||
|
expect(T.tileGridSetForCount(['t5', 't2'], all, 6)).toEqual(['t5', 't2', 't1', 't3', 't4', 't6']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims from the end', () => {
|
||||||
|
expect(T.tileGridSetForCount(['t1', 't2', 't3', 't4', 't5', 't6'], all, 4)).toEqual(['t1', 't2', 't3', 't4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the session to focus when it sat past N, in the last place', () => {
|
||||||
|
expect(T.tileGridSetForCount(['t1', 't2', 't3', 't4', 't5', 't6'], all, 2, 't5')).toEqual(['t1', 't5']);
|
||||||
|
expect(T.tileGridSetForCount(['t1', 't2', 't3', 't4'], all, 2, 't2')).toEqual(['t1', 't2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a base id is never trimmed in favour of a filler', () => {
|
||||||
|
expect(T.tileGridSetForCount(['t7', 't8'], all, 2)).toEqual(['t7', 't8']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fewer open sessions than N: fewer tiles', () => {
|
||||||
|
expect(T.tileGridSetForCount(['t1'], ['t1', 't2', 't3'], 6)).toEqual(['t1', 't2', 't3']);
|
||||||
|
expect(T.tileGridSetForCount([], [], 4)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never past the cap, and at least one', () => {
|
||||||
|
expect(T.tileGridSetForCount([], all, 9)).toHaveLength(T.TILE_GRID_MAX);
|
||||||
|
expect(T.tileGridSetForCount([], all, 0)).toEqual(['t1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops duplicates and non-ids', () => {
|
||||||
|
expect(T.tileGridSetForCount(['t2', 't2', '', null as unknown as string], all, 3)).toEqual(['t2', 't1', 't3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tileCellCols', () => {
|
||||||
|
it('reads the shape back from the cell count, as the layout table lays it out', () => {
|
||||||
|
expect([1, 2, 3, 4, 6, 9].map((n) => T.tileCellCols(n))).toEqual([1, 2, 3, 2, 3, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agrees with computeTileLayout for every count, wide and narrow', () => {
|
||||||
|
for (let n = 1; n <= 9; n++) {
|
||||||
|
for (const width of [Infinity, 0]) {
|
||||||
|
const { cols, rows } = T.computeTileLayout({ count: n, width });
|
||||||
|
expect(T.tileCellCols(cols * rows)).toBe(cols);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unknown length is 0 (the tiles then pack)', () => {
|
||||||
|
for (const n of [0, 5, 7, 8, 10, -1, NaN]) expect(T.tileCellCols(n)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reformTileCells', () => {
|
||||||
|
it('growing 2 (2x1) to 6 (3x2): both stay in their cells, the four new ones fill in reading order', () => {
|
||||||
|
expect(T.reformTileCells(['a', 'b'], 2, ['a', 'b'], ['c', 'd', 'e', 'f'], 3, 2)).toEqual([
|
||||||
|
'a', 'b', 'c',
|
||||||
|
'd', 'e', 'f',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('growing fills the holes first, in reading order', () => {
|
||||||
|
// 5 of a 3x2 with the hole in the middle of the first row, then 6.
|
||||||
|
expect(T.reformTileCells(['a', null, 'b', 'c', 'd', 'e'], 3, ['a', 'b', 'c', 'd', 'e'], ['f'], 3, 2)).toEqual([
|
||||||
|
'a', 'f', 'b',
|
||||||
|
'c', 'd', 'e',
|
||||||
|
]);
|
||||||
|
// 3 of a 2x2 (hole first) growing to 4 keeps the shape: the hole fills.
|
||||||
|
expect(T.reformTileCells([null, 'a', 'b', 'c'], 2, ['a', 'b', 'c'], ['d'], 2, 2)).toEqual(['d', 'a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a 2x2 growing to 3x2 keeps every tile at its row and column', () => {
|
||||||
|
expect(T.reformTileCells(['a', 'b', 'c', 'd'], 2, ['a', 'b', 'c', 'd'], ['e', 'f'], 3, 2)).toEqual([
|
||||||
|
'a', 'b', 'e',
|
||||||
|
'c', 'd', 'f',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shrinking 6 (3x2) to 4 (2x2): a tile in the third column does not fit, so the kept ones pack', () => {
|
||||||
|
expect(T.reformTileCells(['a', 'b', 'c', 'd', 'e', 'f'], 3, ['a', 'b', 'c', 'd'], [], 2, 2)).toEqual([
|
||||||
|
'a', 'b', 'c', 'd',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shrinking keeps rows and columns when every kept tile still fits', () => {
|
||||||
|
// 3x2 to 2x2 keeping two tiles inside the first two columns: they stay at
|
||||||
|
// their row and column, holes and all (packing would move b up).
|
||||||
|
expect(T.reformTileCells([null, 'a', 'x', 'b', null, 'y'], 3, ['a', 'b'], [], 2, 2)).toEqual([
|
||||||
|
null, 'a',
|
||||||
|
'b', null,
|
||||||
|
]);
|
||||||
|
// 2x2 to 2x1 keeping the first row.
|
||||||
|
expect(T.reformTileCells(['a', 'b', 'c', 'd'], 2, ['a', 'b'], [], 2, 1)).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a stored grid of unknown shape (oldCols 0) packs', () => {
|
||||||
|
expect(T.reformTileCells([null, 'a', 'b'], 0, ['a', 'b'], ['c'], 2, 2)).toEqual(['a', 'b', 'c', null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a full grid takes no more, and a tile already there is not added twice', () => {
|
||||||
|
expect(T.reformTileCells(['a', 'b'], 2, ['a', 'b'], ['c', 'a'], 2, 1)).toEqual(['a', 'b']);
|
||||||
|
expect(T.reformTileCells(['a', null], 2, ['a'], ['a', 'c'], 2, 1)).toEqual(['a', 'c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The header Tiles button and its right-click count menu (owner
|
||||||
|
* decision 10, which replaced the session picker).
|
||||||
|
*
|
||||||
|
* - The button is opt-in (`showTileGridButton`, hidden by its `--hidden` marker
|
||||||
|
* class) and hard-gated to desktop widths like Split: a JS width check plus a
|
||||||
|
* CSS `@media (max-width: 1179px)` backstop, and never in a solo window.
|
||||||
|
* With the grid open it closes it (`aria-pressed`).
|
||||||
|
* - Right-click (contextmenu, which Shift+F10 and the Menu key fire too) opens
|
||||||
|
* a menu of three counts, 2, 4 and 6, each with its shape, the remembered
|
||||||
|
* count checked (default 6). No session names in it, so no user text.
|
||||||
|
* - A count the window cannot fit is greyed out with the reason, and the
|
||||||
|
* keyboard starts on the largest that fits.
|
||||||
|
* - Arrows move over the counts that fit (wrapping), Home/End, Enter or Space
|
||||||
|
* picks, Escape closes and gives the keyboard back to the Tiles button (the
|
||||||
|
* menu owns its Escape in the global handler, like the tab-group menu), Tab
|
||||||
|
* and a click elsewhere close it.
|
||||||
|
* - A pick is remembered per device (`codeman:tile-count`; `codeman:tile-grid`
|
||||||
|
* stays ids only) and opens that many tiles: the session to focus included,
|
||||||
|
* the rest from tab order. The click and Ctrl+Shift+G then open with it.
|
||||||
|
* - With the grid open a pick re-forms it: a shape change under the cell
|
||||||
|
* model's rule, the focused tile always kept, tiles dropped from the end,
|
||||||
|
* new ones filling the empty cells first, all mounted and laid out before
|
||||||
|
* any connects. Fewer open sessions than the count give fewer tiles.
|
||||||
|
* - The session picker is gone (no method, no CSS, no markup hook).
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
FakeTile,
|
||||||
|
activeElement,
|
||||||
|
body,
|
||||||
|
bySelector,
|
||||||
|
flushFrames,
|
||||||
|
localStore,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
windowStub,
|
||||||
|
type GridApp,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f', 's-g'];
|
||||||
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
const css = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||||
|
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||||
|
const appSrc = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||||
|
const gridSrc = readFileSync(resolve(PUBLIC, 'tile-grid.js'), 'utf8');
|
||||||
|
|
||||||
|
function makeButton() {
|
||||||
|
const btn = new FakeEl();
|
||||||
|
btn.className = 'btn-icon-header btn-tile-grid btn-tile-grid--hidden';
|
||||||
|
bySelector.set('.btn-tile-grid', btn);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menu = () => body.children.find((c) => c.id === 'tileCountMenu') ?? null;
|
||||||
|
const items = () => menu()!.children.filter((c) => c.attrs.role === 'menuitemradio');
|
||||||
|
const item = (n: number) => items().find((i) => i.dataset.count === String(n))!;
|
||||||
|
const hint = () => menu()!.children.find((c) => c.className === 'tile-count-hint') ?? null;
|
||||||
|
const focused = () => (activeElement() as FakeEl | null)?.dataset?.count ?? null;
|
||||||
|
const key = (app: GridApp, k: string) => {
|
||||||
|
const ev = { key: k, preventDefault: vi.fn() };
|
||||||
|
app._tileCountMenu.onKey(ev);
|
||||||
|
return ev;
|
||||||
|
};
|
||||||
|
const open = (app: GridApp) => app.openTileCountMenu({ preventDefault: vi.fn() });
|
||||||
|
|
||||||
|
let wrapRect = { width: 2400, height: 1200 };
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
wrapRect = { width: 2400, height: 1200 };
|
||||||
|
const wrap = new FakeEl();
|
||||||
|
wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height });
|
||||||
|
bySelector.set('.terminal-wrap', wrap);
|
||||||
|
makeButton();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function gridApp(ids = IDS): GridApp {
|
||||||
|
const app = makeGridApp(ids);
|
||||||
|
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the Tiles button', () => {
|
||||||
|
it('shows only when its setting is on and the window is desktop-wide', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
const btn = makeButton();
|
||||||
|
app._applyTileGridButtonVisibility(false);
|
||||||
|
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
|
||||||
|
app._applyTileGridButtonVisibility(true);
|
||||||
|
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(false);
|
||||||
|
windowStub.innerWidth = 1100;
|
||||||
|
app._applyTileGridButtonVisibility(true);
|
||||||
|
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never in a solo window', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.isSoloWindow = true;
|
||||||
|
const btn = makeButton();
|
||||||
|
app._applyTileGridButtonVisibility(true);
|
||||||
|
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the CSS backstops: the hidden marker, the 1179px media query, solo mode', () => {
|
||||||
|
expect(css).toMatch(/\.btn-tile-grid--hidden\s*\{\s*display: none !important;/);
|
||||||
|
expect(css).toMatch(
|
||||||
|
/@media \(max-width: 1179px\)\s*\{[^}]*\.btn-icon-header\.btn-tile-grid[^{]*\{\s*display: none !important;/
|
||||||
|
);
|
||||||
|
expect(css).toMatch(/body\.solo-mode \.btn-tile-grid,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with the grid open, a click closes it, and the button says so meanwhile', () => {
|
||||||
|
const app = gridApp(IDS.slice(0, 3));
|
||||||
|
const btn = bySelector.get('.btn-tile-grid')!;
|
||||||
|
app.openTileGrid(IDS.slice(0, 3));
|
||||||
|
expect(btn.getAttribute('aria-pressed')).toBe('true');
|
||||||
|
expect(btn.classList.contains('tiles-open')).toBe(true);
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe('Tiles: back to a single session (right-click for how many tiles)');
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(btn.getAttribute('aria-pressed')).toBe('false');
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe('Tiles: show several sessions side by side (right-click for how many)');
|
||||||
|
// No native title (the hover card says it, tile-grid-hint.test.ts).
|
||||||
|
expect(btn.title).toBe('');
|
||||||
|
expect(menu()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('right-click opens the count menu, the click still toggles (one function with Ctrl+Shift+G)', () => {
|
||||||
|
expect(html).toContain('onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)"');
|
||||||
|
expect(html).toContain('aria-label="Tiles: show several sessions side by side (right-click for how many)"');
|
||||||
|
expect(html).toContain('aria-describedby="tileGridHint"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the count menu', () => {
|
||||||
|
it('offers 2, 4 and 6 with their shapes, the default 6 checked and focused; no user text', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
const ev = { preventDefault: vi.fn() };
|
||||||
|
app.openTileCountMenu(ev);
|
||||||
|
expect(ev.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(menu()!.attrs.role).toBe('menu');
|
||||||
|
expect(menu()!.attrs['aria-label']).toBe('How many tiles');
|
||||||
|
expect(items().map((i) => i.dataset.count)).toEqual(['2', '4', '6']);
|
||||||
|
expect(items().map((i) => i.attrs['aria-checked'])).toEqual(['false', 'false', 'true']);
|
||||||
|
expect(items().map((i) => i.children[1].textContent)).toEqual(['2 tiles', '4 tiles', '6 tiles']);
|
||||||
|
// Each shape is the grid's own layout for that count: 2x1, 2x2, 3x2 cells.
|
||||||
|
expect(items().map((i) => i.children[0].children.length)).toEqual([2, 4, 6]);
|
||||||
|
expect(items().every((i) => !i.disabled)).toBe(true);
|
||||||
|
expect(hint()).toBeNull();
|
||||||
|
expect(focused()).toBe('6');
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
// No session name anywhere in it.
|
||||||
|
expect(JSON.stringify(menu()!.children.map((c) => c.textContent))).not.toMatch(/s-[a-g]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the remembered count; a bad stored value reads as the default', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
localStore.set('codeman:tile-count', '4');
|
||||||
|
open(app);
|
||||||
|
expect(items().map((i) => i.attrs['aria-checked'])).toEqual(['false', 'true', 'false']);
|
||||||
|
expect(focused()).toBe('4');
|
||||||
|
app.closeTileCountMenu();
|
||||||
|
localStore.set('codeman:tile-count', '5');
|
||||||
|
open(app);
|
||||||
|
expect(item(6).attrs['aria-checked']).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('greys out a count the window cannot fit, says why, and starts on the largest that fits', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
wrapRect = { width: 1200, height: 900 }; // fits 4 (2x2 of 600x450), not 3x2
|
||||||
|
open(app);
|
||||||
|
expect(item(6).disabled).toBe(true);
|
||||||
|
expect(item(6).attrs['aria-disabled']).toBe('true');
|
||||||
|
expect(item(6).title).toBe('This window fits 4 tiles');
|
||||||
|
expect(item(4).disabled).toBe(false);
|
||||||
|
expect(hint()!.textContent).toBe('This window fits 4 tiles');
|
||||||
|
// The remembered 6 stays checked (owner answer), the keyboard starts on 4.
|
||||||
|
expect(item(6).attrs['aria-checked']).toBe('true');
|
||||||
|
expect(focused()).toBe('4');
|
||||||
|
app.closeTileCountMenu();
|
||||||
|
wrapRect = { width: 1000, height: 400 }; // fits 2
|
||||||
|
open(app);
|
||||||
|
expect(items().map((i) => i.disabled)).toEqual([false, true, true]);
|
||||||
|
expect(hint()!.textContent).toBe('This window fits 2 tiles');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a greyed count is never picked, by click or by Enter', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
wrapRect = { width: 1200, height: 900 };
|
||||||
|
open(app);
|
||||||
|
item(6).dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
expect(menu()).not.toBeNull();
|
||||||
|
item(6).focus();
|
||||||
|
key(app, 'Enter');
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(localStore.has('codeman:tile-count')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('arrows move over the counts that fit and wrap; Home and End', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
open(app);
|
||||||
|
expect(key(app, 'ArrowDown').preventDefault).toHaveBeenCalled();
|
||||||
|
expect(focused()).toBe('2');
|
||||||
|
key(app, 'ArrowRight');
|
||||||
|
expect(focused()).toBe('4');
|
||||||
|
key(app, 'ArrowUp');
|
||||||
|
key(app, 'ArrowLeft');
|
||||||
|
expect(focused()).toBe('6');
|
||||||
|
key(app, 'Home');
|
||||||
|
expect(focused()).toBe('2');
|
||||||
|
key(app, 'End');
|
||||||
|
expect(focused()).toBe('6');
|
||||||
|
app.closeTileCountMenu();
|
||||||
|
wrapRect = { width: 1200, height: 900 };
|
||||||
|
open(app);
|
||||||
|
key(app, 'ArrowDown');
|
||||||
|
expect(focused()).toBe('2');
|
||||||
|
key(app, 'ArrowUp');
|
||||||
|
expect(focused()).toBe('4');
|
||||||
|
// The greyed 6 is skipped both ways.
|
||||||
|
key(app, 'ArrowDown');
|
||||||
|
expect(focused()).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape closes it and gives the keyboard back to the Tiles button; closing twice is harmless', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
open(app);
|
||||||
|
const btn = bySelector.get('.btn-tile-grid')!;
|
||||||
|
const ev = key(app, 'Escape');
|
||||||
|
expect(ev.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(menu()).toBeNull();
|
||||||
|
expect(activeElement()).toBe(btn);
|
||||||
|
expect(() => app.closeTileCountMenu()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the menu owns its Escape in the global handler: it closes alone, with the keyboard back on Tiles', () => {
|
||||||
|
const escape = appSrc.slice(appSrc.indexOf("if (e.key === 'Escape') {"), appSrc.indexOf('this.closeAllPanels();'));
|
||||||
|
expect(escape).toMatch(/if \(this\._tileCountMenu\) \{\s*this\.closeTileCountMenu\(\{ refocus: true \}\);\s*return;/);
|
||||||
|
const app = gridApp();
|
||||||
|
open(app);
|
||||||
|
app.closeTileCountMenu({ refocus: true });
|
||||||
|
expect(activeElement()).toBe(bySelector.get('.btn-tile-grid'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Tab closes it; a click elsewhere closes it, a click inside does not', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
open(app);
|
||||||
|
key(app, 'Tab');
|
||||||
|
expect(menu()).toBeNull();
|
||||||
|
open(app);
|
||||||
|
app._tileCountMenu.onOutside({ target: item(4) });
|
||||||
|
expect(menu()).not.toBeNull();
|
||||||
|
app._tileCountMenu.onOutside({ target: new FakeEl() });
|
||||||
|
expect(menu()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes when the keyboard leaves it for something else (a late focus of the single view), not for nothing', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
open(app);
|
||||||
|
// Inside: moving between counts keeps it.
|
||||||
|
menu()!.dispatch('focusout', { relatedTarget: item(2) });
|
||||||
|
expect(menu()).not.toBeNull();
|
||||||
|
// Nowhere (a click on a button in Safari focuses nothing): kept.
|
||||||
|
menu()!.dispatch('focusout', { relatedTarget: null });
|
||||||
|
expect(menu()).not.toBeNull();
|
||||||
|
// Another element, the single view's terminal: closed, the keyboard left there.
|
||||||
|
const textarea = new FakeEl();
|
||||||
|
textarea.className = 'xterm-helper-textarea';
|
||||||
|
textarea.focus();
|
||||||
|
menu()!.dispatch('focusout', { relatedTarget: textarea });
|
||||||
|
expect(menu()).toBeNull();
|
||||||
|
expect(activeElement()).toBe(textarea);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a second right-click closes it; a click on Tiles closes it and toggles', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
open(app);
|
||||||
|
open(app);
|
||||||
|
expect(menu()).toBeNull();
|
||||||
|
open(app);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(menu()).toBeNull();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses in a narrow window', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
windowStub.innerWidth = 1100;
|
||||||
|
open(app);
|
||||||
|
expect(menu()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('picking a count', () => {
|
||||||
|
it('Enter on 2 opens two tiles, the active session included and focused, and remembers 2', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.activeSessionId = 's-c';
|
||||||
|
open(app);
|
||||||
|
key(app, 'ArrowDown'); // 6 -> 2
|
||||||
|
key(app, 'Enter');
|
||||||
|
expect(menu()).toBeNull();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-other', 's-c']);
|
||||||
|
expect(app._tileGrid.focusedId).toBe('s-c');
|
||||||
|
expect(localStore.get('codeman:tile-count')).toBe('2');
|
||||||
|
// The grid's own key stays ids only.
|
||||||
|
const stored = JSON.parse(localStore.get('codeman:tile-grid')!);
|
||||||
|
expect(Object.keys(stored).sort()).toEqual(['colFr', 'focused', 'ids', 'open', 'rowFr', 'v', 'zoomed']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a click on 4 opens four, Space works too', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
open(app);
|
||||||
|
item(4).dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
expect(app._tileGrid.ids).toHaveLength(4);
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
open(app);
|
||||||
|
item(2).focus();
|
||||||
|
key(app, ' ');
|
||||||
|
expect(app._tileGrid.ids).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('then the click and Ctrl+Shift+G open with the remembered count', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
localStore.set('codeman:tile-count', '4');
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b', 's-c']);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
localStore.delete('codeman:tile-grid');
|
||||||
|
localStore.set('codeman:tile-count', '2');
|
||||||
|
app.runTileShortcut('toggle-tile-grid');
|
||||||
|
expect(app._tileGrid.ids).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the default is 6, and the click never opens more than the window fits', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.ids).toHaveLength(6);
|
||||||
|
app.closeTileGrid({ keepStored: false, reselect: false });
|
||||||
|
wrapRect = { width: 1200, height: 900 };
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.ids).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fewer open sessions than the count: fewer tiles', () => {
|
||||||
|
const app = gridApp(['s-a', 's-b']);
|
||||||
|
open(app);
|
||||||
|
item(6).dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ctrl/Cmd+click on a tab with the grid closed', () => {
|
||||||
|
it('opens the remembered count in total, the clicked session among them and focused (owner answer 2)', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
localStore.set('codeman:tile-count', '4');
|
||||||
|
app.addSessionToTiles('s-f');
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b', 's-f']);
|
||||||
|
expect(app._tileGrid.focusedId).toBe('s-f');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with the default 6, six in total, never seven', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.addSessionToTiles('s-g');
|
||||||
|
expect(app._tileGrid.ids).toHaveLength(6);
|
||||||
|
expect(app._tileGrid.ids.at(-1)).toBe('s-g');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('re-forming an open grid', () => {
|
||||||
|
it('6 to 2 keeps the tiles it has first, drops from the end, never the focused one', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS.slice(0, 6), { focusedId: 's-e' });
|
||||||
|
open(app);
|
||||||
|
item(2).dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-e']);
|
||||||
|
expect(app._tileGrid.focusedId).toBe('s-e');
|
||||||
|
expect(app.activeSessionId).toBe('s-e');
|
||||||
|
// The dropped ones were destroyed, not reloaded or kept connected.
|
||||||
|
const destroyed = FakeTile.all.filter((t) => t.destroy.mock.calls.length).map((t) => t.sessionId);
|
||||||
|
expect(destroyed.sort()).toEqual(['s-b', 's-c', 's-d', 's-f']);
|
||||||
|
expect(localStore.get('codeman:tile-count')).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2 to 6 keeps both in their cells and fills from tab order; nothing remounts', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(['s-c', 's-a']);
|
||||||
|
flushFrames();
|
||||||
|
const before = new Map(FakeTile.all.map((t) => [t.sessionId, t]));
|
||||||
|
open(app);
|
||||||
|
item(6).dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-c', 's-a', 's-other', 's-b', 's-d', 's-e']);
|
||||||
|
expect(app._tileFor('s-c')).toBe(before.get('s-c'));
|
||||||
|
expect(app._tileFor('s-a')).toBe(before.get('s-a'));
|
||||||
|
expect(before.get('s-c')!.connect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every joining tile connects only once the grid has its final cells (one fit, one PTY resize)', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(['s-a', 's-b']);
|
||||||
|
const seen: string[] = [];
|
||||||
|
const realMount = app._mountTile.bind(app);
|
||||||
|
app._mountTile = (id: string, opts?: unknown) => {
|
||||||
|
const ok = realMount(id, opts);
|
||||||
|
const tile = app._tileGrid.tiles.get(id)?.tile as FakeTile | undefined;
|
||||||
|
tile?.connect.mockImplementation(async () => {
|
||||||
|
seen.push(`${id}:${app._tileGrid.cells.length}:${app._tileGrid.cells.indexOf(id)}`);
|
||||||
|
});
|
||||||
|
return ok;
|
||||||
|
};
|
||||||
|
open(app);
|
||||||
|
item(6).dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
// Each one connected into the 3x2 grid, already in its final cell.
|
||||||
|
expect(seen).toEqual(['s-other:6:2', 's-c:6:3', 's-d:6:4', 's-e:6:5']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holes fill first: a 3x2 of five with an empty middle cell grows to six there', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS.slice(0, 5));
|
||||||
|
app._tileGrid.cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e'];
|
||||||
|
app._applyTileLayout();
|
||||||
|
open(app);
|
||||||
|
item(6).dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-other', 's-b', 's-c', 's-d', 's-e']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('6 to 4: the cell model rule (a tile left in the third column packs the rest)', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS.slice(0, 6), { focusedId: 's-a' });
|
||||||
|
open(app);
|
||||||
|
item(4).dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
expect(app._tileGrid.cols).toBe(2);
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the same count changes nothing; a zoom the user chose ends with a re-form', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS.slice(0, 4));
|
||||||
|
const cells = app._tileGrid.cells.slice();
|
||||||
|
open(app);
|
||||||
|
item(4).dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
expect(app._tileGrid.cells).toEqual(cells);
|
||||||
|
app.zoomTile('s-b');
|
||||||
|
open(app);
|
||||||
|
item(2).dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
expect(app._tileGrid.zoomedId).toBeNull();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the session picker is gone', () => {
|
||||||
|
it('no picker method, element, style or markup hook is left', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
expect(app.openTilePicker).toBeUndefined();
|
||||||
|
expect(app.closeTilePicker).toBeUndefined();
|
||||||
|
expect(gridSrc).not.toMatch(/tilePickerMenu|tile-picker|openTilePicker|closeTilePicker/);
|
||||||
|
expect(css).not.toMatch(/tile-picker/);
|
||||||
|
expect(html).not.toMatch(/openTilePicker/);
|
||||||
|
expect(appSrc).not.toMatch(/closeTilePicker/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tile has no + (owner decision 9): tiles come from the Tiles button, its menu, Ctrl/Cmd+click, a tab, a group or Run', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(['s-a', 's-b']);
|
||||||
|
const header = app._tileGrid.tiles.get('s-a').header as FakeEl;
|
||||||
|
const actions = header.children.find((c) => c.className === 'tile-actions')!;
|
||||||
|
expect(actions.children.map((b) => b.className)).toEqual(['tile-btn tile-menu', 'tile-btn tile-zoom', 'tile-btn tile-remove']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Draggable column and row dividers.
|
||||||
|
*
|
||||||
|
* Every tile is placed explicitly (`grid-column` / `grid-row`, reading order),
|
||||||
|
* with a divider track between columns and between rows. Dragging a divider
|
||||||
|
* trades size between the two tracks either side, each kept at the minimum tile
|
||||||
|
* size. The affected tiles reflow LOCALLY at most once per animation frame
|
||||||
|
* (`localFit`, no PTY resize), and each hears exactly ONE `fit()` (one PTY
|
||||||
|
* resize) at pointer-up; tiles in other tracks hear nothing. A drag in progress
|
||||||
|
* is torn down when the grid closes or a tile is removed (the split's mid-drag
|
||||||
|
* lesson). Fractions reset when the column or row count changes.
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
FakeTile,
|
||||||
|
body,
|
||||||
|
flushFrames,
|
||||||
|
makeGridApp,
|
||||||
|
rafCallbacks,
|
||||||
|
resetGridHarness,
|
||||||
|
section,
|
||||||
|
type GridApp,
|
||||||
|
tileEl,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
|
||||||
|
const divider = (app: GridApp, key: string) => app._tileGrid.dividers.get(key) as FakeEl;
|
||||||
|
const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id) as FakeTile;
|
||||||
|
|
||||||
|
function grid(ids: string[]): GridApp {
|
||||||
|
const app = makeGridApp(ids);
|
||||||
|
app.openTileGrid(ids);
|
||||||
|
// The tiles' terminals are built one per frame (_connectTilesPaced).
|
||||||
|
flushFrames();
|
||||||
|
for (const t of FakeTile.all) {
|
||||||
|
t.fit.mockClear();
|
||||||
|
t.localFit.mockClear();
|
||||||
|
}
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runFrames = () => {
|
||||||
|
for (const cb of rafCallbacks.splice(0)) cb();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('placement', () => {
|
||||||
|
it('2x2: one column divider, one row divider, tiles placed in reading order', () => {
|
||||||
|
const app = grid(['s-a', 's-b', 's-c', 's-d']);
|
||||||
|
expect([...app._tileGrid.dividers.keys()].sort()).toEqual(['col-0', 'row-0']);
|
||||||
|
expect(section.style.gridTemplateColumns).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)');
|
||||||
|
expect(section.style.gridTemplateRows).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)');
|
||||||
|
expect([tileEl('s-d').style.gridColumn, tileEl('s-d').style.gridRow]).toEqual(['3', '3']);
|
||||||
|
expect([tileEl('s-b').style.gridColumn, tileEl('s-b').style.gridRow]).toEqual(['3', '1']);
|
||||||
|
expect([divider(app, 'col-0').style.gridColumn, divider(app, 'col-0').style.gridRow]).toEqual(['2', '1 / -1']);
|
||||||
|
expect([divider(app, 'row-0').style.gridColumn, divider(app, 'row-0').style.gridRow]).toEqual(['1 / -1', '2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3x2 has two column dividers; a single tile has none', () => {
|
||||||
|
expect([...grid(SIX)._tileGrid.dividers.keys()].sort()).toEqual(['col-0', 'col-1', 'row-0']);
|
||||||
|
resetGridHarness();
|
||||||
|
expect(grid(['s-a'])._tileGrid.dividers.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dividers are separators a screen reader can name', () => {
|
||||||
|
const app = grid(['s-a', 's-b']);
|
||||||
|
const d = divider(app, 'col-0');
|
||||||
|
expect(d.getAttribute('role')).toBe('separator');
|
||||||
|
expect(d.getAttribute('aria-orientation')).toBe('vertical');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dragging', () => {
|
||||||
|
function drag(app: GridApp, key: string, from: number, to: number[]) {
|
||||||
|
const d = divider(app, key);
|
||||||
|
const axisCol = key.startsWith('col');
|
||||||
|
const at = (v: number) => (axisCol ? { clientX: v, clientY: 0 } : { clientX: 0, clientY: v });
|
||||||
|
d.dispatch('pointerdown', {
|
||||||
|
button: 0,
|
||||||
|
pointerId: 7,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
stopPropagation: vi.fn(),
|
||||||
|
...at(from),
|
||||||
|
});
|
||||||
|
for (const v of to) d.dispatch('pointermove', at(v));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reflows locally once per frame and resizes each affected PTY exactly once, at pointer-up', () => {
|
||||||
|
const app = grid(SIX);
|
||||||
|
const d = drag(app, 'col-1', 1600, [1620, 1640, 1660, 1680]);
|
||||||
|
// Four moves, one frame.
|
||||||
|
expect(rafCallbacks).toHaveLength(1);
|
||||||
|
runFrames();
|
||||||
|
// Column 1 and 2 tiles: b, c (row 0) and e, f (row 1).
|
||||||
|
for (const id of ['s-b', 's-c', 's-e', 's-f']) expect(tile(id).localFit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tile('s-a').localFit).not.toHaveBeenCalled();
|
||||||
|
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
|
||||||
|
|
||||||
|
d.dispatch('pointerup', {});
|
||||||
|
for (const id of ['s-b', 's-c', 's-e', 's-f']) expect(tile(id).fit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tile('s-a').fit).not.toHaveBeenCalled();
|
||||||
|
expect(tile('s-d').fit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the frame still queued at pointer-up is applied, so the tracks end where the pointer did', () => {
|
||||||
|
const app = grid(['s-a', 's-b']);
|
||||||
|
const d = drag(app, 'col-0', 1200, [1300]);
|
||||||
|
d.dispatch('pointerup', {});
|
||||||
|
// 2400 wide, 8px padding, one 6px divider: 2386px shared; the left track gained 100px.
|
||||||
|
const [a, b] = app._tileGrid.colFr;
|
||||||
|
expect(Math.round((a / (a + b)) * 2386)).toBe(1293);
|
||||||
|
expect(section.style.gridTemplateColumns).toMatch(/^minmax\(0, [\d.]+fr\) 6px minmax\(0, [\d.]+fr\)$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps both neighbours at the minimum tile size', () => {
|
||||||
|
const app = grid(['s-a', 's-b']);
|
||||||
|
const d = drag(app, 'col-0', 1200, [100]);
|
||||||
|
d.dispatch('pointerup', {});
|
||||||
|
const [a, b] = app._tileGrid.colFr;
|
||||||
|
expect(Math.round((a / (a + b)) * 2386)).toBe(480);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a row divider moves rows and resizes the tiles in them', () => {
|
||||||
|
const app = grid(['s-a', 's-b', 's-c', 's-d']);
|
||||||
|
const d = drag(app, 'row-0', 600, [650]);
|
||||||
|
d.dispatch('pointerup', {});
|
||||||
|
for (const id of ['s-a', 's-b', 's-c', 's-d']) expect(tile(id).fit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app._tileGrid.rowFr[0]).toBeGreaterThan(app._tileGrid.rowFr[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks the cursor and text selection for the drag, released at the end', () => {
|
||||||
|
const app = grid(['s-a', 's-b']);
|
||||||
|
const d = drag(app, 'col-0', 1200, []);
|
||||||
|
expect(body.classList.contains('tile-grid-resizing')).toBe(true);
|
||||||
|
expect(d.classList.contains('dragging')).toBe(true);
|
||||||
|
d.dispatch('pointerup', {});
|
||||||
|
expect(body.classList.contains('tile-grid-resizing')).toBe(false);
|
||||||
|
expect(d.setPointerCapture).toHaveBeenCalledWith(7);
|
||||||
|
expect(d.releasePointerCapture).toHaveBeenCalledWith(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a drag torn down mid-way', () => {
|
||||||
|
function startDrag(app: GridApp) {
|
||||||
|
const d = divider(app, 'col-0');
|
||||||
|
d.dispatch('pointerdown', {
|
||||||
|
button: 0,
|
||||||
|
pointerId: 1,
|
||||||
|
clientX: 800,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
stopPropagation: vi.fn(),
|
||||||
|
});
|
||||||
|
d.dispatch('pointermove', { clientX: 900 });
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('closing the grid ends it: no body lock left behind, no late resize', () => {
|
||||||
|
const app = grid(['s-a', 's-b']);
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
const d = startDrag(app);
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
expect(body.classList.contains('tile-grid-resizing')).toBe(false);
|
||||||
|
expect(d.listeners.pointermove ?? []).toHaveLength(0);
|
||||||
|
runFrames();
|
||||||
|
d.dispatch('pointerup', {});
|
||||||
|
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removing a tile ends it', () => {
|
||||||
|
const app = grid(['s-a', 's-b', 's-c']);
|
||||||
|
startDrag(app);
|
||||||
|
app.removeTile('s-c');
|
||||||
|
expect(body.classList.contains('tile-grid-resizing')).toBe(false);
|
||||||
|
expect(app._tileDividerDragTeardown ?? null).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fractions', () => {
|
||||||
|
it('reset to equal when the column count changes', () => {
|
||||||
|
const app = grid(['s-a', 's-b', 's-c', 's-d']);
|
||||||
|
app._tileGrid.colFr = [3, 1];
|
||||||
|
app._applyTileLayout();
|
||||||
|
expect(app._tileGrid.colFr).toEqual([3, 1]);
|
||||||
|
app.sessions.set('s-e', { id: 's-e', name: 's-e', mode: 'claude', pid: 1 });
|
||||||
|
app.addTile('s-e');
|
||||||
|
expect(app._tileGrid.colFr).toEqual([1, 1, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fractions on fewer columns', () => {
|
||||||
|
it('reset to equal when the column count drops (no stale track left over)', () => {
|
||||||
|
const app = grid(['s-a', 's-b', 's-c', 's-d', 's-e']);
|
||||||
|
app._tileGrid.colFr = [3, 1, 1];
|
||||||
|
app.removeTile('s-e');
|
||||||
|
expect(app._tileGrid.colFr).toEqual([1, 1]);
|
||||||
|
expect(section.style.gridTemplateColumns).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('zoomed', () => {
|
||||||
|
it('has no dividers while a tile fills the grid, and gets them back on restore', () => {
|
||||||
|
const app = grid(['s-a', 's-b', 's-c', 's-d']);
|
||||||
|
const dividerEls = () => section.children.filter((el) => el.className.startsWith('tile-divider'));
|
||||||
|
app.zoomTile('s-a');
|
||||||
|
expect(app._tileGrid.dividers.size).toBe(0);
|
||||||
|
expect(dividerEls()).toHaveLength(0);
|
||||||
|
app.zoomTile('s-a');
|
||||||
|
expect(dividerEls()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The ways into the grid besides the Tiles button.
|
||||||
|
*
|
||||||
|
* - Drag a tab from the strip onto a tile: a session not yet tiled replaces it
|
||||||
|
* (same place, the replaced session keeps running); one already tiled swaps
|
||||||
|
* places. Onto an empty slot (3 tiles in a 2x2): it joins there. The tile
|
||||||
|
* handles the drag in the CAPTURE phase and stops it, because the payload is
|
||||||
|
* the session id as text and xterm's helper textarea would type it into the
|
||||||
|
* PTY; a drag that is not a tab (a file) is left alone.
|
||||||
|
* - Ctrl/Cmd+click on a tab: the session joins the grid (opening it on what
|
||||||
|
* Tiles would bring back) and takes focus, as a human selection; on a window
|
||||||
|
* too narrow for the grid it is an ordinary click.
|
||||||
|
* - "Open group as tiles" in the grouped rail's group menu: the group's live
|
||||||
|
* sessions become the grid.
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
FakeTile,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
section,
|
||||||
|
windowStub,
|
||||||
|
type GridApp,
|
||||||
|
tileEl,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c'];
|
||||||
|
const slots = () => section.children.filter((el) => el.className === 'tile-slot');
|
||||||
|
const dragEvent = () => ({ preventDefault: vi.fn(), stopPropagation: vi.fn(), dataTransfer: { dropEffect: '' } });
|
||||||
|
|
||||||
|
function dropOn(el: FakeEl) {
|
||||||
|
const over = dragEvent();
|
||||||
|
el.dispatch('dragover', over);
|
||||||
|
const drop = dragEvent();
|
||||||
|
el.dispatch('drop', drop);
|
||||||
|
return { over, drop };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dragging a tab onto a tile', () => {
|
||||||
|
it('a session not yet tiled replaces the tile in place; the replaced one keeps running', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app.markIdleAlertSeen.mockClear();
|
||||||
|
const replaced = FakeTile.all.find((t) => t.sessionId === 's-b')!;
|
||||||
|
app.draggedTabId = 's-other';
|
||||||
|
const { over, drop } = dropOn(tileEl('s-b'));
|
||||||
|
|
||||||
|
expect(over.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(drop.stopPropagation).toHaveBeenCalled();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-other', 's-c']);
|
||||||
|
expect(replaced.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app.sessions.has('s-b')).toBe(true);
|
||||||
|
expect(tileEl('s-other').style.gridColumn).toBe('3');
|
||||||
|
expect(app.activeSessionId).toBe('s-other');
|
||||||
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a session already tiled swaps places with the target', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app.draggedTabId = 's-c';
|
||||||
|
dropOn(tileEl('s-a'));
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-c', 's-b', 's-a']);
|
||||||
|
expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 0)).toBe(true);
|
||||||
|
expect(tileEl('s-c').style.gridColumn).toBe('1');
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a drag that is not a tab (a file) is left alone', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app.draggedTabId = null;
|
||||||
|
const { over, drop } = dropOn(tileEl('s-b'));
|
||||||
|
expect(over.preventDefault).not.toHaveBeenCalled();
|
||||||
|
expect(drop.stopPropagation).not.toHaveBeenCalled();
|
||||||
|
expect(app._tileGrid.ids).toEqual(IDS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is handled in the capture phase, before xterm sees the drop', () => {
|
||||||
|
makeGridApp(IDS).openTileGrid(IDS);
|
||||||
|
expect(tileEl('s-a').captureFlags.dragover).toEqual([true]);
|
||||||
|
expect(tileEl('s-a').captureFlags.drop).toEqual([true]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dragging a tab onto an empty slot', () => {
|
||||||
|
// Under 1800px wide three tiles take a 2x2 (over it, they sit side by side).
|
||||||
|
beforeEach(() => {
|
||||||
|
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3 tiles in a 2x2 leave one slot, placed after the tiles', () => {
|
||||||
|
makeGridApp(IDS).openTileGrid(IDS);
|
||||||
|
expect(slots()).toHaveLength(1);
|
||||||
|
expect([slots()[0].style.gridColumn, slots()[0].style.gridRow]).toEqual(['3', '3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a session dropped there joins the grid and takes focus', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app.draggedTabId = 's-other';
|
||||||
|
dropOn(slots()[0]);
|
||||||
|
expect(app._tileGrid.ids).toEqual([...IDS, 's-other']);
|
||||||
|
expect(slots()).toHaveLength(0);
|
||||||
|
expect(app.activeSessionId).toBe('s-other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a full layout has no slot', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(['s-a', 's-b']);
|
||||||
|
expect(slots()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a zoomed grid has no slot; restoring the grid brings it back', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app.zoomTile('s-a');
|
||||||
|
expect(slots()).toHaveLength(0);
|
||||||
|
app.zoomTile('s-a');
|
||||||
|
expect(slots()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ctrl/Cmd+click on a tab', () => {
|
||||||
|
const click = (mods: Record<string, boolean>) => ({ preventDefault: vi.fn(), ...mods });
|
||||||
|
|
||||||
|
it('with the grid closed: opens what the Tiles toggle would, plus the clicked one, focusing it', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.activeSessionId = 's-a';
|
||||||
|
// Nothing remembered, no split: the open sessions in tab order (the clicked one last).
|
||||||
|
app.handleSessionTabClick(click({ ctrlKey: true }), 's-c');
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b', 's-c']);
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with the grid open: adds it and focuses it (Cmd works the same)', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(['s-a', 's-b']);
|
||||||
|
app.markIdleAlertSeen.mockClear();
|
||||||
|
app.handleSessionTabClick(click({ metaKey: true }), 's-c');
|
||||||
|
expect(app._tileGrid.ids).toEqual(IDS);
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on a tiled one: just focuses its tile', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app.handleSessionTabClick(click({ ctrlKey: true }), 's-b');
|
||||||
|
expect(app._tileGrid.ids).toEqual(IDS);
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a plain click is still a plain selection', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
app.handleSessionTabClick(click({}), 's-b');
|
||||||
|
expect(app.selectSession).toHaveBeenCalledWith('s-b', { forceReload: true });
|
||||||
|
expect(app._tileGrid?.open ?? false).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on a window too narrow for the grid it is an ordinary click', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
windowStub.innerWidth = 1100;
|
||||||
|
app.handleSessionTabClick(click({ ctrlKey: true }), 's-b');
|
||||||
|
expect(app.selectSession).toHaveBeenCalledWith('s-b', { forceReload: true });
|
||||||
|
expect(app._tileGrid?.open ?? false).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('"Open group as tiles"', () => {
|
||||||
|
function withGroups(app: GridApp) {
|
||||||
|
app.tabLayout = {
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
refs: [
|
||||||
|
{ kind: 'session', id: 's-c' },
|
||||||
|
// A web tab is never a tile, even one whose id a session also has.
|
||||||
|
{ kind: 'webview', id: 's-b' },
|
||||||
|
{ kind: 'session', id: 'gone' },
|
||||||
|
{ kind: 'session', id: 's-a' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let actions: Array<{ label: string; run: () => void }> = [];
|
||||||
|
app._openTabLayoutMenu = vi.fn((_e: unknown, _k: string, _l: string, a: typeof actions) => {
|
||||||
|
actions = a;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return () => actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('is in the group menu, and opens the group live sessions as tiles', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
const actions = withGroups(app);
|
||||||
|
app.openTabGroupMenu({}, 'g1');
|
||||||
|
const open = actions().find((a) => a.label === 'Open group as tiles');
|
||||||
|
expect(open).toBeTruthy();
|
||||||
|
open!.run();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-c', 's-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces an open grid', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
withGroups(app);
|
||||||
|
app.openTileGrid(['s-b']);
|
||||||
|
app.openGroupAsTiles('g1');
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-c', 's-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the focused session focused when the group holds it, open grid or not', () => {
|
||||||
|
for (const gridOpen of [false, true]) {
|
||||||
|
resetGridHarness();
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
withGroups(app);
|
||||||
|
if (gridOpen) app.openTileGrid(['s-b', 's-a'], { focusedId: 's-a' });
|
||||||
|
else app.activeSessionId = 's-a';
|
||||||
|
app.openGroupAsTiles('g1');
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-c', 's-a']);
|
||||||
|
expect(app._tileGrid.focusedId).toBe('s-a');
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the group's first session when it does not hold the focused one", () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
withGroups(app);
|
||||||
|
app.openTileGrid(['s-b']);
|
||||||
|
app.openGroupAsTiles('g1');
|
||||||
|
expect(app._tileGrid.focusedId).toBe('s-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not offered where the grid cannot open', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
const actions = withGroups(app);
|
||||||
|
windowStub.innerWidth = 1100;
|
||||||
|
app.openTabGroupMenu({}, 'g1');
|
||||||
|
expect(actions().some((a) => a.label === 'Open group as tiles')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The Tiles button's hover card (owner feedback 1: "give me the
|
||||||
|
* hover info to right click over the tile button to adjust it").
|
||||||
|
*
|
||||||
|
* - Installed once with the button's visibility, never in a solo window: a
|
||||||
|
* hidden `role=tooltip` card in body, the button's `aria-describedby`, and
|
||||||
|
* the button's native title removed (two tooltips never stack); the
|
||||||
|
* aria-label stays.
|
||||||
|
* - Shows 300 ms after a pointer that hovers rests on the button, or after a
|
||||||
|
* keyboard focus (`:focus-visible`), under the button. Never for a touch
|
||||||
|
* pointer, a device that cannot hover, a hidden button, or while the count
|
||||||
|
* menu is open.
|
||||||
|
* - Content: "Tiles · N" (the remembered count, live), what a click does
|
||||||
|
* (open or close the grid), "Right-click: choose 2, 4 or 6 tiles", what
|
||||||
|
* opens when the count does not fit the window, and Shift+F10 when shown
|
||||||
|
* from the keyboard; hidden, it holds all of it (the button's description).
|
||||||
|
* - Hides on pointer leave, blur, a click, a right-click or a press on the
|
||||||
|
* button (and stays hidden while the pointer rests there), Escape, a scroll
|
||||||
|
* and a resize; the count menu never opens beside it, and the Escape out of
|
||||||
|
* the menu brings no card back.
|
||||||
|
* - Only `.tile-hint` styles: the panel of the count menu, a fade on opacity
|
||||||
|
* and transform only, nothing under reduced motion or without hover.
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
body,
|
||||||
|
bySelector,
|
||||||
|
localStore,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
windowStub,
|
||||||
|
type GridApp,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
|
||||||
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
const css = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||||
|
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||||
|
|
||||||
|
let btn: FakeEl;
|
||||||
|
let wrapRect = { width: 2400, height: 1200 };
|
||||||
|
const card = () => body.children.find((c) => c.id === 'tileGridHint') ?? null;
|
||||||
|
const lines = () => card()!.children.filter((c) => c.className.includes('tile-hint-line'));
|
||||||
|
const textOf = (row: FakeEl) => row.children.at(-1)!.textContent;
|
||||||
|
const visibleText = () => [
|
||||||
|
card()!.children[0].textContent,
|
||||||
|
...lines()
|
||||||
|
.filter((l) => !l.hidden)
|
||||||
|
.map(textOf),
|
||||||
|
];
|
||||||
|
const pointer = (type: string, pointerType = 'mouse') => btn.dispatch(type, { pointerType });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
wrapRect = { width: 2400, height: 1200 };
|
||||||
|
const wrap = new FakeEl();
|
||||||
|
wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height });
|
||||||
|
bySelector.set('.terminal-wrap', wrap);
|
||||||
|
btn = new FakeEl();
|
||||||
|
btn.className = 'btn-icon-header btn-tile-grid btn-tile-grid--hidden';
|
||||||
|
btn.title = 'Tiles: show several sessions side by side (right-click for how many)';
|
||||||
|
btn.getBoundingClientRect = () => ({ width: 26, height: 26, top: 8, left: 2000, right: 2026, bottom: 34 });
|
||||||
|
bySelector.set('.btn-tile-grid', btn);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function hintApp(): GridApp {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
|
||||||
|
app._applyTileGridButtonVisibility(true);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('installed with the button', () => {
|
||||||
|
it('a hidden tooltip card in body, the button described by it, its native title gone, its label kept', () => {
|
||||||
|
const app = hintApp();
|
||||||
|
expect(card()).not.toBeNull();
|
||||||
|
expect(card()!.attrs.role).toBe('tooltip');
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
expect(btn.getAttribute('aria-describedby')).toBe('tileGridHint');
|
||||||
|
expect(btn.title).toBe('');
|
||||||
|
expect(btn.getAttribute('title')).toBeNull();
|
||||||
|
app._updateTileGridButtonState();
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe('Tiles: show several sessions side by side (right-click for how many)');
|
||||||
|
expect(btn.title).toBe('');
|
||||||
|
// Once: a second call adds no second card.
|
||||||
|
app._applyTileGridButtonVisibility(true);
|
||||||
|
expect(body.children.filter((c) => c.id === 'tileGridHint')).toHaveLength(1);
|
||||||
|
// The markup: no title on the button, described by the card.
|
||||||
|
const tag = html.match(/<button class="btn-icon-header btn-tile-grid[^>]*>/)![0];
|
||||||
|
expect(tag).not.toMatch(/\stitle=/);
|
||||||
|
expect(tag).toContain('aria-describedby="tileGridHint"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never in a solo window', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.isSoloWindow = true;
|
||||||
|
app._applyTileGridButtonVisibility(true);
|
||||||
|
expect(card()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hidden, it holds everything a screen reader should hear, the keyboard line included', () => {
|
||||||
|
hintApp();
|
||||||
|
expect(lines().map((l) => l.hidden)).toEqual([false, false, true, false]);
|
||||||
|
expect(card()!.children[0].textContent).toBe('Tiles · 6');
|
||||||
|
expect(textOf(lines()[3])).toBe('Shift+F10: the same menu from the keyboard');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showing', () => {
|
||||||
|
it('300 ms after a mouse rests on the button, under it and right-aligned', () => {
|
||||||
|
hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(299);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(card()!.hidden).toBe(false);
|
||||||
|
expect(card()!.style.top).toBe('40px');
|
||||||
|
expect(card()!.style.right).toBe(`${2400 - 2026}px`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('from a pointer: no keyboard line; from a keyboard focus: with it', () => {
|
||||||
|
hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
expect(visibleText()).toEqual(['Tiles · 6', 'Click: open the grid', 'Right-click: choose 2, 4 or 6 tiles']);
|
||||||
|
pointer('pointerleave');
|
||||||
|
(btn as unknown as { matches: (s: string) => boolean }).matches = (s) => s === ':focus-visible';
|
||||||
|
btn.dispatch('focus', {});
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
expect(visibleText()).toContain('Shift+F10: the same menu from the keyboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a mouse focus (not :focus-visible) shows nothing', () => {
|
||||||
|
hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
(btn as unknown as { matches: (s: string) => boolean }).matches = () => false;
|
||||||
|
btn.dispatch('focus', {});
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never for a touch pointer, a device that cannot hover, or a hidden button', () => {
|
||||||
|
hintApp();
|
||||||
|
// The setting off, or a narrow window: the button carries its hidden marker.
|
||||||
|
btn.classList.add('btn-tile-grid--hidden');
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(card()!.hidden).toBe(true); // still --hidden
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
pointer('pointerenter', 'touch');
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
windowStub.matchMedia = (q: string) => ({ matches: !q.includes('hover'), addEventListener: vi.fn() });
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never while the count menu is open', () => {
|
||||||
|
const app = hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
app.openTileCountMenu({ preventDefault: vi.fn() });
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('what it says', () => {
|
||||||
|
const show = () => {
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('the remembered count, live, and open or close by the grid', () => {
|
||||||
|
const app = hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
localStore.set('codeman:tile-count', '2');
|
||||||
|
app._renderTileHint();
|
||||||
|
show();
|
||||||
|
expect(visibleText()[0]).toBe('Tiles · 2');
|
||||||
|
// A pick in the menu changes it at once, the card still up.
|
||||||
|
app._rememberTileGridCount(4);
|
||||||
|
expect(visibleText()[0]).toBe('Tiles · 4');
|
||||||
|
app.openTileGrid(IDS.slice(0, 4));
|
||||||
|
expect(visibleText()).toContain('Click: close the grid');
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
expect(visibleText()).toContain('Click: open the grid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a refresh with nothing changed leaves the translated text alone (it compares with the last English)', () => {
|
||||||
|
const app = hintApp();
|
||||||
|
// What the zh-CN translator leaves in the DOM.
|
||||||
|
card()!.children[0].textContent = '平铺 · 6';
|
||||||
|
expect(textOf(lines()[0])).toBe('Click: open the grid');
|
||||||
|
lines()[0].children.at(-1)!.textContent = '单击:打开平铺网格';
|
||||||
|
app._renderTileHint();
|
||||||
|
app._updateTileGridButtonState();
|
||||||
|
expect(card()!.children[0].textContent).toBe('平铺 · 6');
|
||||||
|
expect(textOf(lines()[0])).toBe('单击:打开平铺网格');
|
||||||
|
// A real change writes the new English for the translator.
|
||||||
|
app._rememberTileGridCount(2);
|
||||||
|
expect(card()!.children[0].textContent).toBe('Tiles \u00B7 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a count the window cannot fit says what opens instead (closed) and what fits (open)', () => {
|
||||||
|
const app = hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
wrapRect = { width: 1200, height: 900 }; // fits 4
|
||||||
|
app._renderTileHint();
|
||||||
|
show();
|
||||||
|
expect(visibleText()).toEqual([
|
||||||
|
'Tiles · 6',
|
||||||
|
'Click: open the grid',
|
||||||
|
'Right-click: choose 2, 4 or 6 tiles',
|
||||||
|
'This window fits 4 tiles: a click opens 4',
|
||||||
|
]);
|
||||||
|
localStore.set('codeman:tile-count', '4');
|
||||||
|
app._renderTileHint();
|
||||||
|
expect(visibleText()).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hiding', () => {
|
||||||
|
const shown = () => {
|
||||||
|
const app = hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
expect(card()!.hidden).toBe(false);
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('on pointer leave, and a pending show is cancelled', () => {
|
||||||
|
shown();
|
||||||
|
pointer('pointerleave');
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
pointer('pointerleave');
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on blur', () => {
|
||||||
|
shown();
|
||||||
|
btn.dispatch('blur', {});
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on a press, a click or a right-click on the button, in the capture phase, and stays hidden there', () => {
|
||||||
|
for (const type of ['pointerdown', 'click', 'contextmenu']) {
|
||||||
|
resetGridHarness();
|
||||||
|
bySelector.set('.btn-tile-grid', btn);
|
||||||
|
btn.listeners = {};
|
||||||
|
btn.captureFlags = {};
|
||||||
|
shown();
|
||||||
|
btn.dispatch(type, {});
|
||||||
|
expect(card()!.hidden, type).toBe(true);
|
||||||
|
expect(btn.captureFlags[type].some(Boolean), type).toBe(true);
|
||||||
|
// Still resting on it: no card back.
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(card()!.hidden, type).toBe(true);
|
||||||
|
// Once the pointer has left, a new rest shows it again.
|
||||||
|
pointer('pointerleave');
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
expect(card()!.hidden, type).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a keyboard focus coming back after a click shows it, the pointer still resting there', () => {
|
||||||
|
shown();
|
||||||
|
btn.dispatch('click', {});
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
// Tab away and back, the mouse never moved.
|
||||||
|
btn.dispatch('blur', {});
|
||||||
|
(btn as unknown as { matches: (s: string) => boolean }).matches = (s) => s === ':focus-visible';
|
||||||
|
btn.dispatch('focus', {});
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
expect(card()!.hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the count menu never opens beside it: openTileCountMenu hides it first', () => {
|
||||||
|
const app = shown();
|
||||||
|
app.openTileCountMenu({ preventDefault: vi.fn() });
|
||||||
|
expect(body.children.some((c) => c.id === 'tileCountMenu')).toBe(true);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the Escape out of the menu brings no card back with the keyboard focus', () => {
|
||||||
|
const app = hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
(btn as unknown as { matches: (s: string) => boolean }).matches = (s) => s === ':focus-visible';
|
||||||
|
btn.focus = vi.fn(() => btn.dispatch('focus', {}));
|
||||||
|
app.openTileCountMenu({ preventDefault: vi.fn() });
|
||||||
|
app.closeTileCountMenu({ refocus: true });
|
||||||
|
expect(btn.focus).toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on Escape, a scroll and a resize, listened to only while shown', () => {
|
||||||
|
const docAdd = (windowStub.addEventListener as ReturnType<typeof vi.fn>).mock;
|
||||||
|
const before = docAdd.calls.length;
|
||||||
|
const app = shown();
|
||||||
|
const added = docAdd.calls.slice(before) as Array<[string, () => void, unknown]>;
|
||||||
|
const scroll = added.find(([t]) => t === 'scroll')!;
|
||||||
|
const resize = added.find(([t]) => t === 'resize')!;
|
||||||
|
expect(scroll[2]).toBe(true);
|
||||||
|
scroll[1]();
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
expect((windowStub.removeEventListener as ReturnType<typeof vi.fn>).mock.calls.some(([t]) => t === 'scroll')).toBe(true);
|
||||||
|
pointer('pointerleave');
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
resize[1]();
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
pointer('pointerleave');
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
app._tileHint.onKey({ key: 'Enter' });
|
||||||
|
expect(card()!.hidden).toBe(false);
|
||||||
|
app._tileHint.onKey({ key: 'Escape' });
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hidden after a keyboard show, the keyboard line is back in the description', () => {
|
||||||
|
shown(); // a pointer show: the keyboard line hidden
|
||||||
|
expect(lines()[3].hidden).toBe(true);
|
||||||
|
pointer('pointerleave');
|
||||||
|
expect(lines()[3].hidden).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the CSS', () => {
|
||||||
|
it('the count menu panel, no pointer, a fade on opacity and transform only, none without hover or with reduced motion', () => {
|
||||||
|
const block = css.slice(css.indexOf('.tile-hint {'), css.indexOf('.tile-hint {') + 900);
|
||||||
|
expect(block).toContain('background: var(--floating-bg');
|
||||||
|
expect(block).toContain('border: 1px solid var(--control-border');
|
||||||
|
expect(block).toContain('pointer-events: none;');
|
||||||
|
const keyframes = css.match(/@keyframes tile-hint-in \{([\s\S]*?)\n\}/)![1];
|
||||||
|
expect([...keyframes.matchAll(/^\s*([a-z-]+):/gm)].map((m) => m[1]).sort()).toEqual(['opacity', 'transform']);
|
||||||
|
expect(css).toMatch(/@media \(hover: none\) \{\s*\.tile-hint \{\s*display: none !important;/);
|
||||||
|
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\) \{\s*\.tile-hint \{\s*animation: none;/);
|
||||||
|
expect(css).toMatch(/\.tile-hint\[hidden\],\s*\.tile-hint \[hidden\] \{\s*display: none;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no other header button changes: its rules are .tile-hint only', () => {
|
||||||
|
const start = css.indexOf('*/', css.indexOf("/* The Tiles button's hover card")) + 2;
|
||||||
|
const end = css.indexOf('/* ── Tile grid motion');
|
||||||
|
const rules = css.slice(start, end).replace(/\/\*[\s\S]*?\*\//g, '');
|
||||||
|
const selectors = [...rules.matchAll(/^([^\s@}][^{]*)\{/gm)].map((m) => m[1].trim());
|
||||||
|
expect(selectors.length).toBeGreaterThan(5);
|
||||||
|
for (const sel of selectors) expect(sel, sel).toMatch(/^(\.tile-hint|from|to)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The tile grid in Chinese (zh-CN): every string it puts on
|
||||||
|
* screen has its own entry or pattern in i18n.js, so nothing is left in
|
||||||
|
* English and nothing falls through to the generic leading-verb fallback.
|
||||||
|
*
|
||||||
|
* - Runtime strings are HARVESTED from the real tile code (the shared vm
|
||||||
|
* harness, test/mocks/tile-grid-vm.ts) driven through every state that
|
||||||
|
* writes text: the count menu (cap and window wording), the Attach
|
||||||
|
* overlay (not attached, attaching, exited, ended), zoom, the header
|
||||||
|
* tooltip, the dividers, the empty slot, every toast, the crash-restart
|
||||||
|
* confirm, the Tiles and Split button titles. Each must translate to text
|
||||||
|
* with no Latin word left beyond key names and durations, and read
|
||||||
|
* unchanged in English.
|
||||||
|
* - Static strings: the shortcut registry's tile entries (overlay and App
|
||||||
|
* Settings list), and index.html run through the real translator in JSDOM
|
||||||
|
* (the Tiles button, the App Settings chips, the Help modal's Tiles rows).
|
||||||
|
* - User text stays as typed: session names (tile header) and group names
|
||||||
|
* carry data-i18n-skip, and a session name inside the
|
||||||
|
* confirm passes through the pattern untranslated.
|
||||||
|
*
|
||||||
|
* Port: N/A.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
body,
|
||||||
|
bySelector,
|
||||||
|
fetchSpy,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
section,
|
||||||
|
windowStub,
|
||||||
|
type GridApp,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
|
||||||
|
const I18N = read('i18n.js');
|
||||||
|
const INDEX = read('index.html');
|
||||||
|
|
||||||
|
type Api = {
|
||||||
|
t(s: string, v?: Record<string, string>): string;
|
||||||
|
configure(o: Record<string, string>): void;
|
||||||
|
start(): void;
|
||||||
|
};
|
||||||
|
function translator(language: string) {
|
||||||
|
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>', {
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
url: 'http://localhost/',
|
||||||
|
});
|
||||||
|
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||||
|
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
|
||||||
|
api.configure({ language });
|
||||||
|
return { api, dom };
|
||||||
|
}
|
||||||
|
const zh = translator('zh-CN');
|
||||||
|
const en = translator('en');
|
||||||
|
afterAll(() => {
|
||||||
|
zh.dom.window.close();
|
||||||
|
en.dom.window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// What may stay Latin in a translation: key names, compact durations, glyphs.
|
||||||
|
const ALLOWED = /\b(Ctrl|Cmd|Shift|Alt|Option|Enter|G|F10)\b|<1m|\b\d+[dhm]\b/g;
|
||||||
|
const leftover = (text: string) => text.replace(ALLOWED, '').match(/[A-Za-z]+/g) ?? [];
|
||||||
|
|
||||||
|
/** Every string a translation must handle, with where it was seen. */
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
/** Text in a data-i18n-skip subtree (user text): must never be translated. */
|
||||||
|
const userText = new Set<string>();
|
||||||
|
/**
|
||||||
|
* The harness logo's tooltip and accessible name (and the model box's
|
||||||
|
* tooltip): harness and model NAMES, which stay as they are, plus at most a
|
||||||
|
* note on where the model came from, which translates.
|
||||||
|
*/
|
||||||
|
const harnessLabels = new Map<string, string>();
|
||||||
|
/** The harness labels and models the exercise below gives its sessions. */
|
||||||
|
const HARNESS_NAMES = ['Claude Code', 'DeepSeek', 'Codex', 'qwen3.8-27b', 'Haiku 4.5', 'haiku', 'gpt-5.6-terra'];
|
||||||
|
/**
|
||||||
|
* A title or accessible name inside a skipped subtree: the translator skips
|
||||||
|
* the element's attributes along with its text, so a UI label there stays
|
||||||
|
* English. Only the user text itself may be skipped.
|
||||||
|
*/
|
||||||
|
const labelsInSkip: string[] = [];
|
||||||
|
|
||||||
|
function harvest(root: FakeEl | null | undefined, where: string) {
|
||||||
|
const walk = (el: FakeEl, inSkip: boolean) => {
|
||||||
|
const skip = inSkip || 'data-i18n-skip' in el.attrs;
|
||||||
|
const names = /\b(run-mode-dot|tile-model)\b/.test(el.className);
|
||||||
|
const add = (value: unknown, kind: string) => {
|
||||||
|
if (typeof value !== 'string' || !/[A-Za-z]/.test(value)) return;
|
||||||
|
if (names && !skip && kind !== 'text') harnessLabels.set(value.trim(), `${where} (${kind})`);
|
||||||
|
else if (skip && kind === 'text') userText.add(value.trim());
|
||||||
|
else if (skip) labelsInSkip.push(`${where} (${kind}): ${value}`);
|
||||||
|
else if (!seen.has(value.trim())) seen.set(value.trim(), `${where} (${kind})`);
|
||||||
|
};
|
||||||
|
add(el.textContent, 'text');
|
||||||
|
add(el.title, 'title');
|
||||||
|
add(el.attrs['aria-label'], 'aria-label');
|
||||||
|
add(el.attrs.title, 'title attribute');
|
||||||
|
for (const child of el.children) walk(child, skip);
|
||||||
|
};
|
||||||
|
if (root) walk(root, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function harvestAll(app: GridApp, where: string) {
|
||||||
|
harvest(section, where);
|
||||||
|
harvest(body, where);
|
||||||
|
harvest(bySelector.get('.btn-tile-grid'), where);
|
||||||
|
harvest(bySelector.get('.btn-split'), where);
|
||||||
|
for (const [msg] of (app.showToast as ReturnType<typeof vi.fn>).mock.calls) {
|
||||||
|
if (typeof msg === 'string' && !seen.has(msg)) seen.set(msg, `${where} (toast)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let wrapRect = { width: 2400, height: 1200 };
|
||||||
|
function setUp() {
|
||||||
|
resetGridHarness();
|
||||||
|
wrapRect = { width: 2400, height: 1200 };
|
||||||
|
const wrap = new FakeEl();
|
||||||
|
wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height });
|
||||||
|
bySelector.set('.terminal-wrap', wrap);
|
||||||
|
for (const cls of ['btn-tile-grid', 'btn-split']) {
|
||||||
|
const btn = new FakeEl();
|
||||||
|
btn.className = `btn-icon-header ${cls}`;
|
||||||
|
bySelector.set(`.${cls}`, btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EIGHT = Array.from({ length: 8 }, (_, i) => `s-${i + 1}`);
|
||||||
|
|
||||||
|
/** Drives the real code through every state that writes text, harvesting as it goes. */
|
||||||
|
async function exercise() {
|
||||||
|
setUp();
|
||||||
|
const app = makeGridApp(EIGHT);
|
||||||
|
// The harness logos: a CLI-reported model, a launch one, a custom endpoint's, none.
|
||||||
|
windowStub.__codemanCliCatalog = [
|
||||||
|
{ id: 'claude', label: 'Claude Code' },
|
||||||
|
{ id: 'deepseek', label: 'DeepSeek' },
|
||||||
|
{ id: 'codex', label: 'Codex' },
|
||||||
|
];
|
||||||
|
Object.assign(app.sessions.get('s-1'), {
|
||||||
|
mode: 'deepseek',
|
||||||
|
displayModel: { model: 'qwen3.8-27b', source: 'screen' },
|
||||||
|
});
|
||||||
|
app.sessions.get('s-2').displayModel = { model: 'haiku', source: 'launch' };
|
||||||
|
Object.assign(app.sessions.get('s-3'), {
|
||||||
|
mode: 'codex',
|
||||||
|
displayModel: { model: 'qwen3.8-27b', source: 'custom-endpoint' },
|
||||||
|
});
|
||||||
|
app.sessions.get('s-6').displayModel = { model: 'Haiku 4.5', source: 'statusline' };
|
||||||
|
Object.assign(app.sessions.get('s-4'), {
|
||||||
|
mode: 'deepseek',
|
||||||
|
displayModel: { model: 'qwen3.8-27b', source: 'config' },
|
||||||
|
});
|
||||||
|
let pill = 'idle';
|
||||||
|
app._sidebarRichRow = () => ({ state: pill, pill, since: { at: 1 } });
|
||||||
|
app._mobileOverviewStampText = () => '3m';
|
||||||
|
app.sessions.get('s-2').pid = null;
|
||||||
|
app.sessions.get('s-3').paneExit = { status: 3 };
|
||||||
|
app.sessions.get('s-4').paneExit = { signal: 9 };
|
||||||
|
// A session named like a UI string (a count menu label): user text, never translated.
|
||||||
|
app.sessions.get('s-5').name = '6 tiles';
|
||||||
|
|
||||||
|
// The Tiles button's hover card, closed: the default count, then a count
|
||||||
|
// the window cannot fit (the card is in body, harvested with it).
|
||||||
|
app._applyTileGridButtonVisibility(true);
|
||||||
|
harvestAll(app, 'hover card, closed');
|
||||||
|
wrapRect = { width: 1200, height: 900 };
|
||||||
|
app._rememberTileGridCount(6);
|
||||||
|
harvestAll(app, 'hover card, does not fit');
|
||||||
|
wrapRect = { width: 2400, height: 1200 };
|
||||||
|
app._renderTileHint();
|
||||||
|
|
||||||
|
// The count menu: the cap (nothing greyed), then the window wording.
|
||||||
|
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||||
|
harvestAll(app, 'count menu, cap');
|
||||||
|
app.closeTileCountMenu();
|
||||||
|
wrapRect = { width: 1200, height: 900 };
|
||||||
|
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||||
|
harvestAll(app, 'count menu, window');
|
||||||
|
app.closeTileCountMenu();
|
||||||
|
// A window that fits one tile words it in the singular.
|
||||||
|
wrapRect = { width: 700, height: 500 };
|
||||||
|
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||||
|
harvestAll(app, 'count menu, one tile');
|
||||||
|
app.closeTileCountMenu();
|
||||||
|
wrapRect = { width: 2400, height: 1200 };
|
||||||
|
|
||||||
|
// The grid: five tiles first (an empty slot), then the sixth.
|
||||||
|
app.openTileGrid(EIGHT.slice(0, 5));
|
||||||
|
harvestAll(app, 'grid of five');
|
||||||
|
// The hover card with the grid open, and a count it cannot fit there.
|
||||||
|
section.getBoundingClientRect = () => ({ width: 1200, height: 900, top: 0, left: 0, right: 1200, bottom: 900 });
|
||||||
|
app._renderTileHint();
|
||||||
|
harvestAll(app, 'hover card, open, does not fit');
|
||||||
|
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||||
|
app._renderTileHint();
|
||||||
|
app.addTile('s-6');
|
||||||
|
app._renderTileChrome();
|
||||||
|
harvestAll(app, 'grid of six');
|
||||||
|
for (const state of ['needs you', 'error', 'waiting', 'working', 'done', 'exited']) {
|
||||||
|
pill = state;
|
||||||
|
app._renderTileChrome();
|
||||||
|
harvestAll(app, `tooltip ${state}`);
|
||||||
|
}
|
||||||
|
pill = 'idle';
|
||||||
|
|
||||||
|
// Zoom and back.
|
||||||
|
app.zoomTile('s-1');
|
||||||
|
harvestAll(app, 'zoomed');
|
||||||
|
app.zoomTile('s-1');
|
||||||
|
|
||||||
|
// The toasts of a full grid, by the cap and by the window.
|
||||||
|
app._joinTileGridFromRun('s-7');
|
||||||
|
app.addSessionToTiles('s-7');
|
||||||
|
wrapRect = { width: 1200, height: 900 };
|
||||||
|
section.getBoundingClientRect = () => ({ width: 1200, height: 900, top: 0, left: 0, right: 1200, bottom: 900 });
|
||||||
|
app._joinTileGridFromRun('s-7');
|
||||||
|
app.addSessionToTiles('s-8');
|
||||||
|
// Too small for six: the auto-zoom hint.
|
||||||
|
section.getBoundingClientRect = () => ({ width: 900, height: 400, top: 0, left: 0, right: 900, bottom: 400 });
|
||||||
|
app._applyTileLayout();
|
||||||
|
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||||
|
wrapRect = { width: 2400, height: 1200 };
|
||||||
|
harvestAll(app, 'full grid');
|
||||||
|
|
||||||
|
// Attach: in flight, failed, the crash-restart confirm, a socket that ended.
|
||||||
|
let release: (v: unknown) => void = () => {};
|
||||||
|
fetchSpy.mockImplementationOnce(() => new Promise((r) => (release = r)));
|
||||||
|
const pending = app.attachTileSession('s-2');
|
||||||
|
harvestAll(app, 'attaching');
|
||||||
|
release({ ok: false, json: async () => ({ success: false }) });
|
||||||
|
await pending;
|
||||||
|
harvestAll(app, 'attach failed');
|
||||||
|
const confirmText: string[] = [];
|
||||||
|
windowStub.confirm = vi.fn((msg: string) => {
|
||||||
|
confirmText.push(msg);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
app.sessions.get('s-5').pid = null;
|
||||||
|
app.sessions.get('s-5').respawnBlocked = true;
|
||||||
|
app._renderTileChrome();
|
||||||
|
await app.attachTileSession('s-5');
|
||||||
|
for (const msg of confirmText) seen.set(msg, 'confirm');
|
||||||
|
const tile6 = app._tileFor('s-6');
|
||||||
|
tile6._stoppedCode = 4009;
|
||||||
|
app._renderTileChrome();
|
||||||
|
harvestAll(app, 'socket ended');
|
||||||
|
|
||||||
|
// A group with no live session, and a toggle with nothing to open.
|
||||||
|
app.tabLayout = { groups: [{ id: 'g', name: 'Tiles', refs: [{ kind: 'session', id: 'gone' }] }] };
|
||||||
|
app.openGroupAsTiles('g');
|
||||||
|
harvestAll(app, 'empty group');
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
harvestAll(app, 'grid closed');
|
||||||
|
const empty = makeGridApp([]);
|
||||||
|
empty.sessions.clear();
|
||||||
|
empty.sessionOrder = [];
|
||||||
|
empty.toggleTileGrid();
|
||||||
|
harvestAll(empty, 'nothing to open');
|
||||||
|
return { confirmText };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The harvest runs once, in the first test; the others read what it saw.
|
||||||
|
let confirmText: string[] = [];
|
||||||
|
|
||||||
|
describe('every tile grid string the code puts on screen translates to zh-CN', () => {
|
||||||
|
it('harvests the strings it should (the harvest itself is not vacuous)', async () => {
|
||||||
|
({ confirmText } = await exercise());
|
||||||
|
// ('Tiled sessions', the grid region's label, is markup: the JSDOM test below.)
|
||||||
|
const expected = [
|
||||||
|
'How many tiles',
|
||||||
|
'Tiles',
|
||||||
|
'Tiles \u00B7 6',
|
||||||
|
'Click: open the grid',
|
||||||
|
'Click: close the grid',
|
||||||
|
'Right-click: choose 2, 4 or 6 tiles',
|
||||||
|
'Shift+F10: the same menu from the keyboard',
|
||||||
|
'This window fits 4 tiles: a click opens 4',
|
||||||
|
'2 tiles',
|
||||||
|
'4 tiles',
|
||||||
|
'6 tiles',
|
||||||
|
'The grid holds at most 6 tiles',
|
||||||
|
'This window fits 4 tiles',
|
||||||
|
'This window fits 1 tile',
|
||||||
|
'The grid already holds what this window fits (4)',
|
||||||
|
'Drop a tab or a tile here',
|
||||||
|
'Resize tile columns',
|
||||||
|
'Resize tile rows',
|
||||||
|
'Zoom this tile',
|
||||||
|
'Restore the grid',
|
||||||
|
'Session actions',
|
||||||
|
'Remove tile (the session keeps running)',
|
||||||
|
'idle 3m',
|
||||||
|
'idle 3m\nDrag to move the tile',
|
||||||
|
'needs you 3m\nDrag to move the tile',
|
||||||
|
'exited 3m\nDrag to move the tile',
|
||||||
|
'Not attached',
|
||||||
|
'Attach',
|
||||||
|
'Attaching…',
|
||||||
|
'The agent exited (3)',
|
||||||
|
'The agent exited (signal 9)',
|
||||||
|
'The session ended',
|
||||||
|
'It cannot be restarted in place: close it from ⋯ (Close session).',
|
||||||
|
'The grid holds at most 6 tiles: the new session opens on its own',
|
||||||
|
'The grid already holds what this window fits (4): the new session opens on its own',
|
||||||
|
'The grid already holds what this window fits (4)',
|
||||||
|
'The window is too small for 6 tiles: showing the focused one',
|
||||||
|
'Could not attach the session',
|
||||||
|
'This group has no session to show as tiles',
|
||||||
|
'No sessions to show as tiles',
|
||||||
|
'Split: unavailable while tiles are open',
|
||||||
|
'Tiles: show several sessions side by side (right-click for how many)',
|
||||||
|
'Tiles: back to a single session (right-click for how many tiles)',
|
||||||
|
'6 tiles was stopped after crashing repeatedly. Restart it?',
|
||||||
|
];
|
||||||
|
const missing = expected.filter((s) => !seen.has(s));
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each one: Chinese with no English left, and no half-translated fallback', () => {
|
||||||
|
const bad: string[] = [];
|
||||||
|
for (const [source, where] of seen) {
|
||||||
|
const text = zh.api.t(source);
|
||||||
|
// The session name inside the confirm is user text, allowed to stay.
|
||||||
|
const words = leftover(text.replace('6 tiles', ''));
|
||||||
|
if (text === source || words.length) bad.push(`${where}: "${source}" -> "${text}"`);
|
||||||
|
}
|
||||||
|
expect(bad).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the harness logo keeps harness and model names as they are and translates the rest', () => {
|
||||||
|
// Not vacuous: every form the logo's tooltip takes was seen.
|
||||||
|
expect([...harnessLabels.keys()]).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'DeepSeek \u00B7 qwen3.8-27b',
|
||||||
|
'Claude Code \u00B7 haiku (set at launch)',
|
||||||
|
'Codex \u00B7 qwen3.8-27b (custom endpoint)',
|
||||||
|
'DeepSeek \u00B7 qwen3.8-27b (from config)',
|
||||||
|
'Claude Code \u00B7 Haiku 4.5',
|
||||||
|
'Claude Code',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const bad: string[] = [];
|
||||||
|
for (const [label, where] of harnessLabels) {
|
||||||
|
const text = zh.api.t(label);
|
||||||
|
const kept = HARNESS_NAMES.filter((n) => label.includes(n));
|
||||||
|
let rest = text;
|
||||||
|
for (const n of [...kept].sort((a, b) => b.length - a.length)) rest = rest.split(n).join('');
|
||||||
|
if (!kept.length || kept.some((n) => !text.includes(n)) || leftover(rest).length || en.api.t(label) !== label) {
|
||||||
|
bad.push(`${where}: "${label}" -> "${text}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(bad).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the header tooltip with the drag hint: every state, with and without a duration', () => {
|
||||||
|
// The harvest sees the hint under a state with a duration; a session the
|
||||||
|
// overview gives no time yet shows the bare state above it.
|
||||||
|
expect(zh.api.t('working 3m\nDrag to move the tile')).toBe('工作中 3m\n拖动可移动窗格');
|
||||||
|
expect(zh.api.t('needs you <1m\nDrag to move the tile')).toBe('需要你 <1m\n拖动可移动窗格');
|
||||||
|
expect(zh.api.t('idle\nDrag to move the tile')).toBe('空闲\n拖动可移动窗格');
|
||||||
|
expect(zh.api.t('Drag to move the tile')).toBe('拖动可移动窗格');
|
||||||
|
expect(en.api.t('idle\nDrag to move the tile')).toBe('idle\nDrag to move the tile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no UI label sits inside a skipped subtree (where the translator cannot reach it)', () => {
|
||||||
|
expect(labelsInSkip).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each one reads exactly as before in English', () => {
|
||||||
|
const changed = [...seen.keys()].filter((s) => en.api.t(s) !== s);
|
||||||
|
expect(changed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the shortcut registry entries: the Tiles group, its labels, "not bound", the group menu item', () => {
|
||||||
|
const app = read('app.js');
|
||||||
|
const labels = [...app.matchAll(/group: 'Tiles',\s*label: '([^']+)'/g)].map((m) => m[1]);
|
||||||
|
expect(labels).toHaveLength(11);
|
||||||
|
expect(app).toContain("label: 'Open group as tiles'");
|
||||||
|
expect(app).toContain('not bound');
|
||||||
|
const bad = ['Tiles', 'not bound', 'Open group as tiles', ...labels].filter((s) => {
|
||||||
|
const text = zh.api.t(s);
|
||||||
|
return text === s || leftover(text).length > 0;
|
||||||
|
});
|
||||||
|
expect(bad).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the static markup through the real translator (JSDOM, zh-CN)', () => {
|
||||||
|
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
|
||||||
|
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||||
|
const doc = dom.window.document;
|
||||||
|
const extra = doc.createElement('div');
|
||||||
|
extra.innerHTML =
|
||||||
|
'<span id="u-session" data-i18n-skip>6 tiles</span>' +
|
||||||
|
'<span id="u-group" class="tab-layout-group-name" data-i18n-skip>Tiles</span>' +
|
||||||
|
'<span id="control">Tiles</span>';
|
||||||
|
doc.body.appendChild(extra);
|
||||||
|
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
|
||||||
|
api.start();
|
||||||
|
api.configure({ language: 'zh-CN' });
|
||||||
|
|
||||||
|
it('the Tiles header button: its accessible name, no native title, its description is the hover card', () => {
|
||||||
|
const btn = doc.querySelector('.btn-tile-grid')!;
|
||||||
|
const label = btn.getAttribute('aria-label')!;
|
||||||
|
expect(label).toContain('平铺');
|
||||||
|
expect(leftover(label)).toEqual([]);
|
||||||
|
expect(btn.hasAttribute('title')).toBe(false);
|
||||||
|
expect(btn.getAttribute('aria-describedby')).toBe('tileGridHint');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the App Settings chips (Tiles, and Split beside it) and the grid region', () => {
|
||||||
|
const chip = (id: string) => doc.getElementById(id)!.closest('label')!.textContent!.trim();
|
||||||
|
expect(chip('appSettingsShowTileGridButton')).toBe('平铺');
|
||||||
|
expect(chip('appSettingsShowSplitButton')).toBe('分屏');
|
||||||
|
expect(doc.getElementById('tileGrid')!.getAttribute('aria-label')).toBe('平铺的会话');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the Help modal's Tiles rows: Chinese around the key names", () => {
|
||||||
|
const tiles = [...doc.querySelectorAll('#helpModal .shortcut-section')].find(
|
||||||
|
(s) => s.querySelector('h4')!.textContent === '平铺'
|
||||||
|
);
|
||||||
|
expect(tiles).toBeTruthy();
|
||||||
|
expect(leftover(tiles!.textContent!)).toEqual([]);
|
||||||
|
expect(tiles!.textContent).toContain('切换平铺网格');
|
||||||
|
expect(tiles!.textContent).toContain('右键单击');
|
||||||
|
expect(tiles!.textContent).toContain('选择窗格数量(2、4 或 6)');
|
||||||
|
// Moving tiles: the chords and the header drag.
|
||||||
|
expect(tiles!.textContent).toContain('向左 / 右 / 上 / 下移动窗格');
|
||||||
|
expect(tiles!.textContent).toContain('拖动');
|
||||||
|
expect(tiles!.textContent).toContain('窗格的标题栏');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('user text stays as typed: a session name and a group name that are also UI words', () => {
|
||||||
|
expect(doc.getElementById('u-session')!.textContent).toBe('6 tiles');
|
||||||
|
expect(doc.getElementById('u-group')!.textContent).toBe('Tiles');
|
||||||
|
expect(doc.getElementById('control')!.textContent).toBe('平铺');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('user text in the tile code', () => {
|
||||||
|
it('session names in a tile header are marked data-i18n-skip', () => {
|
||||||
|
// The harvest saw them only inside skipped subtrees.
|
||||||
|
expect(userText.has('6 tiles')).toBe(true);
|
||||||
|
expect([...seen.keys()]).not.toContain('s-1');
|
||||||
|
const src = read('tile-grid.js');
|
||||||
|
expect(src.match(/setAttribute\('data-i18n-skip', ''\)/g)?.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the group names of the grouped rail are marked data-i18n-skip', () => {
|
||||||
|
expect(read('tab-layout-browser.js')).toContain('class="tab-layout-group-name" id="${nameId}" data-i18n-skip>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a session name inside the crash-restart confirm passes through the pattern untranslated', () => {
|
||||||
|
expect(confirmText).toEqual(['6 tiles was stopped after crashing repeatedly. Restart it?']);
|
||||||
|
expect(zh.api.t(confirmText[0])).toBe('6 tiles 因反复崩溃已被停止。要重启吗?');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The tile grid's pure helpers (constants.js, `window.CodemanTileGrid`).
|
||||||
|
*
|
||||||
|
* - `computeTileLayout`: columns x rows by tile count (the spec's table), with
|
||||||
|
* the 3-tile special case (3x1 only on a wide grid area) and whether every
|
||||||
|
* cell clears the minimum tile size.
|
||||||
|
* - `tileGridCapacity`: how many tiles a grid area can hold.
|
||||||
|
* - `sanitizeTileGridState`: the stored `codeman:tile-grid` value made safe to
|
||||||
|
* apply (unknown, deleted, detached and duplicate ids dropped); the stored
|
||||||
|
* `ids` are the grid's cells, `null` for a hole, and come back as `cells`
|
||||||
|
* (holes kept, a dropped id a hole) beside the packed `ids`. The old packed
|
||||||
|
* format reads as cells with no hole.
|
||||||
|
* - `fitTileCells`: the cells after a shape change (owner: an empty cell can be
|
||||||
|
* any cell). Same shape: unchanged; a new one: each tile keeps its row and
|
||||||
|
* column when all fit, else the tiles pack in reading order.
|
||||||
|
* - `tileNeighbor`, `tileInDirection`, `cycleTile`: which tile takes focus when
|
||||||
|
* one leaves, on a directional chord (over cells: never onto a hole), and on
|
||||||
|
* Ctrl+Tab / Alt+[ ]. `tileCellInDirection`: the adjacent cell a Move Tile
|
||||||
|
* chord moves into or swaps with.
|
||||||
|
*
|
||||||
|
* Loaded via `vm` like split-pane-helpers.test.ts. Port: N/A.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
type Layout = { cols: number; rows: number; fits: boolean };
|
||||||
|
type TileGrid = {
|
||||||
|
computeTileLayout(p: Record<string, number>): Layout;
|
||||||
|
tileGridCapacity(p: Record<string, number>): number;
|
||||||
|
sanitizeTileGridState(raw: unknown, live: unknown, detached?: Set<string>): Record<string, unknown> | null;
|
||||||
|
tileNeighbor(ids: string[], id: string): string | null;
|
||||||
|
tileInDirection(cells: Array<string | null>, focused: string, dir: string, cols: number): string | null;
|
||||||
|
tileCellInDirection(index: number, dir: string, cols: number, cellCount: number): number;
|
||||||
|
fitTileCells(cells: Array<string | null>, oldCols: number, cols: number, rows: number): Array<string | null>;
|
||||||
|
cycleTile(ids: string[], focused: string, delta: number): string | null;
|
||||||
|
TILE_GRID_MAX: number;
|
||||||
|
TILE_LAYOUT_MAX: number;
|
||||||
|
TILE_MIN_W: number;
|
||||||
|
TILE_MIN_H: number;
|
||||||
|
TILE_SCROLLBACK: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadTileGrid(): TileGrid {
|
||||||
|
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||||
|
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||||
|
return (context.window as { CodemanTileGrid: TileGrid }).CodemanTileGrid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const T = loadTileGrid();
|
||||||
|
// Plenty of room: every layout fits.
|
||||||
|
const BIG = { width: 3000, height: 2000 };
|
||||||
|
|
||||||
|
describe('computeTileLayout', () => {
|
||||||
|
it.each([
|
||||||
|
[1, 1, 1],
|
||||||
|
[2, 2, 1],
|
||||||
|
[4, 2, 2],
|
||||||
|
[5, 3, 2],
|
||||||
|
[6, 3, 2],
|
||||||
|
[7, 3, 3],
|
||||||
|
[8, 3, 3],
|
||||||
|
[9, 3, 3],
|
||||||
|
])('%i tiles lay out as %ix%i', (count, cols, rows) => {
|
||||||
|
expect(T.computeTileLayout({ count, ...BIG })).toMatchObject({ cols, rows, fits: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts 3 tiles side by side only on a grid area at least 1800px wide', () => {
|
||||||
|
expect(T.computeTileLayout({ count: 3, width: 1800, height: 900 })).toMatchObject({ cols: 3, rows: 1 });
|
||||||
|
expect(T.computeTileLayout({ count: 3, width: 1799, height: 900 })).toMatchObject({ cols: 2, rows: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lays out up to 9 (past the cap, unreachable but kept) and treats nothing as an empty grid', () => {
|
||||||
|
expect(T.TILE_LAYOUT_MAX).toBe(9);
|
||||||
|
expect(T.computeTileLayout({ count: 12, ...BIG })).toMatchObject({ cols: 3, rows: 3 });
|
||||||
|
expect(T.computeTileLayout({ count: 0, ...BIG })).toMatchObject({ cols: 0, rows: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports whether every cell clears the minimum tile size', () => {
|
||||||
|
// 3x2 needs 3 * 480 = 1440 wide and 2 * 240 = 480 high.
|
||||||
|
expect(T.computeTileLayout({ count: 6, width: 1440, height: 480 }).fits).toBe(true);
|
||||||
|
expect(T.computeTileLayout({ count: 6, width: 1439, height: 480 }).fits).toBe(false);
|
||||||
|
expect(T.computeTileLayout({ count: 6, width: 1440, height: 479 }).fits).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tileGridCapacity', () => {
|
||||||
|
it('never holds more than the cap of 6 (owner decision 7), even where nine would fit', () => {
|
||||||
|
expect(T.TILE_GRID_MAX).toBe(6);
|
||||||
|
expect(T.computeTileLayout({ count: 9, ...BIG }).fits).toBe(true);
|
||||||
|
expect(T.tileGridCapacity(BIG)).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops at the first count whose layout does not fit', () => {
|
||||||
|
// 1440 x 600: 3x2 fits (480 x 300) but 3x3 (480 x 200) does not.
|
||||||
|
expect(T.tileGridCapacity({ width: 1440, height: 600 })).toBe(6);
|
||||||
|
// 1200 x 900: 2x2 fits (600 x 450), 3x2 does not (400 wide).
|
||||||
|
expect(T.tileGridCapacity({ width: 1200, height: 900 })).toBe(4);
|
||||||
|
// 1000 x 400: 2x1 fits (500 x 400); three tiles take a 2x2 (200 high), which does not.
|
||||||
|
expect(T.tileGridCapacity({ width: 1000, height: 400 })).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is 0 when not even one tile fits', () => {
|
||||||
|
expect(T.tileGridCapacity({ width: 400, height: 900 })).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeTileGridState', () => {
|
||||||
|
const live = new Map([
|
||||||
|
['a', {}],
|
||||||
|
['b', {}],
|
||||||
|
['c', {}],
|
||||||
|
['d', {}],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('keeps a valid stored grid as it is', () => {
|
||||||
|
const raw = { v: 1, open: true, ids: ['a', 'b'], focused: 'b', zoomed: 'a', colFr: [1, 2], rowFr: [1] };
|
||||||
|
expect(T.sanitizeTileGridState(raw, live, new Set())).toEqual({
|
||||||
|
v: 1,
|
||||||
|
open: true,
|
||||||
|
ids: ['a', 'b'],
|
||||||
|
cells: ['a', 'b'],
|
||||||
|
focused: 'b',
|
||||||
|
zoomed: 'a',
|
||||||
|
colFr: [1, 2],
|
||||||
|
rowFr: [1],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the stored JSON string', () => {
|
||||||
|
const raw = JSON.stringify({ v: 1, open: true, ids: ['c'], focused: 'c' });
|
||||||
|
expect(T.sanitizeTileGridState(raw, live)?.ids).toEqual(['c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops unknown (deleted), detached and duplicate ids', () => {
|
||||||
|
const raw = { v: 1, open: true, ids: ['a', 'gone', 'b', 'a', 'c', 7, ''], focused: 'a' };
|
||||||
|
const out = T.sanitizeTileGridState(raw, live, new Set(['b']));
|
||||||
|
expect(out?.ids).toEqual(['a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves focus to the first kept tile when the focused one was dropped, and drops a dropped zoom', () => {
|
||||||
|
const raw = { v: 1, open: true, ids: ['gone', 'b', 'c'], focused: 'gone', zoomed: 'gone' };
|
||||||
|
const out = T.sanitizeTileGridState(raw, live);
|
||||||
|
expect(out?.focused).toBe('b');
|
||||||
|
expect(out?.zoomed).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is closed when no tile survives', () => {
|
||||||
|
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: ['gone'] }, live);
|
||||||
|
expect(out).toMatchObject({ open: false, ids: [], focused: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the list at the cap (6): the extras are dropped', () => {
|
||||||
|
const many = Array.from({ length: 12 }, (_, i) => `s${i}`);
|
||||||
|
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: many }, many);
|
||||||
|
expect(out?.ids).toEqual(many.slice(0, 6));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a stored 3x3 keeps its focus if it survives the cap, and loses a zoom that did not', () => {
|
||||||
|
const nine = Array.from({ length: 9 }, (_, i) => `s${i}`);
|
||||||
|
const kept = T.sanitizeTileGridState({ v: 1, open: true, ids: nine, focused: 's4', zoomed: 's7' }, nine);
|
||||||
|
expect(kept).toMatchObject({ ids: nine.slice(0, 6), focused: 's4', zoomed: null });
|
||||||
|
const lost = T.sanitizeTileGridState({ v: 1, open: true, ids: nine, focused: 's8', zoomed: 's2' }, nine);
|
||||||
|
expect(lost).toMatchObject({ focused: 's0', zoomed: 's2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops malformed track fractions', () => {
|
||||||
|
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: ['a'], colFr: [1, -1], rowFr: [1, 1, 1, 1] }, live);
|
||||||
|
expect(out?.colFr).toBeNull();
|
||||||
|
expect(out?.rowFr).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stored cells keep their holes; the list beside them is packed', () => {
|
||||||
|
const raw = { v: 1, open: true, ids: ['a', null, 'b', 'c', 'd', null], focused: 'c' };
|
||||||
|
const out = T.sanitizeTileGridState(raw, live);
|
||||||
|
expect(out?.cells).toEqual(['a', null, 'b', 'c', 'd', null]);
|
||||||
|
expect(out?.ids).toEqual(['a', 'b', 'c', 'd']);
|
||||||
|
expect(out?.focused).toBe('c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a dropped id (gone, detached, a duplicate, malformed) leaves a hole where it was, never a shift', () => {
|
||||||
|
const raw = { v: 1, open: true, ids: ['a', 'gone', 'b', 'a', 7, 'c'] };
|
||||||
|
const out = T.sanitizeTileGridState(raw, live, new Set(['b']));
|
||||||
|
expect(out?.cells).toEqual(['a', null, null, null, null, 'c']);
|
||||||
|
expect(out?.ids).toEqual(['a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the old packed format reads unchanged: cells with no hole', () => {
|
||||||
|
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: ['a', 'b', 'c'] }, live);
|
||||||
|
expect(out?.cells).toEqual(['a', 'b', 'c']);
|
||||||
|
expect(out?.ids).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('past the cap a tile becomes a hole; no more cells than the largest layout', () => {
|
||||||
|
const many = Array.from({ length: 12 }, (_, i) => `s${i}`);
|
||||||
|
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: many }, many);
|
||||||
|
expect(out?.cells).toEqual([...many.slice(0, 6), null, null, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, 'not json', '[]', 42, { v: 2, ids: ['a'] }, { ids: ['a'] }])('rejects %j', (raw) => {
|
||||||
|
expect(T.sanitizeTileGridState(raw, live)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('focus helpers', () => {
|
||||||
|
it('tileNeighbor prefers the next tile, then the previous one', () => {
|
||||||
|
expect(T.tileNeighbor(['a', 'b', 'c'], 'b')).toBe('c');
|
||||||
|
expect(T.tileNeighbor(['a', 'b', 'c'], 'c')).toBe('b');
|
||||||
|
expect(T.tileNeighbor(['a'], 'a')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tileInDirection moves within a row-major grid', () => {
|
||||||
|
// 3x2: a b c
|
||||||
|
// d e
|
||||||
|
const ids = ['a', 'b', 'c', 'd', 'e'];
|
||||||
|
expect(T.tileInDirection(ids, 'b', 'left', 3)).toBe('a');
|
||||||
|
expect(T.tileInDirection(ids, 'a', 'left', 3)).toBeNull();
|
||||||
|
expect(T.tileInDirection(ids, 'b', 'right', 3)).toBe('c');
|
||||||
|
expect(T.tileInDirection(ids, 'c', 'right', 3)).toBeNull();
|
||||||
|
expect(T.tileInDirection(ids, 'e', 'right', 3)).toBeNull();
|
||||||
|
expect(T.tileInDirection(ids, 'd', 'up', 3)).toBe('a');
|
||||||
|
expect(T.tileInDirection(ids, 'a', 'up', 3)).toBeNull();
|
||||||
|
expect(T.tileInDirection(ids, 'b', 'down', 3)).toBe('e');
|
||||||
|
// Nothing below c in that column: the short last row's last tile.
|
||||||
|
expect(T.tileInDirection(ids, 'c', 'down', 3)).toBe('e');
|
||||||
|
expect(T.tileInDirection(ids, 'e', 'down', 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tileInDirection never lands on a hole: along a row it skips one, never leaving the row', () => {
|
||||||
|
// 3x2: a _ c
|
||||||
|
// d e f
|
||||||
|
const cells = ['a', null, 'c', 'd', 'e', 'f'];
|
||||||
|
expect(T.tileInDirection(cells, 'a', 'right', 3)).toBe('c');
|
||||||
|
expect(T.tileInDirection(cells, 'c', 'left', 3)).toBe('a');
|
||||||
|
expect(T.tileInDirection(cells, 'c', 'right', 3)).toBeNull();
|
||||||
|
expect(T.tileInDirection(cells, 'd', 'left', 3)).toBeNull();
|
||||||
|
// Up from e (a hole above): the nearest tile in that row, the lower column on a tie.
|
||||||
|
expect(T.tileInDirection(cells, 'e', 'up', 3)).toBe('a');
|
||||||
|
expect(T.tileInDirection(cells, 'f', 'up', 3)).toBe('c');
|
||||||
|
expect(T.tileInDirection(cells, 'a', 'down', 3)).toBe('d');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tileInDirection: up and down go to the nearest row that has a tile, even across a column', () => {
|
||||||
|
// 3x2: a b c
|
||||||
|
// _ _ f
|
||||||
|
const cells = ['a', 'b', 'c', null, null, 'f'];
|
||||||
|
expect(T.tileInDirection(cells, 'a', 'down', 3)).toBe('f');
|
||||||
|
expect(T.tileInDirection(cells, 'c', 'down', 3)).toBe('f');
|
||||||
|
expect(T.tileInDirection(cells, 'f', 'up', 3)).toBe('c');
|
||||||
|
expect(T.tileInDirection(cells, 'f', 'left', 3)).toBeNull();
|
||||||
|
// A row with no tile at all is passed over (a 3x3, which the layout table keeps).
|
||||||
|
const tall = ['a', null, null, null, null, null, null, 'h', null];
|
||||||
|
expect(T.tileInDirection(tall, 'a', 'down', 3)).toBe('h');
|
||||||
|
expect(T.tileInDirection(tall, 'h', 'up', 3)).toBe('a');
|
||||||
|
// A hole is never the focused cell either.
|
||||||
|
expect(T.tileInDirection(cells, null as unknown as string, 'up', 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tileCellInDirection: the adjacent cell, or -1 at the edge', () => {
|
||||||
|
// 3x2 cells 0 1 2 / 3 4 5
|
||||||
|
expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'left', 3, 6))).toEqual([-1, 0, 1, -1, 3, 4]);
|
||||||
|
expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'right', 3, 6))).toEqual([1, 2, -1, 4, 5, -1]);
|
||||||
|
expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'up', 3, 6))).toEqual([-1, -1, -1, 0, 1, 2]);
|
||||||
|
expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'down', 3, 6))).toEqual([3, 4, 5, -1, -1, -1]);
|
||||||
|
// 2x2
|
||||||
|
expect([0, 1, 2, 3].map((i) => T.tileCellInDirection(i, 'down', 2, 4))).toEqual([2, 3, -1, -1]);
|
||||||
|
expect(T.tileCellInDirection(7, 'left', 3, 6)).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cycleTile wraps in reading order', () => {
|
||||||
|
expect(T.cycleTile(['a', 'b', 'c'], 'c', 1)).toBe('a');
|
||||||
|
expect(T.cycleTile(['a', 'b', 'c'], 'a', -1)).toBe('c');
|
||||||
|
expect(T.cycleTile([], 'a', 1)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fitTileCells (the cells after a shape change)', () => {
|
||||||
|
it('the same shape keeps every cell, holes included', () => {
|
||||||
|
expect(T.fitTileCells(['a', null, 'c', 'd', 'e', 'f'], 3, 3, 2)).toEqual(['a', null, 'c', 'd', 'e', 'f']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a first layout (no columns yet) packs and pads', () => {
|
||||||
|
expect(T.fitTileCells(['a', 'b', 'c', 'd', 'e'], 0, 3, 2)).toEqual(['a', 'b', 'c', 'd', 'e', null]);
|
||||||
|
expect(T.fitTileCells(['a', 'b', 'c'], 0, 2, 2)).toEqual(['a', 'b', 'c', null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('growing 2x2 to 3x2: every tile keeps its row and column', () => {
|
||||||
|
expect(T.fitTileCells(['a', 'b', 'c', 'd'], 2, 3, 2)).toEqual(['a', 'b', null, 'c', 'd', null]);
|
||||||
|
// 2x1 to 2x2 likewise (the same as packing there).
|
||||||
|
expect(T.fitTileCells(['a', 'b'], 2, 2, 2)).toEqual(['a', 'b', null, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shrinking: kept where every tile fits, else packed in reading order', () => {
|
||||||
|
// 3x2 -> 2x2 with the third column empty: the tiles stay put.
|
||||||
|
expect(T.fitTileCells(['a', 'b', null, 'd', 'e', null], 3, 2, 2)).toEqual(['a', 'b', 'd', 'e']);
|
||||||
|
// A tile in the third column: packed, holes collapsed.
|
||||||
|
expect(T.fitTileCells(['a', null, 'c', 'd', 'e', null], 3, 2, 2)).toEqual(['a', 'c', 'd', 'e']);
|
||||||
|
// 2x2 -> 3x1 (a wider window, three tiles): row 1 does not fit, packed.
|
||||||
|
expect(T.fitTileCells(['a', null, 'c', 'd'], 2, 3, 1)).toEqual(['a', 'c', 'd']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dragTrackFractions (divider drags)', () => {
|
||||||
|
const drag = (fr: number[], i: number, d: number, total = 1200, min = 300) =>
|
||||||
|
(T as unknown as { dragTrackFractions: (...a: unknown[]) => number[] }).dragTrackFractions(fr, i, d, total, min);
|
||||||
|
const px = (fr: number[], total = 1200) => fr.map((f) => Math.round((f / fr.reduce((a, b) => a + b, 0)) * total));
|
||||||
|
|
||||||
|
it('moves size from one neighbour to the other, the rest untouched', () => {
|
||||||
|
// 3 equal tracks of 400px; divider 0 moves 100px right.
|
||||||
|
expect(px(drag([1, 1, 1], 0, 100))).toEqual([500, 300, 400]);
|
||||||
|
expect(px(drag([1, 1, 1], 1, -50))).toEqual([400, 350, 450]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps both neighbours to the minimum size', () => {
|
||||||
|
expect(px(drag([1, 1, 1], 0, 300))).toEqual([500, 300, 400]);
|
||||||
|
expect(px(drag([1, 1, 1], 0, -300))).toEqual([300, 500, 400]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a pair too small for two minimums splits evenly instead of inverting', () => {
|
||||||
|
expect(px(drag([1, 1], 0, 200, 500, 300), 500)).toEqual([250, 250]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works from the fractions the drag started with (no drift)', () => {
|
||||||
|
const start = [2, 1];
|
||||||
|
expect(px(drag(start, 0, 0))).toEqual([800, 400]);
|
||||||
|
expect(start).toEqual([2, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a divider that is not between two tracks', () => {
|
||||||
|
expect(drag([1, 1], 1, 100)).toEqual([1, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tile constants', () => {
|
||||||
|
it('a tile keeps 10,000 lines of scrollback, not the primary pane 50,000', () => {
|
||||||
|
expect(T.TILE_SCROLLBACK).toBe(10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Every capture a grid tile fetches goes through ONE queue.
|
||||||
|
*
|
||||||
|
* `GET /api/sessions/:id/terminal` runs synchronous tmux calls on the server, so
|
||||||
|
* N tiles loading at once do not load in parallel: they stall every WebSocket
|
||||||
|
* and SSE stream on the server back to back. The grid therefore hands each
|
||||||
|
* TerminalTile a `scheduleLoad` (a TileLoadQueue, terminal-tile.js) and the
|
||||||
|
* tile routes EVERY capture through it: the initial load, the refresh after a
|
||||||
|
* reconnect, a server `{t:'r'}` refresh and the shell history pull.
|
||||||
|
*
|
||||||
|
* Pinned here, with `connect()` and the socket handlers running for real:
|
||||||
|
* - at most one `/terminal` fetch is in flight at a time, for initial loads and
|
||||||
|
* for N tiles reconnecting together;
|
||||||
|
* - the focused tile goes first, then reading order, and a history pull (the
|
||||||
|
* user is waiting on it) jumps ahead of background refreshes;
|
||||||
|
* - `{t:'r'}` goes through the same queue, and a tile waiting its turn keeps its
|
||||||
|
* last frame (the clear happens at its turn);
|
||||||
|
* - a destroyed tile's queued load is dropped, and destroying the tile whose
|
||||||
|
* load is running aborts its fetch so the queue moves on;
|
||||||
|
* - a load that never answers is cut off by its deadline;
|
||||||
|
* - a close while a load only WAITS writes the disconnected marker at once;
|
||||||
|
* - grid tiles load a bounded window and keep TILE_SCROLLBACK lines.
|
||||||
|
*
|
||||||
|
* Real code under test: constants.js + app.js + terminal-ui.js +
|
||||||
|
* terminal-tile.js in one `vm` context; xterm, the fit addon and WebSocket are
|
||||||
|
* fakes (test/mocks/terminal-tile-fakes.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { FakeFit, FakeSocket, FakeTerminal } from './mocks/terminal-tile-fakes.js';
|
||||||
|
|
||||||
|
/** One `/terminal` fetch the test answers (or lets hang) by hand. */
|
||||||
|
type Capture = { url: string; settled: boolean; aborted: boolean; answer(body: string): void };
|
||||||
|
let captures: Capture[] = [];
|
||||||
|
const fetchMock = vi.fn((url: string, init?: { signal?: AbortSignal }) => {
|
||||||
|
return new Promise((resolveFetch, rejectFetch) => {
|
||||||
|
const capture: Capture = {
|
||||||
|
url,
|
||||||
|
settled: false,
|
||||||
|
aborted: false,
|
||||||
|
answer(body: string) {
|
||||||
|
capture.settled = true;
|
||||||
|
resolveFetch({ ok: true, status: 200, json: async () => ({ data: { terminalBuffer: body } }) });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
init?.signal?.addEventListener('abort', () => {
|
||||||
|
capture.settled = true;
|
||||||
|
capture.aborted = true;
|
||||||
|
rejectFetch(new Error('aborted'));
|
||||||
|
});
|
||||||
|
captures.push(capture);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const inFlight = () => captures.filter((c) => !c.settled).length;
|
||||||
|
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||||
|
const windowStub: Record<string, unknown> = {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
CodemanBase: { base: '' },
|
||||||
|
AbortController,
|
||||||
|
};
|
||||||
|
const context = vm.createContext({
|
||||||
|
console: { ...console, log: vi.fn(), debug: vi.fn() },
|
||||||
|
performance,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
// Late-bound so vi.useFakeTimers() reaches code running in this context.
|
||||||
|
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
|
||||||
|
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
WebSocket: FakeSocket,
|
||||||
|
Terminal: FakeTerminal,
|
||||||
|
FitAddon: { FitAddon: FakeFit },
|
||||||
|
fetch: (...args: Parameters<typeof fetchMock>) => fetchMock(...args),
|
||||||
|
location: { protocol: 'http:', host: 'codeman.test' },
|
||||||
|
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
|
||||||
|
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||||
|
window: windowStub,
|
||||||
|
MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
|
||||||
|
});
|
||||||
|
vm.runInContext(
|
||||||
|
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
|
||||||
|
'globalThis.__CodemanApp = CodemanApp;',
|
||||||
|
context
|
||||||
|
);
|
||||||
|
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
||||||
|
const TileGrid = windowStub.CodemanTileGrid as { TILE_SCROLLBACK: number };
|
||||||
|
const TAIL = 1024 * 1024;
|
||||||
|
// A grid tile's full captures read no more tmux history than its xterm keeps:
|
||||||
|
// TILE_SCROLLBACK plus the screen (the fake terminal has 24 rows).
|
||||||
|
const LINES = `&lines=${TileGrid.TILE_SCROLLBACK + 24}`;
|
||||||
|
|
||||||
|
type Tile = {
|
||||||
|
sessionId: string;
|
||||||
|
connect(): Promise<void>;
|
||||||
|
destroy(): void;
|
||||||
|
reconnectNow(): void;
|
||||||
|
_maybeLoadMoreHistory(): void;
|
||||||
|
_destroyed: boolean;
|
||||||
|
terminal: FakeTerminal | null;
|
||||||
|
ws: FakeSocket | null;
|
||||||
|
};
|
||||||
|
type Queue = {
|
||||||
|
schedule(tile: object, kind: string, run: () => Promise<void>): Promise<void>;
|
||||||
|
size: number;
|
||||||
|
activeTile: object | null;
|
||||||
|
};
|
||||||
|
const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile;
|
||||||
|
const TileLoadQueue = windowStub.TileLoadQueue as new (opts?: object) => Queue;
|
||||||
|
|
||||||
|
function makeApp() {
|
||||||
|
const app = Object.create(CodemanApp.prototype) as Record<string, unknown>;
|
||||||
|
app._clientId = 'c-test';
|
||||||
|
app._wsTabNonce = 'nonce-1';
|
||||||
|
app._seqCounters = new Map();
|
||||||
|
app._pendingDeliveries = new Map();
|
||||||
|
app._postDraining = new Set();
|
||||||
|
app._extraInputSockets = new Map();
|
||||||
|
app._persistReliableState = vi.fn();
|
||||||
|
app._persistReliableNow = vi.fn();
|
||||||
|
app._updateConnectionIndicator = vi.fn();
|
||||||
|
app.loadAppSettingsFromStorage = () => ({});
|
||||||
|
app._estimateReplayRows = (text: string) => text.split('\n').length;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveTiles: Tile[] = [];
|
||||||
|
/** Grid-style tiles sharing one queue: the focused id ranks first, then the order given. */
|
||||||
|
function makeGrid(ids: string[], { focused = ids[0], modes = {} as Record<string, string> } = {}) {
|
||||||
|
windowStub.app = makeApp();
|
||||||
|
const order = [...ids];
|
||||||
|
const states: Array<[string, string]> = [];
|
||||||
|
const queue = new TileLoadQueue({
|
||||||
|
rank: (tile: Tile) => (tile.sessionId === focused ? -1 : order.indexOf(tile.sessionId)),
|
||||||
|
onChange: (tile: Tile, state: string) => states.push([tile.sessionId, state]),
|
||||||
|
});
|
||||||
|
const tiles = ids.map((id) => {
|
||||||
|
const tile = new TerminalTile(
|
||||||
|
id,
|
||||||
|
{ addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
{
|
||||||
|
mode: modes[id] ?? 'claude',
|
||||||
|
scheduleLoad: (t: object, kind: string, run: () => Promise<void>) => queue.schedule(t, kind, run),
|
||||||
|
scrollback: TileGrid.TILE_SCROLLBACK,
|
||||||
|
fontSize: 13,
|
||||||
|
boundedLoad: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
liveTiles.push(tile);
|
||||||
|
return tile;
|
||||||
|
});
|
||||||
|
return { tiles, queue, states };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Let promise chains (fetch, json, chunked write, queue pump) run. */
|
||||||
|
async function settle() {
|
||||||
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer every capture as it arrives, one at a time, asserting the queue never overlaps two. */
|
||||||
|
async function drain(body = 'frame') {
|
||||||
|
let max = 0;
|
||||||
|
for (let guard = 0; guard < 50; guard++) {
|
||||||
|
await settle();
|
||||||
|
max = Math.max(max, inFlight());
|
||||||
|
const open = captures.find((c) => !c.settled);
|
||||||
|
if (!open) break;
|
||||||
|
open.answer(body);
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
captures = [];
|
||||||
|
fetchMock.mockClear();
|
||||||
|
FakeSocket.instances = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const tile of liveTiles.splice(0)) tile.destroy();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initial loads', () => {
|
||||||
|
it('N tiles connecting together fetch one capture at a time', async () => {
|
||||||
|
const { tiles } = makeGrid(['a', 'b', 'c', 'd']);
|
||||||
|
const connecting = tiles.map((t) => t.connect());
|
||||||
|
|
||||||
|
await settle();
|
||||||
|
expect(inFlight()).toBe(1);
|
||||||
|
expect(await drain()).toBe(1);
|
||||||
|
await Promise.all(connecting);
|
||||||
|
expect(captures).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the focused tile first, then reading order', async () => {
|
||||||
|
const { tiles } = makeGrid(['a', 'b', 'c', 'd'], { focused: 'c' });
|
||||||
|
const connecting = tiles.map((t) => t.connect());
|
||||||
|
await drain();
|
||||||
|
await Promise.all(connecting);
|
||||||
|
// `a` was already running when the others arrived; then focus, then order.
|
||||||
|
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'c', 'b', 'd']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports each tile queued, then running, then idle (the quiet loading state)', async () => {
|
||||||
|
const { tiles, states } = makeGrid(['a', 'b']);
|
||||||
|
const connecting = tiles.map((t) => t.connect());
|
||||||
|
await drain();
|
||||||
|
await Promise.all(connecting);
|
||||||
|
expect(states.filter(([id]) => id === 'b').map(([, s]) => s)).toEqual(['queued', 'running', 'idle']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads a bounded window: full=1&tail= for a TUI, tail= for a shell', async () => {
|
||||||
|
const { tiles } = makeGrid(['tui', 'sh'], { modes: { sh: 'shell' } });
|
||||||
|
const connecting = tiles.map((t) => t.connect());
|
||||||
|
await drain();
|
||||||
|
await Promise.all(connecting);
|
||||||
|
expect(captures.map((c) => c.url)).toEqual([
|
||||||
|
`/api/sessions/tui/terminal?full=1&tail=${TAIL}${LINES}`,
|
||||||
|
`/api/sessions/sh/terminal?tail=${TAIL}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps TILE_SCROLLBACK lines and the tile font size', async () => {
|
||||||
|
const { tiles } = makeGrid(['a']);
|
||||||
|
const connecting = tiles[0].connect();
|
||||||
|
await drain();
|
||||||
|
await connecting;
|
||||||
|
expect(tiles[0].terminal?.options.scrollback).toBe(10000);
|
||||||
|
expect(tiles[0].terminal?.options.fontSize).toBe(13);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Connects every tile and opens its socket, with the queue drained. */
|
||||||
|
async function connectAll(tiles: Tile[]) {
|
||||||
|
const connecting = tiles.map((t) => t.connect());
|
||||||
|
await drain('first');
|
||||||
|
await Promise.all(connecting);
|
||||||
|
for (const tile of tiles) tile.ws?.open();
|
||||||
|
captures = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('refreshes', () => {
|
||||||
|
it('N tiles reconnecting together (a deploy restart) refresh one at a time', async () => {
|
||||||
|
const { tiles } = makeGrid(['a', 'b', 'c', 'd', 'e', 'f']);
|
||||||
|
await connectAll(tiles);
|
||||||
|
for (const tile of tiles) tile.ws?.drop(1006);
|
||||||
|
for (const tile of tiles) {
|
||||||
|
tile.reconnectNow();
|
||||||
|
tile.ws?.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
await settle();
|
||||||
|
expect(inFlight()).toBe(1);
|
||||||
|
expect(await drain('after')).toBe(1);
|
||||||
|
expect(captures).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a server {t:"r"} refresh waits its turn behind another tile, keeping its last frame meanwhile', async () => {
|
||||||
|
const { tiles } = makeGrid(['a', 'b']);
|
||||||
|
await connectAll(tiles);
|
||||||
|
const [a, b] = tiles;
|
||||||
|
a.ws?.receive({ t: 'r' });
|
||||||
|
b.ws?.receive({ t: 'r' });
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a']);
|
||||||
|
// b has not been cleared: it shows its last frame until its load runs.
|
||||||
|
expect(b.terminal?.writes).not.toContain('<CLEAR>');
|
||||||
|
|
||||||
|
await drain('fresh');
|
||||||
|
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'b']);
|
||||||
|
expect(b.terminal?.writes.slice(-2)).toEqual(['<CLEAR>', 'fresh']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a history pull jumps ahead of background refreshes', async () => {
|
||||||
|
const { tiles } = makeGrid(['a', 'b', 'sh'], { modes: { sh: 'shell' } });
|
||||||
|
await connectAll(tiles);
|
||||||
|
const [a, b, sh] = tiles;
|
||||||
|
a.ws?.receive({ t: 'r' });
|
||||||
|
b.ws?.receive({ t: 'r' });
|
||||||
|
sh._maybeLoadMoreHistory();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(inFlight()).toBe(1);
|
||||||
|
await drain();
|
||||||
|
expect(captures.map((c) => c.url)).toEqual([
|
||||||
|
`/api/sessions/a/terminal?full=1&tail=${TAIL}${LINES}`,
|
||||||
|
`/api/sessions/sh/terminal?full=1&tail=${TAIL}${LINES}`,
|
||||||
|
`/api/sessions/b/terminal?full=1&tail=${TAIL}${LINES}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a close while the refresh only WAITS writes the marker at once, and once', async () => {
|
||||||
|
const { tiles } = makeGrid(['a', 'b']);
|
||||||
|
await connectAll(tiles);
|
||||||
|
const [a, b] = tiles;
|
||||||
|
a.ws?.receive({ t: 'r' });
|
||||||
|
b.ws?.receive({ t: 'r' });
|
||||||
|
await settle();
|
||||||
|
b.ws?.drop(1006);
|
||||||
|
|
||||||
|
const markers = () => (b.terminal?.writes ?? []).filter((w) => w.includes('[disconnected')).length;
|
||||||
|
expect(markers()).toBe(1);
|
||||||
|
await drain('fresh');
|
||||||
|
// Its turn cleared the screen, so the marker is written again below the replay: one on screen.
|
||||||
|
const writes = b.terminal?.writes ?? [];
|
||||||
|
expect(writes.slice(writes.lastIndexOf('<CLEAR>'))).toEqual([
|
||||||
|
'<CLEAR>',
|
||||||
|
'fresh',
|
||||||
|
expect.stringContaining('[disconnected'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('teardown', () => {
|
||||||
|
it("drops a destroyed tile's queued load: it never fetches", async () => {
|
||||||
|
const { tiles, queue } = makeGrid(['a', 'b', 'c']);
|
||||||
|
const connecting = tiles.map((t) => t.connect());
|
||||||
|
await settle();
|
||||||
|
tiles[1].destroy();
|
||||||
|
await drain();
|
||||||
|
await Promise.all(connecting);
|
||||||
|
|
||||||
|
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'c']);
|
||||||
|
expect(queue.size).toBe(0);
|
||||||
|
expect(tiles[1].ws).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroying the tile whose load is running aborts its fetch and the queue moves on', async () => {
|
||||||
|
const { tiles } = makeGrid(['a', 'b']);
|
||||||
|
const connecting = tiles.map((t) => t.connect());
|
||||||
|
await settle();
|
||||||
|
tiles[0].destroy();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(captures[0].aborted).toBe(true);
|
||||||
|
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'b']);
|
||||||
|
await drain();
|
||||||
|
await Promise.all(connecting);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroying a tile while xterm still parses its replay settles the load, and the queue moves on', async () => {
|
||||||
|
// The replay waits for xterm's write callback (writeChunked), which a
|
||||||
|
// disposed xterm never runs: unsettled, the tile's load would hold the
|
||||||
|
// grid's one queue, and every other tile behind it, forever.
|
||||||
|
const { tiles } = makeGrid(['a', 'b']);
|
||||||
|
const connecting = tiles.map((t) => t.connect());
|
||||||
|
await settle();
|
||||||
|
tiles[0].terminal!.holdParse = true;
|
||||||
|
captures[0].answer('replay of a');
|
||||||
|
await settle();
|
||||||
|
// a's replay is still parsing: b waits its turn.
|
||||||
|
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a']);
|
||||||
|
|
||||||
|
tiles[0].destroy();
|
||||||
|
await settle();
|
||||||
|
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'b']);
|
||||||
|
captures[1].answer('b');
|
||||||
|
await settle();
|
||||||
|
await Promise.all(connecting);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a capture that never answers is cut off by its deadline, and the next tile loads', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { tiles } = makeGrid(['a', 'b']);
|
||||||
|
const connecting = tiles.map((t) => t.connect());
|
||||||
|
await settle();
|
||||||
|
expect(captures).toHaveLength(1);
|
||||||
|
|
||||||
|
// The full-capture budget (CodemanFetchDeadline, 45 s for a TUI).
|
||||||
|
await vi.advanceTimersByTimeAsync(45_000);
|
||||||
|
await settle();
|
||||||
|
expect(captures[0].aborted).toBe(true);
|
||||||
|
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'b']);
|
||||||
|
captures[1].answer('b');
|
||||||
|
await settle();
|
||||||
|
await Promise.all(connecting);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TileLoadQueue on its own', () => {
|
||||||
|
it('never rejects, and moves on when a load throws', async () => {
|
||||||
|
const queue = new TileLoadQueue();
|
||||||
|
const order: string[] = [];
|
||||||
|
const first = queue.schedule({}, 'initial', async () => {
|
||||||
|
order.push('first');
|
||||||
|
throw new Error('boom');
|
||||||
|
});
|
||||||
|
const second = queue.schedule({}, 'initial', async () => {
|
||||||
|
order.push('second');
|
||||||
|
});
|
||||||
|
await expect(first).resolves.toBeUndefined();
|
||||||
|
await expect(second).resolves.toBeUndefined();
|
||||||
|
expect(order).toEqual(['first', 'second']);
|
||||||
|
expect(queue.activeTile).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves, but never runs, a load whose tile was destroyed while it waited', async () => {
|
||||||
|
const queue = new TileLoadQueue();
|
||||||
|
let release: () => void = () => {};
|
||||||
|
const busy = queue.schedule({}, 'initial', () => new Promise<void>((r) => (release = r)));
|
||||||
|
const gone = { _destroyed: false };
|
||||||
|
const run = vi.fn(async () => {});
|
||||||
|
const waiting = queue.schedule(gone, 'refresh', run);
|
||||||
|
gone._destroyed = true;
|
||||||
|
release();
|
||||||
|
await busy;
|
||||||
|
await expect(waiting).resolves.toBeUndefined();
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
expect(queue.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drop() resolves every load still waiting for a tile', async () => {
|
||||||
|
const queue = new TileLoadQueue();
|
||||||
|
let release: () => void = () => {};
|
||||||
|
const busy = queue.schedule({}, 'initial', () => new Promise<void>((r) => (release = r)));
|
||||||
|
const tile = {};
|
||||||
|
const run = vi.fn(async () => {});
|
||||||
|
const waiting = queue.schedule(tile, 'refresh', run);
|
||||||
|
(queue as unknown as { drop(t: object): void }).drop(tile);
|
||||||
|
await expect(waiting).resolves.toBeUndefined();
|
||||||
|
release();
|
||||||
|
await busy;
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The grid's open and close animation (owner request: "when
|
||||||
|
* clicking on the tile button first make this animation nicer").
|
||||||
|
*
|
||||||
|
* - Opening: every tile mounts with `.tile--entering` and its place in reading
|
||||||
|
* order (`--tile-enter-index`, the CSS stagger), cleared by its own
|
||||||
|
* `tile-enter` animationend only. Its terminal stays transparent
|
||||||
|
* (`.tile--revealing`) until the load queue reports its first capture done,
|
||||||
|
* with a backstop timer.
|
||||||
|
* - The animation adds no fit and no PTY resize: a grid opened with motion
|
||||||
|
* fits and connects its tiles exactly as one opened under reduced motion.
|
||||||
|
* - Closing with the Tiles toggle (button, Ctrl+Shift+G; owner answer 4): a
|
||||||
|
* still copy of the tiles (clones, no TerminalTile) dims over the stage and
|
||||||
|
* waits until the single view's selection has settled, at most 700 ms, then
|
||||||
|
* fades and removes itself (animationend, or a fallback timer). Every other
|
||||||
|
* close cuts as before. A reopen drops a copy still showing.
|
||||||
|
* - Re-forming to another count: the old grid's copy fades at once, the new
|
||||||
|
* tiles enter staggered, the tiles that stay do not.
|
||||||
|
* - Reduced motion: no entrance, no copy, no stagger property.
|
||||||
|
* - The CSS: every new keyframe animates opacity and transform only, all of
|
||||||
|
* it stops under prefers-reduced-motion, and a web tab hides the copy.
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
FakeTile,
|
||||||
|
body,
|
||||||
|
bySelector,
|
||||||
|
flushFrames,
|
||||||
|
main,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
section,
|
||||||
|
windowStub,
|
||||||
|
type GridApp,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
|
||||||
|
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
||||||
|
|
||||||
|
const tiles = () => section.children.filter((c) => c.classList.contains('tile'));
|
||||||
|
const ghosts = () => main.children.filter((c) => c.classList.contains('tile-grid-ghosts'));
|
||||||
|
const end = (el: FakeEl, animationName: string, target: FakeEl = el) => el.dispatch('animationend', { target, animationName });
|
||||||
|
function reducedMotion(on: boolean) {
|
||||||
|
windowStub.matchMedia = (q: string) => ({ matches: on && q.includes('reduce'), addEventListener: vi.fn() });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
const btn = new FakeEl();
|
||||||
|
btn.className = 'btn-icon-header btn-tile-grid';
|
||||||
|
bySelector.set('.btn-tile-grid', btn);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function gridApp(ids = IDS): GridApp {
|
||||||
|
const app = makeGridApp(ids);
|
||||||
|
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('opening', () => {
|
||||||
|
it('each tile enters, staggered in reading order', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS.slice(0, 4), { focusedId: 's-c' });
|
||||||
|
expect(tiles().map((t) => t.classList.contains('tile--entering'))).toEqual([true, true, true, true]);
|
||||||
|
expect(tiles().map((t) => t.style['--tile-enter-index'])).toEqual(['0', '1', '2', '3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tile stops entering on its own tile-enter animationend, not on a child or another animation', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(['s-a', 's-b']);
|
||||||
|
const el = tiles()[0];
|
||||||
|
end(el, 'tile-needs-pulse');
|
||||||
|
end(el, 'tile-enter', new FakeEl());
|
||||||
|
expect(el.classList.contains('tile--entering')).toBe(true);
|
||||||
|
end(el, 'tile-enter');
|
||||||
|
expect(el.classList.contains('tile--entering')).toBe(false);
|
||||||
|
expect(tiles()[1].classList.contains('tile--entering')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a terminal stays transparent until the load queue reports its first capture done', () => {
|
||||||
|
// A queue that hands the grid's own state callback to the test.
|
||||||
|
const Queue = windowStub.TileLoadQueue;
|
||||||
|
let report: (tile: unknown, state: string) => void = () => {};
|
||||||
|
windowStub.TileLoadQueue = class {
|
||||||
|
constructor(opts: { onChange: (tile: unknown, state: string) => void }) {
|
||||||
|
report = opts.onChange;
|
||||||
|
}
|
||||||
|
schedule(_t: unknown, _k: string, run: () => Promise<void>) {
|
||||||
|
return run();
|
||||||
|
}
|
||||||
|
drop() {}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(['s-a', 's-b']);
|
||||||
|
app._tileLoadQueue();
|
||||||
|
expect(tiles().every((t) => t.classList.contains('tile--revealing'))).toBe(true);
|
||||||
|
const tileA = app._tileFor('s-a');
|
||||||
|
report(tileA, 'queued');
|
||||||
|
report(tileA, 'running');
|
||||||
|
expect(tiles()[0].classList.contains('tile--revealing')).toBe(true);
|
||||||
|
report(tileA, 'idle');
|
||||||
|
expect(tiles()[0].classList.contains('tile--revealing')).toBe(false);
|
||||||
|
expect(tiles()[1].classList.contains('tile--revealing')).toBe(true);
|
||||||
|
// A later load (a reconnect refresh) never hides it again.
|
||||||
|
report(tileA, 'running');
|
||||||
|
expect(tiles()[0].classList.contains('tile--revealing')).toBe(false);
|
||||||
|
} finally {
|
||||||
|
windowStub.TileLoadQueue = Queue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a backstop timer shows a terminal whose load never reports back', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(['s-a']);
|
||||||
|
vi.advanceTimersByTime(14_999);
|
||||||
|
expect(tiles()[0].classList.contains('tile--revealing')).toBe(true);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(tiles()[0].classList.contains('tile--revealing')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds no fit and no PTY resize: tiles fit and connect exactly as under reduced motion', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const counts = (motion: boolean) => {
|
||||||
|
resetGridHarness();
|
||||||
|
reducedMotion(!motion);
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
flushFrames();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
return FakeTile.all.map((t) => [t.connect.mock.calls.length, t.fit.mock.calls.length, t.localFit.mock.calls.length]);
|
||||||
|
};
|
||||||
|
const withMotion = counts(true);
|
||||||
|
const without = counts(false);
|
||||||
|
expect(withMotion).toEqual(without);
|
||||||
|
// One connect per tile (its one resize goes out when its socket opens).
|
||||||
|
expect(withMotion.every(([connects]) => connects === 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tile added later enters too; one dropped on a tile enters in its place', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(['s-a', 's-b']);
|
||||||
|
for (const t of tiles()) end(t, 'tile-enter');
|
||||||
|
app.addTile('s-c');
|
||||||
|
const added = tiles().find((t) => t.dataset.sessionId === 's-c')!;
|
||||||
|
expect(added.classList.contains('tile--entering')).toBe(true);
|
||||||
|
expect(tiles().filter((t) => t.classList.contains('tile--entering'))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('closing with the Tiles toggle', () => {
|
||||||
|
it('leaves a still copy of the tiles over the stage: clones, inert, no terminals', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS.slice(0, 3));
|
||||||
|
const before = FakeTile.all.length;
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(ghosts()).toHaveLength(1);
|
||||||
|
const layer = ghosts()[0];
|
||||||
|
expect(layer.inert).toBe(true);
|
||||||
|
expect(layer.attrs['aria-hidden']).toBe('true');
|
||||||
|
expect(layer.children.map((g) => g.dataset.sessionId)).toEqual(['s-a', 's-b', 's-c']);
|
||||||
|
expect(layer.children.every((g) => g.classList.contains('tile--leaving'))).toBe(true);
|
||||||
|
expect(layer.children.some((g) => g.classList.contains('tile--entering'))).toBe(false);
|
||||||
|
// Copies only: no TerminalTile was made for them, the real ones are gone.
|
||||||
|
expect(FakeTile.all).toHaveLength(before);
|
||||||
|
expect(FakeTile.all.every((t) => t._destroyed)).toBe(true);
|
||||||
|
expect(tiles()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds until the single view has its content, then fades and goes', async () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS.slice(0, 2));
|
||||||
|
let settle: () => void = () => {};
|
||||||
|
app.selectSession = vi.fn(() => new Promise<void>((r) => (settle = r)));
|
||||||
|
app.toggleTileGrid();
|
||||||
|
const layer = ghosts()[0];
|
||||||
|
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(false);
|
||||||
|
settle();
|
||||||
|
// The selection's promise comes from another realm (the test's), so its
|
||||||
|
// settling reaches the grid after a few microtasks: a macrotask covers them.
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true);
|
||||||
|
end(layer, 'tile-leave', layer.children[0]);
|
||||||
|
expect(ghosts()).toHaveLength(1);
|
||||||
|
end(layer, 'tile-leave', layer.lastElementChild!);
|
||||||
|
expect(ghosts()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a single view that never settles still gets it gone: 700 ms, then the fade fallback', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS.slice(0, 2));
|
||||||
|
app.selectSession = vi.fn(() => new Promise<void>(() => {}));
|
||||||
|
app.toggleTileGrid();
|
||||||
|
const layer = ghosts()[0];
|
||||||
|
vi.advanceTimersByTime(699);
|
||||||
|
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(false);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true);
|
||||||
|
vi.advanceTimersByTime(450);
|
||||||
|
expect(ghosts()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a zoomed grid leaves a copy of the zoomed tile only', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS.slice(0, 3));
|
||||||
|
app.zoomTile('s-b');
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(ghosts()[0].children.map((g) => g.dataset.sessionId)).toEqual(['s-b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every other close cuts as before: no copy', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS.slice(0, 2));
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
expect(ghosts()).toHaveLength(0);
|
||||||
|
app.openTileGrid(IDS.slice(0, 2));
|
||||||
|
// A user pick of a tab that is not tiled leaves the grid (decision 1).
|
||||||
|
app.closeTileGrid({ keepStored: true });
|
||||||
|
expect(ghosts()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopening drops a copy still showing', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS.slice(0, 2));
|
||||||
|
app.selectSession = vi.fn(() => new Promise<void>(() => {}));
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(ghosts()).toHaveLength(1);
|
||||||
|
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(ghosts()).toHaveLength(0);
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('re-forming to another count', () => {
|
||||||
|
it('the old grid fades at once, the new tiles enter staggered, the ones that stay do not', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(['s-a', 's-b']);
|
||||||
|
for (const t of tiles()) end(t, 'tile-enter');
|
||||||
|
app.openTileCountMenu({ preventDefault: vi.fn() });
|
||||||
|
const menu = body.children.find((c) => c.id === 'tileCountMenu')!;
|
||||||
|
menu.children.find((c) => c.dataset.count === '6')!.dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
const layer = ghosts()[0];
|
||||||
|
expect(layer.classList.contains('tile-grid-ghosts--now')).toBe(true);
|
||||||
|
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true);
|
||||||
|
const entering = tiles().filter((t) => t.classList.contains('tile--entering'));
|
||||||
|
expect(entering.map((t) => t.style['--tile-enter-index'])).toEqual(['0', '1', '2', '3']);
|
||||||
|
expect(entering.map((t) => t.dataset.sessionId)).not.toContain('s-a');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reduced motion', () => {
|
||||||
|
it('no entrance, no stagger property, no copy on close', () => {
|
||||||
|
reducedMotion(true);
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS.slice(0, 3));
|
||||||
|
expect(tiles().some((t) => t.classList.contains('tile--entering'))).toBe(false);
|
||||||
|
expect(tiles().some((t) => '--tile-enter-index' in t.style)).toBe(false);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(ghosts()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the CSS', () => {
|
||||||
|
const keyframes = (name: string) => {
|
||||||
|
const m = css.match(new RegExp(`@keyframes ${name} \\{([\\s\\S]*?)\\n\\}`));
|
||||||
|
return m ? m[1] : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
it('every new keyframe animates opacity and transform only', () => {
|
||||||
|
for (const name of ['tile-enter', 'tile-ghost-dim', 'tile-leave', 'tile-leave-now', 'tile-loading-breathe', 'tile-count-menu-in']) {
|
||||||
|
const body = keyframes(name);
|
||||||
|
expect(body, name).not.toBe('');
|
||||||
|
const props = [...body.matchAll(/^\s*([a-z-]+):/gm)].map((m) => m[1]);
|
||||||
|
expect(props.length, name).toBeGreaterThan(0);
|
||||||
|
expect(props.filter((p) => p !== 'opacity' && p !== 'transform'), name).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the terminal reveal is an opacity transition only', () => {
|
||||||
|
expect(css).toMatch(/\.tile-body \.xterm \{\s*transition: opacity 160ms ease-out;\s*\}/);
|
||||||
|
expect(css).toMatch(/\.tile\.tile--revealing \.tile-body \.xterm \{\s*opacity: 0;\s*\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays within about 150 to 300 ms: 180 ms per tile, 24 ms apart, the last of six done at 300', () => {
|
||||||
|
expect(css).toMatch(/\.tile\.tile--entering \{\s*animation: tile-enter 180ms [^;]+;\s*animation-delay: calc\(var\(--tile-enter-index, 0\) \* 24ms\);/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nothing moves under prefers-reduced-motion, and a web tab hides the copy', () => {
|
||||||
|
const block = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce) {\n .tile-count-menu,'));
|
||||||
|
const reduced = block.slice(0, block.indexOf('\n}\n'));
|
||||||
|
for (const sel of ['.tile-count-menu', '.tile.tile--entering', '.tile.tile--loading .tile-body::after', '.tile-grid-ghosts .tile.tile--leaving']) {
|
||||||
|
expect(reduced).toContain(sel);
|
||||||
|
}
|
||||||
|
expect(reduced).toMatch(/animation: none;/);
|
||||||
|
expect(reduced).toMatch(/\.tile-body \.xterm \{\s*transition: none;/);
|
||||||
|
expect(css).toMatch(/\.main\.webview-active \.tile-grid-ghosts \{\s*display: none;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,790 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Moving tiles (owner request): a tile dragged by its header
|
||||||
|
* onto another tile trades places with it.
|
||||||
|
*
|
||||||
|
* - One path for every move (`_reorderTiles`): the header drag, a tab dragged
|
||||||
|
* onto a tile or a slot, the Move Tile chords. Nothing is remounted,
|
||||||
|
* reconnected or reloaded (no new TerminalTile, no `connect()`, no fetch),
|
||||||
|
* no session joins or leaves, the grid stays open, the order is persisted.
|
||||||
|
* - Divider sizes belong to the cells: a tile whose cell size changed fits
|
||||||
|
* once (one PTY resize, #464), every other tile is left alone.
|
||||||
|
* - An empty slot refuses a tile (its header drag or its tab): owner, "dont
|
||||||
|
* move the tile" (a slot is always the last cell, so a move there shifted
|
||||||
|
* every tile after it). A session not tiled yet still joins there.
|
||||||
|
* - The header focuses its tile on click, never on a press (the body keeps
|
||||||
|
* press-to-focus), so a drag that is cancelled changes nothing, focus and
|
||||||
|
* idle alert included (owner: best practice).
|
||||||
|
* - The header drag is native: Escape or a drop anywhere else ends in a
|
||||||
|
* `dragend` with no drop, which moves nothing and clears what the drag
|
||||||
|
* painted. It carries a type of its own (never text) and is not
|
||||||
|
* `draggedTabId`, so neither a text field nor the tab strip takes it. The
|
||||||
|
* drop targets hold it in the capture phase, before xterm.
|
||||||
|
* - The handle is the header's free area: a press on a button or the rename
|
||||||
|
* input starts no drag, and a double-click on the name still renames.
|
||||||
|
* - Moving is off while a tile is zoomed (the header drag, a tab drag of a
|
||||||
|
* tiled session, the chords) and with a single tile.
|
||||||
|
* - Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows): the focused tile trades
|
||||||
|
* places with the neighbour the Alt+Shift+Arrow focus chords pick, checked
|
||||||
|
* against hand-written tables for every cell and direction of 2x1, 2x2, 3x2
|
||||||
|
* and the partial 2x2 and 3x2; focus stays on the moved tile. The chords
|
||||||
|
* apply (and are swallowed) only while the grid is open, zoomed included as a
|
||||||
|
* no-op, and never in a text field, where Ctrl+Shift+Arrows select by word
|
||||||
|
* (the focus chords skip text fields too: tile-grid-shortcuts.test.ts).
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
FakeTile,
|
||||||
|
documentAddEventListener,
|
||||||
|
fetchSpy,
|
||||||
|
localStore,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
section,
|
||||||
|
type GridApp,
|
||||||
|
tileEl,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const FOUR = ['s-a', 's-b', 's-c', 's-d'];
|
||||||
|
const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
|
||||||
|
const TILE_TYPE = 'application/x-codeman-tile';
|
||||||
|
|
||||||
|
const headerOf = (id: string) => tileEl(id).children[0];
|
||||||
|
const actionsOf = (id: string) => headerOf(id).children[3];
|
||||||
|
const nameOf = (id: string) => headerOf(id).children[2].children[0];
|
||||||
|
const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed) as FakeTile;
|
||||||
|
const slots = () => section.children.filter((el) => el.className.split(' ').includes('tile-slot'));
|
||||||
|
const stored = () => JSON.parse(localStore.get('codeman:tile-grid') ?? 'null');
|
||||||
|
|
||||||
|
const dragEvent = () => ({
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
stopPropagation: vi.fn(),
|
||||||
|
dataTransfer: { effectAllowed: '', dropEffect: '', setData: vi.fn() },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A press on `target` inside the header (the capture-phase listener notes where), then the native dragstart. */
|
||||||
|
function startDrag(id: string, target: FakeEl = headerOf(id)) {
|
||||||
|
headerOf(id).dispatch('pointerdown', { target, button: 0 });
|
||||||
|
const start = dragEvent();
|
||||||
|
headerOf(id).dispatch('dragstart', start);
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
function over(el: FakeEl) {
|
||||||
|
const e = dragEvent();
|
||||||
|
el.dispatch('dragover', e);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
function drop(el: FakeEl) {
|
||||||
|
const e = dragEvent();
|
||||||
|
el.dispatch('drop', e);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
const end = (id: string) => headerOf(id).dispatch('dragend', dragEvent());
|
||||||
|
|
||||||
|
/** A full header drag: press, start, over and drop on `target`, end. */
|
||||||
|
function dragTileOnto(id: string, target: FakeEl) {
|
||||||
|
const start = startDrag(id);
|
||||||
|
const o = over(target);
|
||||||
|
const d = drop(target);
|
||||||
|
end(id);
|
||||||
|
return { start, over: o, drop: d };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGrid(ids: string[], focusedId = ids[0]): GridApp {
|
||||||
|
const app = makeGridApp(ids);
|
||||||
|
app.openTileGrid(ids, { focusedId });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a move must never cost: a new tile, a connect, a destroy, a fetch. */
|
||||||
|
function snapshotCost() {
|
||||||
|
const tiles = FakeTile.all.length;
|
||||||
|
const connects = FakeTile.all.map((t) => t.connect.mock.calls.length);
|
||||||
|
fetchSpy.mockClear();
|
||||||
|
return () => {
|
||||||
|
expect(FakeTile.all.length).toBe(tiles);
|
||||||
|
expect(FakeTile.all.map((t) => t.connect.mock.calls.length)).toEqual(connects);
|
||||||
|
expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 0)).toBe(true);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const clearFits = () => {
|
||||||
|
for (const t of FakeTile.all) t.fit.mockClear();
|
||||||
|
};
|
||||||
|
const fitCounts = (ids: string[]) => Object.fromEntries(ids.map((id) => [id, tile(id).fit.mock.calls.length]));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dragging a tile by its header', () => {
|
||||||
|
it('onto another tile: the two trade places, the dragged one takes focus, nothing remounts or reloads', () => {
|
||||||
|
const app = openGrid(FOUR, 's-b');
|
||||||
|
app.markIdleAlertSeen.mockClear();
|
||||||
|
const costsNothing = snapshotCost();
|
||||||
|
|
||||||
|
const start = startDrag('s-a');
|
||||||
|
expect(start.preventDefault).not.toHaveBeenCalled();
|
||||||
|
expect(tileEl('s-a').classList.contains('tile--dragging')).toBe(true);
|
||||||
|
const o = over(tileEl('s-d'));
|
||||||
|
expect(o.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(o.dataTransfer.dropEffect).toBe('move');
|
||||||
|
expect(tileEl('s-d').classList.contains('tile--drop-target')).toBe(true);
|
||||||
|
drop(tileEl('s-d'));
|
||||||
|
end('s-a');
|
||||||
|
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-d', 's-b', 's-c', 's-a']);
|
||||||
|
expect([tileEl('s-a').style.gridColumn, tileEl('s-a').style.gridRow]).toEqual(['3', '3']);
|
||||||
|
expect([tileEl('s-d').style.gridColumn, tileEl('s-d').style.gridRow]).toEqual(['1', '1']);
|
||||||
|
costsNothing();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
expect([...app._tileGrid.tiles.keys()].sort()).toEqual(FOUR);
|
||||||
|
// A drag is a human action: the dropped tile takes focus, as a tab drop does.
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-a');
|
||||||
|
// What the drag painted is gone.
|
||||||
|
for (const id of FOUR) {
|
||||||
|
expect(tileEl(id).classList.contains('tile--dragging')).toBe(false);
|
||||||
|
expect(tileEl(id).classList.contains('tile--drop-target')).toBe(false);
|
||||||
|
}
|
||||||
|
expect(app._draggedTileId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the new order is stored (ids only) and comes back on a reload', () => {
|
||||||
|
const app = openGrid(FOUR);
|
||||||
|
dragTileOnto('s-a', tileEl('s-c'));
|
||||||
|
expect(stored().ids).toEqual(['s-c', 's-b', 's-a', 's-d']);
|
||||||
|
expect(stored().focused).toBe('s-a');
|
||||||
|
expect(stored().open).toBe(true);
|
||||||
|
|
||||||
|
// A reload: a fresh page on the same device restores the stored grid.
|
||||||
|
app._tileGrid.open = false;
|
||||||
|
section.children = [];
|
||||||
|
const reloaded = makeGridApp(FOUR);
|
||||||
|
expect(reloaded._restoreTileGrid()).toBe(true);
|
||||||
|
expect(reloaded._tileGrid.ids).toEqual(['s-c', 's-b', 's-a', 's-d']);
|
||||||
|
expect(reloaded.activeSessionId).toBe('s-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onto an empty slot', () => {
|
||||||
|
// Under 1800px wide three tiles take a 2x2 with one empty slot, five a 3x2.
|
||||||
|
beforeEach(() => {
|
||||||
|
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Owner: an empty cell can be any cell, and a tile dragged onto one moves
|
||||||
|
// THERE, leaving its own cell empty; nothing else moves.
|
||||||
|
const LAYOUTS = [
|
||||||
|
{ name: '2x2 with 3 tiles', ids: ['s-a', 's-b', 's-c'], cells: 4 },
|
||||||
|
{ name: '3x2 with 5 tiles', ids: SIX.slice(0, 5), cells: 6 },
|
||||||
|
];
|
||||||
|
for (const layout of LAYOUTS) {
|
||||||
|
it(`${layout.name}: from every cell into the hole wherever it is, by header drag`, () => {
|
||||||
|
const bad: string[] = [];
|
||||||
|
for (let hole = 0; hole < layout.cells; hole++) {
|
||||||
|
for (let from = 0; from < layout.cells; from++) {
|
||||||
|
if (from === hole) continue;
|
||||||
|
resetGridHarness();
|
||||||
|
section.getBoundingClientRect = () => ({
|
||||||
|
width: 1700,
|
||||||
|
height: 1000,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 1700,
|
||||||
|
bottom: 1000,
|
||||||
|
});
|
||||||
|
const app = openGrid(layout.ids, layout.ids[0]);
|
||||||
|
const cells: Array<string | null> = layout.ids.slice();
|
||||||
|
cells.splice(hole, 0, null);
|
||||||
|
app._tileGrid.cells = cells.slice(0, layout.cells);
|
||||||
|
app._applyTileLayout();
|
||||||
|
const moved = app._tileGrid.cells[from];
|
||||||
|
const costsNothing = snapshotCost();
|
||||||
|
expect(slots()).toHaveLength(1);
|
||||||
|
expect(slots()[0].dataset.cell).toBe(String(hole));
|
||||||
|
const { over: o } = dragTileOnto(moved, slots()[0]);
|
||||||
|
const expected = app._tileGrid.cells.slice();
|
||||||
|
const want = cells.slice(0, layout.cells);
|
||||||
|
want[hole] = moved;
|
||||||
|
want[from] = null;
|
||||||
|
const ok =
|
||||||
|
JSON.stringify(expected) === JSON.stringify(want) &&
|
||||||
|
o.dataTransfer.dropEffect === 'move' &&
|
||||||
|
app.activeSessionId === moved &&
|
||||||
|
JSON.stringify(stored().ids) === JSON.stringify(want) &&
|
||||||
|
slots().length === 1 &&
|
||||||
|
slots()[0].dataset.cell === String(from);
|
||||||
|
if (!ok) bad.push(`hole ${hole} from ${from}: got ${JSON.stringify(expected)}`);
|
||||||
|
costsNothing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(bad).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('the moved tile sits in the hole (its grid place), and the slot takes its old place', () => {
|
||||||
|
const app = openGrid(SIX.slice(0, 5));
|
||||||
|
// [a b c / d e _]: c into the hole below it.
|
||||||
|
dragTileOnto('s-c', slots()[0]);
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-d', 's-e', 's-c']);
|
||||||
|
expect([tileEl('s-c').style.gridColumn, tileEl('s-c').style.gridRow]).toEqual(['5', '3']);
|
||||||
|
expect([slots()[0].style.gridColumn, slots()[0].style.gridRow]).toEqual(['5', '1']);
|
||||||
|
expect(slots()[0].classList.contains('tile--drop-target')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tab of a tiled session moves the same way; a session not tiled yet joins in THAT cell', () => {
|
||||||
|
const app = openGrid(SIX.slice(0, 5));
|
||||||
|
app._tileGrid.cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e'];
|
||||||
|
app._applyTileLayout();
|
||||||
|
app.draggedTabId = 's-e';
|
||||||
|
drop(slots()[0]);
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-e', 's-b', 's-c', 's-d', null]);
|
||||||
|
app.draggedTabId = 's-other';
|
||||||
|
expect(over(slots()[0]).dataTransfer.dropEffect).toBe('move');
|
||||||
|
drop(slots()[0]);
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-e', 's-b', 's-c', 's-d', 's-other']);
|
||||||
|
expect(app.activeSessionId).toBe('s-other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('while a tile is zoomed there is no slot, and a move into a hole is refused', () => {
|
||||||
|
const app = openGrid(SIX.slice(0, 5));
|
||||||
|
app.zoomTile('s-a');
|
||||||
|
expect(slots()).toHaveLength(0);
|
||||||
|
expect(app._moveTileToCell('s-b', 5)).toBe(false);
|
||||||
|
expect(app._tileGrid.cells).toEqual([...SIX.slice(0, 5), null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape, or a drop anywhere else, cancels: dragend with no drop moves nothing and clears what was painted', () => {
|
||||||
|
const app = openGrid(FOUR);
|
||||||
|
localStore.delete('codeman:tile-grid');
|
||||||
|
const costsNothing = snapshotCost();
|
||||||
|
clearFits();
|
||||||
|
startDrag('s-a');
|
||||||
|
over(tileEl('s-c'));
|
||||||
|
// The browser cancels (Escape) or the pointer is released outside every
|
||||||
|
// target: no drop, and not always a dragleave either.
|
||||||
|
end('s-a');
|
||||||
|
expect(app._tileGrid.ids).toEqual(FOUR);
|
||||||
|
expect(tileEl('s-c').classList.contains('tile--drop-target')).toBe(false);
|
||||||
|
expect(tileEl('s-a').classList.contains('tile--dragging')).toBe(false);
|
||||||
|
expect(app._draggedTileId).toBeNull();
|
||||||
|
expect(localStore.has('codeman:tile-grid')).toBe(false);
|
||||||
|
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
|
||||||
|
costsNothing();
|
||||||
|
// The drag is over: a later drag that is neither a tab nor a tile (a file) is left alone.
|
||||||
|
expect(over(tileEl('s-c')).preventDefault).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries a type of its own, never text, and is not a tab drag (the strip ignores it)', () => {
|
||||||
|
const app = openGrid(FOUR);
|
||||||
|
const start = startDrag('s-b');
|
||||||
|
expect(start.dataTransfer.setData).toHaveBeenCalledWith(TILE_TYPE, 's-b');
|
||||||
|
expect(start.dataTransfer.setData.mock.calls.every(([type]) => type === TILE_TYPE)).toBe(true);
|
||||||
|
expect(start.dataTransfer.effectAllowed).toBe('move');
|
||||||
|
expect(app.draggedTabId).toBeFalsy();
|
||||||
|
end('s-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never reaches xterm: tiles and slots hold the drag in the capture phase and stop it', () => {
|
||||||
|
openGrid(FOUR);
|
||||||
|
for (const id of FOUR) {
|
||||||
|
expect(tileEl(id).captureFlags.dragover).toEqual([true]);
|
||||||
|
expect(tileEl(id).captureFlags.drop).toEqual([true]);
|
||||||
|
}
|
||||||
|
startDrag('s-a');
|
||||||
|
const o = over(tileEl('s-b'));
|
||||||
|
const d = drop(tileEl('s-b'));
|
||||||
|
for (const e of [o, d]) {
|
||||||
|
expect(e.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(e.stopPropagation).toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
end('s-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('over its own tile the drag is held there too (never reaching its xterm) but refused', () => {
|
||||||
|
const app = openGrid(FOUR);
|
||||||
|
startDrag('s-a');
|
||||||
|
const o = over(tileEl('s-a'));
|
||||||
|
expect(o.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(o.stopPropagation).toHaveBeenCalled();
|
||||||
|
expect(o.dataTransfer.dropEffect).toBe('none');
|
||||||
|
expect(tileEl('s-a').classList.contains('tile--drop-target')).toBe(false);
|
||||||
|
drop(tileEl('s-a'));
|
||||||
|
end('s-a');
|
||||||
|
expect(app._tileGrid.ids).toEqual(FOUR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tile removed mid-drag, or the grid closed, ends the drag', () => {
|
||||||
|
const app = openGrid(FOUR);
|
||||||
|
startDrag('s-a');
|
||||||
|
app.removeTile('s-a');
|
||||||
|
expect(app._draggedTileId).toBeNull();
|
||||||
|
expect(over(tileEl('s-b')).preventDefault).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
startDrag('s-b');
|
||||||
|
over(tileEl('s-c'));
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
app.closeTileGrid();
|
||||||
|
expect(app._draggedTileId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('focus: the header focuses its tile on click, so a cancelled drag changes nothing', () => {
|
||||||
|
it('a press on the header does not focus the tile; a click does (a human selection)', () => {
|
||||||
|
const app = openGrid(FOUR, 's-a');
|
||||||
|
app.markIdleAlertSeen.mockClear();
|
||||||
|
tileEl('s-c').dispatch('pointerdown', { target: nameOf('s-c'), button: 0 });
|
||||||
|
tileEl('s-c').dispatch('pointerdown', { target: headerOf('s-c'), button: 0 });
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
|
headerOf('s-c').dispatch('click', {});
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a press in the body still focuses at once (before the press reaches xterm)', () => {
|
||||||
|
const app = openGrid(FOUR, 's-a');
|
||||||
|
tileEl('s-c').dispatch('pointerdown', { target: tileEl('s-c').children[1], button: 0 });
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a drag of an unfocused tile that is cancelled leaves focus and its alert alone', () => {
|
||||||
|
const app = openGrid(FOUR, 's-a');
|
||||||
|
app.markIdleAlertSeen.mockClear();
|
||||||
|
tileEl('s-c').dispatch('pointerdown', { target: headerOf('s-c'), button: 0 });
|
||||||
|
startDrag('s-c');
|
||||||
|
over(tileEl('s-b'));
|
||||||
|
end('s-c');
|
||||||
|
expect(app._tileGrid.ids).toEqual(FOUR);
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
|
expect(app._tileGrid.focusedId).toBe('s-a');
|
||||||
|
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a double-click on the name leaves the keyboard in the rename input, and its clicks stay in it', () => {
|
||||||
|
const app = openGrid(FOUR, 's-a');
|
||||||
|
app._queueInlineSessionName = vi.fn();
|
||||||
|
// The browser's sequence: click, click, dblclick.
|
||||||
|
headerOf('s-c').dispatch('click', {});
|
||||||
|
headerOf('s-c').dispatch('click', {});
|
||||||
|
nameOf('s-c').dispatch('dblclick', { stopPropagation: vi.fn() });
|
||||||
|
const input = headerOf('s-c').children[2].children[0];
|
||||||
|
expect(input.className).toBe('tile-rename-input');
|
||||||
|
const terminalFocus = tile('s-c').terminal.focus.mock.invocationCallOrder;
|
||||||
|
expect(input.focus).toHaveBeenCalled();
|
||||||
|
expect(input.focus.mock.invocationCallOrder.at(-1)).toBeGreaterThan(terminalFocus.at(-1) ?? 0);
|
||||||
|
const click = { stopPropagation: vi.fn() };
|
||||||
|
input.dispatch('click', click);
|
||||||
|
expect(click.stopPropagation).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the handle', () => {
|
||||||
|
it('is the header: draggable, with a tooltip that says so under the state', () => {
|
||||||
|
const app = openGrid(FOUR);
|
||||||
|
app._sidebarRichRow = () => ({ state: 'idle', pill: 'idle', since: { at: 1 } });
|
||||||
|
app._mobileOverviewStampText = () => '3m';
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(headerOf('s-a').getAttribute('draggable')).toBe('true');
|
||||||
|
expect(headerOf('s-a').title).toBe('idle 3m\nDrag to move the tile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a press on a header button starts no drag (and a press on the free area after it does)', () => {
|
||||||
|
const app = openGrid(FOUR);
|
||||||
|
for (const button of actionsOf('s-a').children) {
|
||||||
|
const start = startDrag('s-a', button);
|
||||||
|
expect(start.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(app._draggedTileId).toBeFalsy();
|
||||||
|
expect(tileEl('s-a').classList.contains('tile--dragging')).toBe(false);
|
||||||
|
}
|
||||||
|
const start = startDrag('s-a', nameOf('s-a'));
|
||||||
|
expect(start.preventDefault).not.toHaveBeenCalled();
|
||||||
|
expect(app._draggedTileId).toBe('s-a');
|
||||||
|
end('s-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a double-click on the name still renames, and no drag starts while the input is there', () => {
|
||||||
|
const app = openGrid(FOUR);
|
||||||
|
app._queueInlineSessionName = vi.fn();
|
||||||
|
nameOf('s-a').dispatch('dblclick', { stopPropagation: vi.fn() });
|
||||||
|
const entry = app._tileGrid.tiles.get('s-a');
|
||||||
|
expect(entry.renaming).toBe(true);
|
||||||
|
expect(headerOf('s-a').getAttribute('draggable')).toBe('false');
|
||||||
|
const input = headerOf('s-a').children[2].children[0];
|
||||||
|
expect(input.className).toBe('tile-rename-input');
|
||||||
|
// A press in the input, then a would-be drag.
|
||||||
|
expect(startDrag('s-a', input).preventDefault).toHaveBeenCalled();
|
||||||
|
expect(app._draggedTileId).toBeFalsy();
|
||||||
|
input.dispatch('keydown', { key: 'Escape', preventDefault: vi.fn() });
|
||||||
|
expect(entry.renaming).toBe(false);
|
||||||
|
expect(headerOf('s-a').getAttribute('draggable')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a single tile has nowhere to go: not draggable, no drag hint', () => {
|
||||||
|
const app = openGrid(['s-a']);
|
||||||
|
app._sidebarRichRow = () => ({ state: 'idle', pill: 'idle', since: { at: 1 } });
|
||||||
|
app._mobileOverviewStampText = () => '3m';
|
||||||
|
app._renderTileChrome();
|
||||||
|
expect(headerOf('s-a').getAttribute('draggable')).toBe('false');
|
||||||
|
expect(headerOf('s-a').title).toBe('idle 3m');
|
||||||
|
expect(startDrag('s-a').preventDefault).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('while a tile is zoomed, moving is off', () => {
|
||||||
|
it('no header drags (draggable off, no hint), and a drag that starts anyway is refused', () => {
|
||||||
|
const app = openGrid(FOUR);
|
||||||
|
app._sidebarRichRow = () => ({ state: 'idle', pill: 'idle', since: { at: 1 } });
|
||||||
|
app._mobileOverviewStampText = () => '3m';
|
||||||
|
app._renderTileChrome();
|
||||||
|
app.zoomTile('s-a');
|
||||||
|
for (const id of FOUR) {
|
||||||
|
expect(headerOf(id).getAttribute('draggable')).toBe('false');
|
||||||
|
expect(headerOf(id).title).toBe('idle 3m');
|
||||||
|
}
|
||||||
|
expect(startDrag('s-a').preventDefault).toHaveBeenCalled();
|
||||||
|
expect(app._draggedTileId).toBeFalsy();
|
||||||
|
|
||||||
|
app.zoomTile('s-a');
|
||||||
|
expect(headerOf('s-a').getAttribute('draggable')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tab of a tiled session dropped on the zoomed tile moves nothing either', () => {
|
||||||
|
const app = openGrid(FOUR);
|
||||||
|
app.zoomTile('s-a');
|
||||||
|
app.draggedTabId = 's-c';
|
||||||
|
over(tileEl('s-a'));
|
||||||
|
drop(tileEl('s-a'));
|
||||||
|
expect(app._tileGrid.ids).toEqual(FOUR);
|
||||||
|
expect(app._tileGrid.zoomedId).toBe('s-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an automatic zoom (the window too small for the tiles) counts too', () => {
|
||||||
|
const app = openGrid(FOUR);
|
||||||
|
section.getBoundingClientRect = () => ({ width: 700, height: 400, top: 0, left: 0, right: 700, bottom: 400 });
|
||||||
|
app._applyTileLayout();
|
||||||
|
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||||
|
expect(app._tileGrid.autoZoom).toBe(true);
|
||||||
|
expect(headerOf('s-b').getAttribute('draggable')).toBe('false');
|
||||||
|
expect(app._swapTiles('s-a', 's-b')).toBe(false);
|
||||||
|
expect(app._tileGrid.ids).toEqual(FOUR);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sizes belong to the cells: only a tile whose size changed fits, once', () => {
|
||||||
|
it('equal cells: a swap fits nothing (no PTY resize anywhere)', () => {
|
||||||
|
const app = openGrid(SIX);
|
||||||
|
clearFits();
|
||||||
|
dragTileOnto('s-a', tileEl('s-f'));
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-f', 's-b', 's-c', 's-d', 's-e', 's-a']);
|
||||||
|
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a wider first column: the two swapped tiles fit once each, the other four not at all', () => {
|
||||||
|
const app = openGrid(SIX);
|
||||||
|
app._tileGrid.colFr = [2, 1, 1];
|
||||||
|
app._applyTileLayout();
|
||||||
|
clearFits();
|
||||||
|
dragTileOnto('s-a', tileEl('s-b'));
|
||||||
|
expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 });
|
||||||
|
// The fractions stay with the columns.
|
||||||
|
expect(app._tileGrid.colFr).toEqual([2, 1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a swap between two cells of the same size fits nothing, whatever the other sizes', () => {
|
||||||
|
const app = openGrid(SIX);
|
||||||
|
app._tileGrid.colFr = [2, 1, 1];
|
||||||
|
app._tileGrid.rowFr = [1, 3];
|
||||||
|
app._applyTileLayout();
|
||||||
|
clearFits();
|
||||||
|
// s-c (column 2, row 0) and s-b (column 1, row 0): both 1 x 1.
|
||||||
|
dragTileOnto('s-c', tileEl('s-b'));
|
||||||
|
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a swap across rows of different heights: the two moved tiles fit, the rest not', () => {
|
||||||
|
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
|
||||||
|
try {
|
||||||
|
const five = SIX.slice(0, 5);
|
||||||
|
const app = openGrid(five);
|
||||||
|
app._tileGrid.rowFr = [3, 1];
|
||||||
|
app._applyTileLayout();
|
||||||
|
clearFits();
|
||||||
|
// [a b c / d e _]: a (row 0) and e (row 1) trade places and heights.
|
||||||
|
dragTileOnto('s-a', tileEl('s-e'));
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-e', 's-b', 's-c', 's-d', 's-a']);
|
||||||
|
expect(fitCounts(five)).toEqual({ 's-a': 1, 's-b': 0, 's-c': 0, 's-d': 0, 's-e': 1 });
|
||||||
|
} finally {
|
||||||
|
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no debounced refit of every tile follows a move', async () => {
|
||||||
|
const app = openGrid(SIX);
|
||||||
|
app._tileGrid.colFr = [2, 1, 1];
|
||||||
|
app._applyTileLayout();
|
||||||
|
clearFits();
|
||||||
|
dragTileOnto('s-a', tileEl('s-b'));
|
||||||
|
// Past TILE_GRID_REFIT_MS (150ms): still only the two moved tiles.
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)', () => {
|
||||||
|
const ARROW = { left: 'ArrowLeft', right: 'ArrowRight', up: 'ArrowUp', down: 'ArrowDown' } as const;
|
||||||
|
type Dir = keyof typeof ARROW;
|
||||||
|
const chord = (dir: Dir, overrides: Record<string, unknown> = {}) => ({
|
||||||
|
type: 'keydown',
|
||||||
|
key: ARROW[dir],
|
||||||
|
code: ARROW[dir],
|
||||||
|
ctrlKey: true,
|
||||||
|
shiftKey: true,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
target: { closest: () => null, classList: { contains: (c: string) => c === 'xterm-helper-textarea' } },
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
function handlerFor(app: GridApp) {
|
||||||
|
app.$ = () => null;
|
||||||
|
app.setupColorPicker = vi.fn();
|
||||||
|
const before = (documentAddEventListener.mock.calls as unknown[]).length;
|
||||||
|
app.setupEventListeners();
|
||||||
|
const added = (documentAddEventListener.mock.calls as Array<[string, (e: unknown) => void, boolean]>).slice(before);
|
||||||
|
const keydown = added.find(([type, , capture]) => type === 'keydown' && capture === true);
|
||||||
|
if (!keydown) throw new Error('no capture-phase keydown listener');
|
||||||
|
return keydown[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cell next to each cell in each direction, written out by hand (null:
|
||||||
|
// an edge). A move goes to that cell: a swap when a tile is there, a move
|
||||||
|
// into it when it is empty (the partial layouts below).
|
||||||
|
const TABLES: Record<
|
||||||
|
string,
|
||||||
|
{ ids: string[]; cols: number; width?: number; next: Array<Record<Dir, number | null>> }
|
||||||
|
> = {
|
||||||
|
'2x1': {
|
||||||
|
cols: 2,
|
||||||
|
ids: ['s-a', 's-b'],
|
||||||
|
next: [
|
||||||
|
{ left: null, right: 1, up: null, down: null },
|
||||||
|
{ left: 0, right: null, up: null, down: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'2x2': {
|
||||||
|
cols: 2,
|
||||||
|
ids: FOUR,
|
||||||
|
next: [
|
||||||
|
{ left: null, right: 1, up: null, down: 2 },
|
||||||
|
{ left: 0, right: null, up: null, down: 3 },
|
||||||
|
{ left: null, right: 3, up: 0, down: null },
|
||||||
|
{ left: 2, right: null, up: 1, down: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'3x2': {
|
||||||
|
cols: 3,
|
||||||
|
ids: SIX,
|
||||||
|
next: [
|
||||||
|
{ left: null, right: 1, up: null, down: 3 },
|
||||||
|
{ left: 0, right: 2, up: null, down: 4 },
|
||||||
|
{ left: 1, right: null, up: null, down: 5 },
|
||||||
|
{ left: null, right: 4, up: 0, down: null },
|
||||||
|
{ left: 3, right: 5, up: 1, down: null },
|
||||||
|
{ left: 4, right: null, up: 2, down: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [layout, table] of Object.entries(TABLES)) {
|
||||||
|
it(`${layout}: every cell, every direction, swaps with the right neighbour (or nothing at an edge)`, () => {
|
||||||
|
const bad: string[] = [];
|
||||||
|
table.next.forEach((next, i) => {
|
||||||
|
for (const dir of Object.keys(ARROW) as Dir[]) {
|
||||||
|
resetGridHarness();
|
||||||
|
if (table.width) {
|
||||||
|
const w = table.width;
|
||||||
|
section.getBoundingClientRect = () => ({ width: w, height: 1000, top: 0, left: 0, right: w, bottom: 1000 });
|
||||||
|
}
|
||||||
|
const app = openGrid(table.ids, table.ids[i]);
|
||||||
|
expect(app._tileGrid.cols).toBe(table.cols);
|
||||||
|
const costsNothing = snapshotCost();
|
||||||
|
app.markIdleAlertSeen.mockClear();
|
||||||
|
const onKeydown = handlerFor(app);
|
||||||
|
const e = chord(dir);
|
||||||
|
onKeydown(e);
|
||||||
|
const expected = table.ids.slice();
|
||||||
|
const j = next[dir];
|
||||||
|
if (j !== null) [expected[i], expected[j]] = [expected[j], expected[i]];
|
||||||
|
const moved = table.ids[i];
|
||||||
|
const ok =
|
||||||
|
e.preventDefault.mock.calls.length === 1 &&
|
||||||
|
JSON.stringify(app._tileGrid.ids) === JSON.stringify(expected) &&
|
||||||
|
app.activeSessionId === moved &&
|
||||||
|
app._tileGrid.focusedId === moved &&
|
||||||
|
tileEl(moved).classList.contains('focused') &&
|
||||||
|
JSON.stringify(stored().ids) === JSON.stringify(expected) &&
|
||||||
|
stored().focused === moved;
|
||||||
|
if (!ok) bad.push(`cell ${i} ${dir}: got ${app._tileGrid.ids.join(',')} focus ${app.activeSessionId}`);
|
||||||
|
costsNothing();
|
||||||
|
// Equal cells: nothing changed size, so nothing fits.
|
||||||
|
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(bad).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner: an empty cell can be any cell. With the hole in every cell in turn,
|
||||||
|
// every tile and every direction: into the hole, or a swap, or nothing.
|
||||||
|
const HOLED = [
|
||||||
|
{ name: '2x2 with 3 tiles', ids: ['s-a', 's-b', 's-c'], adjacency: TABLES['2x2'].next },
|
||||||
|
{ name: '3x2 with 5 tiles', ids: SIX.slice(0, 5), adjacency: TABLES['3x2'].next },
|
||||||
|
];
|
||||||
|
for (const layout of HOLED) {
|
||||||
|
it(`${layout.name}: with the hole anywhere, every tile, every direction (into the hole, a swap, or nothing)`, () => {
|
||||||
|
const bad: string[] = [];
|
||||||
|
const size = layout.adjacency.length;
|
||||||
|
for (let hole = 0; hole < size; hole++) {
|
||||||
|
const cells: Array<string | null> = layout.ids.slice();
|
||||||
|
cells.splice(hole, 0, null);
|
||||||
|
for (let i = 0; i < size; i++) {
|
||||||
|
if (i === hole) continue;
|
||||||
|
for (const dir of Object.keys(ARROW) as Dir[]) {
|
||||||
|
resetGridHarness();
|
||||||
|
section.getBoundingClientRect = () => ({
|
||||||
|
width: 1700,
|
||||||
|
height: 1000,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 1700,
|
||||||
|
bottom: 1000,
|
||||||
|
});
|
||||||
|
const moved = cells[i] as string;
|
||||||
|
const app = openGrid(layout.ids, moved);
|
||||||
|
app._tileGrid.cells = cells.slice();
|
||||||
|
app._applyTileLayout();
|
||||||
|
const costsNothing = snapshotCost();
|
||||||
|
const e = chord(dir);
|
||||||
|
handlerFor(app)(e);
|
||||||
|
const want = cells.slice();
|
||||||
|
const j = layout.adjacency[i][dir];
|
||||||
|
if (j !== null) [want[i], want[j]] = [want[j], want[i]];
|
||||||
|
const ok =
|
||||||
|
e.preventDefault.mock.calls.length === 1 &&
|
||||||
|
JSON.stringify(app._tileGrid.cells) === JSON.stringify(want) &&
|
||||||
|
app.activeSessionId === moved &&
|
||||||
|
app._tileGrid.focusedId === moved &&
|
||||||
|
JSON.stringify(stored().ids) === JSON.stringify(want);
|
||||||
|
if (!ok) bad.push(`hole ${hole}, cell ${i} ${dir}: got ${JSON.stringify(app._tileGrid.cells)}`);
|
||||||
|
costsNothing();
|
||||||
|
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(bad).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('a move into a hole across columns of different widths: only the moved tile fits, once', () => {
|
||||||
|
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
|
||||||
|
const five = SIX.slice(0, 5);
|
||||||
|
const app = openGrid(five, 's-b');
|
||||||
|
app._tileGrid.colFr = [1, 1, 2];
|
||||||
|
app._applyTileLayout();
|
||||||
|
clearFits();
|
||||||
|
// [a b c / d e _]: b (column 1) down into... e is there; first move e right into the hole.
|
||||||
|
app._selectTiledSession('s-e', { auto: true });
|
||||||
|
handlerFor(app)(chord('right'));
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', null, 's-e']);
|
||||||
|
expect(fitCounts(five)).toEqual({ 's-a': 0, 's-b': 0, 's-c': 0, 's-d': 0, 's-e': 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focus stays on the moved tile through several moves, and the order is stored', () => {
|
||||||
|
const app = openGrid(SIX, 's-a');
|
||||||
|
const onKeydown = handlerFor(app);
|
||||||
|
for (const dir of ['right', 'right', 'down', 'left'] as Dir[]) onKeydown(chord(dir));
|
||||||
|
// a: 0 -> 1 -> 2 -> 5 -> 4
|
||||||
|
expect(app._tileGrid.ids.indexOf('s-a')).toBe(4);
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
|
expect(stored().ids).toEqual(app._tileGrid.ids);
|
||||||
|
expect(stored().focused).toBe('s-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only a tile whose cell size changed fits, once', () => {
|
||||||
|
const app = openGrid(SIX, 's-a');
|
||||||
|
app._tileGrid.colFr = [2, 1, 1];
|
||||||
|
app._applyTileLayout();
|
||||||
|
clearFits();
|
||||||
|
handlerFor(app)(chord('right'));
|
||||||
|
expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('swallowed only while they apply', () => {
|
||||||
|
it('grid closed: not a tile chord, and the capture handler leaves it alone (it reaches the terminal)', () => {
|
||||||
|
const app = makeGridApp(FOUR);
|
||||||
|
// Even with the Tiles setting on (where the toggle chord would apply).
|
||||||
|
app.loadAppSettingsFromStorage = () => ({ showTileGridButton: true });
|
||||||
|
expect(app.tileShortcutFor(chord('right'))).toBeNull();
|
||||||
|
const e = chord('right');
|
||||||
|
handlerFor(app)(e);
|
||||||
|
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grid open, from a terminal: applies and is swallowed', () => {
|
||||||
|
const app = openGrid(FOUR);
|
||||||
|
expect(app.tileShortcutFor(chord('right'))).toBe('move-tile-right');
|
||||||
|
expect(app.tileShortcutFor(chord('down'))).toBe('move-tile-down');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('in a text field (the rename input, an editor) it is left to the field: word selection', () => {
|
||||||
|
const app = openGrid(FOUR);
|
||||||
|
const onKeydown = handlerFor(app);
|
||||||
|
for (const tagName of ['INPUT', 'TEXTAREA']) {
|
||||||
|
const e = chord('right', { target: { tagName, closest: () => null, classList: { contains: () => false } } });
|
||||||
|
expect(app.tileShortcutFor(e)).toBeNull();
|
||||||
|
onKeydown(e);
|
||||||
|
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
const editable = chord('left', { target: { isContentEditable: true, closest: () => null } });
|
||||||
|
expect(app.tileShortcutFor(editable)).toBeNull();
|
||||||
|
expect(app._tileGrid.ids).toEqual(FOUR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('while a tile is zoomed it still applies (swallowed, never typed into the CLI) and moves nothing', () => {
|
||||||
|
const app = openGrid(FOUR);
|
||||||
|
app.zoomTile('s-a');
|
||||||
|
const e = chord('right');
|
||||||
|
expect(app.tileShortcutFor(e)).toBe('move-tile-right');
|
||||||
|
handlerFor(app)(e);
|
||||||
|
expect(e.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(app._tileGrid.ids).toEqual(FOUR);
|
||||||
|
expect(app._tileGrid.zoomedId).toBe('s-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a disable and a rebind from App Settings', () => {
|
||||||
|
const app = openGrid(FOUR);
|
||||||
|
app.loadAppSettingsFromStorage = () => ({
|
||||||
|
shortcutOverrides: {
|
||||||
|
'move-tile-right': { disabled: true },
|
||||||
|
'move-tile-left': { bindings: [{ modifiers: ['ctrl', 'alt'], key: 'h', code: 'KeyH' }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(app.tileShortcutFor(chord('right'))).toBeNull();
|
||||||
|
expect(app.tileShortcutFor(chord('left'))).toBeNull();
|
||||||
|
expect(app.tileShortcutFor(chord('left', { key: 'h', code: 'KeyH', shiftKey: false, altKey: true }))).toBe(
|
||||||
|
'move-tile-left'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The Tiles button opens the grid at once (owner decision 8).
|
||||||
|
*
|
||||||
|
* - `tileGridOpenSet` (constants.js, pure): what opens, in order: (a) the grid
|
||||||
|
* this tab last had, if any of its sessions survive; (b) else an open
|
||||||
|
* split's two sessions; (c) else the open sessions in tab order up to the
|
||||||
|
* limit (never past the cap of 6), the active one always among them and
|
||||||
|
* focused. Detached sessions and ones that no longer exist are left out, as
|
||||||
|
* in the picker.
|
||||||
|
* - The button's click and Ctrl+Shift+G are the same function
|
||||||
|
* (`toggleTileGrid`); right-click (contextmenu) opens the count menu (owner
|
||||||
|
* decision 10). With the grid open a pick re-forms it, the focused tile kept.
|
||||||
|
*
|
||||||
|
* The pure helper and the app both via the shared harness (test/mocks/tile-grid-vm.ts).
|
||||||
|
* Port: N/A.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
body,
|
||||||
|
bySelector,
|
||||||
|
documentAddEventListener,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
windowStub,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
type OpenSet = { source: string; ids: string[]; focusedId: string | null } | null;
|
||||||
|
type Helpers = { tileGridOpenSet(p: Record<string, unknown>): OpenSet; TILE_GRID_MAX: number };
|
||||||
|
|
||||||
|
// constants.js as the harness loaded it (window.CodemanTileGrid).
|
||||||
|
const T = windowStub.CodemanTileGrid as Helpers;
|
||||||
|
const INDEX_HTML = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
|
||||||
|
|
||||||
|
const order = Array.from({ length: 9 }, (_, i) => `t${i + 1}`);
|
||||||
|
const sessions = new Map(order.map((id) => [id, { id, name: id }]));
|
||||||
|
const base = { sessions, sessionOrder: order, limit: 6 };
|
||||||
|
|
||||||
|
describe('tileGridOpenSet (what the Tiles button opens)', () => {
|
||||||
|
it('a: the grid this tab last had, its focus (or its zoom) included', () => {
|
||||||
|
const stored = { ids: ['t4', 't2'], focused: 't2', zoomed: null };
|
||||||
|
expect(T.tileGridOpenSet({ ...base, stored, split: ['t1', 't9'], activeId: 't1' })).toEqual({
|
||||||
|
source: 'stored',
|
||||||
|
ids: ['t4', 't2'],
|
||||||
|
focusedId: 't2',
|
||||||
|
});
|
||||||
|
expect(T.tileGridOpenSet({ ...base, stored: { ids: ['t4', 't2'], focused: 't4', zoomed: 't2' } })?.focusedId).toBe(
|
||||||
|
't2'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('b: else an open split, Pane A focused', () => {
|
||||||
|
expect(T.tileGridOpenSet({ ...base, stored: { ids: [] }, split: ['t5', 't7'], activeId: 't5' })).toEqual({
|
||||||
|
source: 'split',
|
||||||
|
ids: ['t5', 't7'],
|
||||||
|
focusedId: 't5',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('c: else the open sessions in tab order up to the limit, the active one focused', () => {
|
||||||
|
expect(T.tileGridOpenSet({ ...base, activeId: 't3' })).toEqual({
|
||||||
|
source: 'tabs',
|
||||||
|
ids: order.slice(0, 6),
|
||||||
|
focusedId: 't3',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('c: an active session past the limit still comes, with the first ones before it', () => {
|
||||||
|
expect(T.tileGridOpenSet({ ...base, activeId: 't9' })).toEqual({
|
||||||
|
source: 'tabs',
|
||||||
|
ids: ['t1', 't2', 't3', 't4', 't5', 't9'],
|
||||||
|
focusedId: 't9',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('c: a window that takes fewer gets fewer; nothing ever passes the cap', () => {
|
||||||
|
expect(T.tileGridOpenSet({ ...base, limit: 4, activeId: 't1' })?.ids).toEqual(['t1', 't2', 't3', 't4']);
|
||||||
|
expect(T.tileGridOpenSet({ ...base, limit: 99, activeId: 't1' })?.ids).toHaveLength(T.TILE_GRID_MAX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out detached sessions and ones that no longer exist, as the picker does', () => {
|
||||||
|
const detachedIds = new Set(['t2']);
|
||||||
|
const out = T.tileGridOpenSet({ ...base, sessionOrder: ['gone', ...order], detachedIds, activeId: 't1' });
|
||||||
|
expect(out?.ids).toEqual(['t1', 't3', 't4', 't5', 't6', 't7']);
|
||||||
|
// A split whose Pane B was popped out: Pane A alone.
|
||||||
|
expect(T.tileGridOpenSet({ ...base, split: ['t1', 't2'], detachedIds, activeId: 't1' })?.ids).toEqual(['t1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null when there is nothing to open', () => {
|
||||||
|
expect(T.tileGridOpenSet({ sessions: new Map(), sessionOrder: [], limit: 6, activeId: null })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the Tiles button and Ctrl+Shift+G', () => {
|
||||||
|
const button = () => {
|
||||||
|
const html = INDEX_HTML.match(/<button class="btn-icon-header btn-tile-grid[^>]*>/)?.[0] ?? '';
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('a click opens the grid at once (the toggle), a right-click opens the count menu', () => {
|
||||||
|
expect(button()).toContain('onclick="app.toggleTileGrid()"');
|
||||||
|
expect(button()).toContain('oncontextmenu="app.openTileCountMenu(event)"');
|
||||||
|
expect(button()).toContain('right-click for how many');
|
||||||
|
expect(button()).not.toContain('onclick="app.openTileCountMenu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Shift+G runs the same toggle', () => {
|
||||||
|
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/tile-grid.js'), 'utf8');
|
||||||
|
expect(src).toContain("if (id === 'toggle-tile-grid') this.toggleTileGrid();");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('opening at once, in the app', () => {
|
||||||
|
const IDS = ['s-a', 's-b', 's-c'];
|
||||||
|
const picker = () => body.children.find((c) => c.id === 'tileCountMenu') ?? null;
|
||||||
|
const pick = (n: number) =>
|
||||||
|
picker()!
|
||||||
|
.children.find((c) => c.dataset.count === String(n))!
|
||||||
|
.dispatch('click', { stopPropagation: vi.fn() });
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
const wrap = new FakeEl();
|
||||||
|
bySelector.set('.terminal-wrap', wrap);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a click with the grid closed shows the tiles, no menu', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.activeSessionId = 's-b';
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(picker()).toBeNull();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-other', ...IDS]);
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a click closes a menu that a right-click left open', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||||
|
expect(picker()).not.toBeNull();
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(picker()).toBeNull();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a click elsewhere closes the menu; a click inside it does not', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
const before = documentAddEventListener.mock.calls.length;
|
||||||
|
app.openTileCountMenu({ preventDefault: vi.fn() });
|
||||||
|
const calls = documentAddEventListener.mock.calls.slice(before) as Array<[string, (e: unknown) => void]>;
|
||||||
|
const onClick = calls.find(([type]) => type === 'click')![1];
|
||||||
|
onClick({ target: picker()!.children[0] });
|
||||||
|
expect(picker()).not.toBeNull();
|
||||||
|
onClick({ target: new FakeEl() });
|
||||||
|
expect(picker()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a right-click opens the count menu and keeps the browser menu away', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
const ev = { preventDefault: vi.fn(), stopPropagation: vi.fn() };
|
||||||
|
app.openTileCountMenu(ev);
|
||||||
|
expect(ev.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(picker()).not.toBeNull();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a right-click with the grid open re-forms it to the count picked, the focused tile kept', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(['s-a', 's-b'], { focusedId: 's-b' });
|
||||||
|
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
pick(4);
|
||||||
|
// Its two first, then the open sessions in tab order.
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-other', 's-c']);
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||||
|
pick(2);
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Opening the grid paints the tiles first and builds their
|
||||||
|
* terminals one per frame (owner answer: "paced connect: in").
|
||||||
|
*
|
||||||
|
* - `openTileGrid` connects no tile inside the click: every tile is mounted
|
||||||
|
* and the grid laid out first, then one tile connects per animation frame,
|
||||||
|
* the focused one first, then reading order. Each still connects once, into
|
||||||
|
* its final cell (one fit, one PTY resize).
|
||||||
|
* - The keyboard goes to the focused tile's terminal as soon as it exists
|
||||||
|
* (`focusOnConnect`): not before, never for a `focus: false` selection, and
|
||||||
|
* to the newly focused tile when focus moved while they were being built.
|
||||||
|
* - A grid closed (or opened again) meanwhile stops the old run; a tile
|
||||||
|
* removed before its turn is skipped; a tile already connected (one a
|
||||||
|
* re-form added) is never connected twice; a remount after Attach connects
|
||||||
|
* its new terminal.
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
FakeTile,
|
||||||
|
bySelector,
|
||||||
|
flushFrames,
|
||||||
|
makeGridApp,
|
||||||
|
rafCallbacks,
|
||||||
|
resetGridHarness,
|
||||||
|
type GridApp,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c', 's-d'];
|
||||||
|
const connected = () => FakeTile.all.filter((t) => t.connect.mock.calls.length > 0).map((t) => t.sessionId);
|
||||||
|
const tileOf = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed)!;
|
||||||
|
/** Runs the frames queued so far, once (one step of the paced connect). */
|
||||||
|
const oneFrame = () => {
|
||||||
|
for (const cb of rafCallbacks.splice(0)) cb();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
const btn = new FakeEl();
|
||||||
|
btn.className = 'btn-icon-header btn-tile-grid';
|
||||||
|
bySelector.set('.btn-tile-grid', btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
function gridApp(ids = IDS): GridApp {
|
||||||
|
const app = makeGridApp(ids);
|
||||||
|
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('opening paints first, then one terminal per frame', () => {
|
||||||
|
it('nothing connects inside the click; then the focused tile first, then reading order, one per frame', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS, { focusedId: 's-c' });
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
expect(FakeTile.all).toHaveLength(4);
|
||||||
|
expect(connected()).toEqual([]);
|
||||||
|
oneFrame();
|
||||||
|
expect(connected()).toEqual(['s-c']);
|
||||||
|
oneFrame();
|
||||||
|
expect(connected()).toEqual(['s-a', 's-c']);
|
||||||
|
oneFrame();
|
||||||
|
oneFrame();
|
||||||
|
expect(connected().sort()).toEqual(IDS);
|
||||||
|
oneFrame();
|
||||||
|
expect(rafCallbacks).toHaveLength(0);
|
||||||
|
expect(FakeTile.all.every((t) => t.connect.mock.calls.length === 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every tile connects into its final cell: the grid was laid out before the first', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
const seen: string[] = [];
|
||||||
|
for (const t of FakeTile.all) {
|
||||||
|
t.connect.mockImplementation(async () => {
|
||||||
|
seen.push(`${t.sessionId}:${app._tileGrid.cells.length}:${app._tileGrid.cells.indexOf(t.sessionId)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
flushFrames();
|
||||||
|
expect(seen).toEqual(['s-a:4:0', 's-b:4:1', 's-c:4:2', 's-d:4:3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the keyboard', () => {
|
||||||
|
it('lands in the focused tile once its terminal exists, not before', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS, { focusedId: 's-b' });
|
||||||
|
expect(tileOf('s-b').terminal.focus).not.toHaveBeenCalled();
|
||||||
|
oneFrame();
|
||||||
|
expect(tileOf('s-b').terminal.focus).toHaveBeenCalledTimes(1);
|
||||||
|
flushFrames();
|
||||||
|
expect(tileOf('s-b').terminal.focus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tileOf('s-a').terminal.focus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows a focus moved while the terminals are being built', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS, { focusedId: 's-a' });
|
||||||
|
app._selectTiledSession('s-d', {});
|
||||||
|
flushFrames();
|
||||||
|
expect(tileOf('s-d').terminal.focus).toHaveBeenCalled();
|
||||||
|
expect(tileOf('s-a').terminal.focus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never for a selection that asked for no focus', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS, { focusedId: 's-a' });
|
||||||
|
app._selectTiledSession('s-a', { focus: false });
|
||||||
|
flushFrames();
|
||||||
|
expect(FakeTile.all.some((t) => t.terminal.focus.mock.calls.length > 0)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tile already built takes the keyboard at once', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS, { focusedId: 's-a' });
|
||||||
|
flushFrames();
|
||||||
|
app._selectTiledSession('s-c', {});
|
||||||
|
expect(tileOf('s-c').terminal.focus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a grid that changes meanwhile', () => {
|
||||||
|
it('closed: nothing connects any more, and no frame is asked for after it', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
oneFrame();
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
oneFrame();
|
||||||
|
expect(rafCallbacks).toHaveLength(0);
|
||||||
|
flushFrames();
|
||||||
|
expect(connected()).toEqual(['s-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opened again: the old run stops at once, the new one starts with its own focused tile', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS, { focusedId: 's-a' });
|
||||||
|
oneFrame();
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
app.activeSessionId = null;
|
||||||
|
app.openTileGrid(IDS, { focusedId: 's-d' });
|
||||||
|
oneFrame();
|
||||||
|
const live = () => FakeTile.all.filter((t) => !t._destroyed && t.connect.mock.calls.length).map((t) => t.sessionId);
|
||||||
|
expect(live()).toEqual(['s-d']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closed and opened again: the old run stops, the new one connects each tile once', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
oneFrame();
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
app.activeSessionId = null;
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
flushFrames();
|
||||||
|
const live = FakeTile.all.filter((t) => !t._destroyed);
|
||||||
|
expect(live).toHaveLength(4);
|
||||||
|
expect(live.every((t) => t.connect.mock.calls.length === 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tile removed before its turn is skipped; one a re-form connected is not connected again', () => {
|
||||||
|
const app = gridApp(['s-a', 's-b', 's-c', 's-d', 's-e']);
|
||||||
|
app.openTileGrid(['s-a', 's-b', 's-c']);
|
||||||
|
app.removeTile('s-b');
|
||||||
|
app.addTile('s-e');
|
||||||
|
expect(tileOf('s-e').connect).toHaveBeenCalledTimes(1);
|
||||||
|
flushFrames();
|
||||||
|
expect(FakeTile.all.find((t) => t.sessionId === 's-b')!.connect).not.toHaveBeenCalled();
|
||||||
|
expect(tileOf('s-e').connect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tileOf('s-a').connect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tileOf('s-c').connect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tile remounted before its turn (Attach) is connected once, not again by the paced run', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app._remountTile('s-c');
|
||||||
|
flushFrames();
|
||||||
|
expect(tileOf('s-c').connect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a remount after Attach connects its new terminal', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
flushFrames();
|
||||||
|
const old = tileOf('s-b');
|
||||||
|
app._remountTile('s-b');
|
||||||
|
const fresh = tileOf('s-b');
|
||||||
|
expect(fresh).not.toBe(old);
|
||||||
|
expect(fresh.connect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview While the tile grid is open the main terminal is PARKED.
|
||||||
|
*
|
||||||
|
* Opening the grid (tile-grid.js `openTileGrid`) runs `_cleanupPreviousSession`
|
||||||
|
* once, which closes the main terminal's socket, and hides `.terminal-wrap`.
|
||||||
|
* With that socket closed `_wsReady` is false, so every SSE terminal handler
|
||||||
|
* would start writing the FOCUSED tile's output into the hidden xterm (they all
|
||||||
|
* key on `activeSessionId`), refetch captures for it, or reopen its socket onto
|
||||||
|
* a session a tile already shows. One predicate, `_tilesOwnTerminal()`, turns
|
||||||
|
* each of them into a no-op. This suite enumerates them, and checks each one
|
||||||
|
* BOTH ways (grid open: stands aside; grid closed: acts), so none can pass
|
||||||
|
* vacuously:
|
||||||
|
*
|
||||||
|
* - `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh`
|
||||||
|
* (returns false, never undefined), `_scheduleDroppedOutputRecovery`;
|
||||||
|
* - the `terminal.writeln` in `_onSessionCompletion` / `_onSessionError`;
|
||||||
|
* - `retryConnection` and `handleInit` (which reconnect the main socket):
|
||||||
|
* they re-arm the TILES instead, and handleInit keeps live tiles;
|
||||||
|
* - the backstops `sendResize`, `_maybeRefetchFullHistory`;
|
||||||
|
* - the WebGL long-task observer, which watches the WHOLE page: tile renders
|
||||||
|
* must not count toward the main terminal's sticky WebGL disable;
|
||||||
|
* - the header connection state, derived from the tile sockets.
|
||||||
|
*
|
||||||
|
* Plus the park and unpark themselves: one cleanup on enter; on exit every
|
||||||
|
* tile destroyed, `_lastResizeDims` reset, the main terminal's cached content
|
||||||
|
* for EVERY tiled id invalidated, and the focused session replayed fresh.
|
||||||
|
*
|
||||||
|
* Real code: constants.js + app.js + terminal-ui.js + terminal-split.js +
|
||||||
|
* tile-grid.js in one `vm` context, with the shared fake DOM and fake
|
||||||
|
* TerminalTile (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeTile,
|
||||||
|
advanceClock,
|
||||||
|
clockNow,
|
||||||
|
flushFrames,
|
||||||
|
localStore,
|
||||||
|
main,
|
||||||
|
makeGridApp,
|
||||||
|
perfObserverCallbacks,
|
||||||
|
resetGridHarness,
|
||||||
|
windowStub,
|
||||||
|
type GridApp,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
type App = GridApp;
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared app plus what these guards exercise: the REAL sendResize (the
|
||||||
|
* harness stubs it), and spies on the main terminal's own paths.
|
||||||
|
*/
|
||||||
|
function makeApp(): App {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
delete app.sendResize;
|
||||||
|
app._initGeneration = 1;
|
||||||
|
app.isOnline = true;
|
||||||
|
app._connectionStatus = 'connected';
|
||||||
|
app._wsState = 'disconnected';
|
||||||
|
app._lastResizeDims = { cols: 100, rows: 30 };
|
||||||
|
for (const name of [
|
||||||
|
'selectSession',
|
||||||
|
'batchTerminalWrite',
|
||||||
|
'_connectWs',
|
||||||
|
'_geometryForResizeRequest',
|
||||||
|
'updateCost',
|
||||||
|
'_notifySession',
|
||||||
|
'connectSSE',
|
||||||
|
'_updateConnectionLossUi',
|
||||||
|
]) {
|
||||||
|
app[name] = vi.fn();
|
||||||
|
}
|
||||||
|
app._fetchTerminalCapture = vi.fn(async () => ({ json: { data: {} }, headersAt: 0 }));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parking the main terminal', () => {
|
||||||
|
it('opening takes no snapshot of a session that becomes a tile, and keeps it for one that does not', () => {
|
||||||
|
// Closing the grid drops the snapshot of every tiled id, so one taken of a
|
||||||
|
// tiled session at opening was always thrown away.
|
||||||
|
const tiled = makeApp();
|
||||||
|
tiled.openTileGrid(IDS);
|
||||||
|
expect(tiled._cleanupPreviousSession).toHaveBeenCalledWith('s-a', { skipSnapshot: true });
|
||||||
|
|
||||||
|
resetGridHarness();
|
||||||
|
const elsewhere = makeApp(); // s-a is active, the grid opens on the others
|
||||||
|
elsewhere.openTileGrid(['s-b', 's-c']);
|
||||||
|
expect(elsewhere._cleanupPreviousSession).toHaveBeenCalledWith('s-b', { skipSnapshot: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_cleanupPreviousSession skips the snapshot only when asked', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
delete app._cleanupPreviousSession; // the real one
|
||||||
|
app._disconnectWs = vi.fn();
|
||||||
|
const serialize = vi.fn(() => 'snapshot of s-a\r\n'.repeat(4));
|
||||||
|
app._serializeAddon = { serialize };
|
||||||
|
app._isUsableXtermSnapshot = () => true;
|
||||||
|
app._persistXtermSnapshot = vi.fn();
|
||||||
|
app._cleanupPreviousSession('s-b', { skipSnapshot: true });
|
||||||
|
expect(serialize).not.toHaveBeenCalled();
|
||||||
|
expect(app._xtermSnapshots.has('s-a')).toBe(false);
|
||||||
|
app._cleanupPreviousSession('s-b');
|
||||||
|
expect(serialize).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app._xtermSnapshots.has('s-a')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opening parks it ONCE and puts a tile per session in its place, focused tile active', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
expect(app.openTileGrid(IDS, { focusedId: 's-b' })).toBe(true);
|
||||||
|
|
||||||
|
expect(app._cleanupPreviousSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(main.classList.contains('tiles-active')).toBe(true);
|
||||||
|
expect(FakeTile.all.map((t) => t.sessionId)).toEqual(IDS);
|
||||||
|
// Built one per frame after the click (_connectTilesPaced), each once.
|
||||||
|
flushFrames();
|
||||||
|
expect(FakeTile.all.every((t) => t.connect.mock.calls.length === 1)).toBe(true);
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
// Grid tiles: the one load queue, the smaller scrollback, a bounded load.
|
||||||
|
expect(FakeTile.all[0].opts).toMatchObject({ scrollback: 10000, boundedLoad: true });
|
||||||
|
expect(typeof FakeTile.all[0].opts.scheduleLoad).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the focused tile connects first, so its capture leads the queue', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const order: string[] = [];
|
||||||
|
app.openTileGrid(IDS, { focusedId: 's-c' });
|
||||||
|
for (const t of FakeTile.all) order.push(`${t.sessionId}:${t.connect.mock.invocationCallOrder[0]}`);
|
||||||
|
const first = FakeTile.all.reduce((a, b) =>
|
||||||
|
a.connect.mock.invocationCallOrder[0] < b.connect.mock.invocationCallOrder[0] ? a : b
|
||||||
|
);
|
||||||
|
expect(first.sessionId).toBe('s-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses below the desktop width, in a solo window, and with no live session', () => {
|
||||||
|
const narrow = makeApp();
|
||||||
|
windowStub.innerWidth = 1179;
|
||||||
|
expect(narrow.openTileGrid(IDS)).toBe(false);
|
||||||
|
windowStub.innerWidth = 2400;
|
||||||
|
|
||||||
|
const solo = makeApp();
|
||||||
|
solo.isSoloWindow = true;
|
||||||
|
expect(solo.openTileGrid(IDS)).toBe(false);
|
||||||
|
|
||||||
|
const none = makeApp();
|
||||||
|
expect(none.openTileGrid(['gone'])).toBe(false);
|
||||||
|
expect(none._cleanupPreviousSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never tiles a detached session', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app.detachedSessions.add('s-b');
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
expect(FakeTile.all.map((t) => t.sessionId)).toEqual(['s-a', 's-c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closing destroys every tile and replays the focused session fresh in the single view', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app.openTileGrid(IDS, { focusedId: 's-b' });
|
||||||
|
app.closeTileGrid();
|
||||||
|
|
||||||
|
expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 1)).toBe(true);
|
||||||
|
expect(main.classList.contains('tiles-active')).toBe(false);
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(app._lastResizeDims).toBeNull();
|
||||||
|
expect(app.selectSession).toHaveBeenCalledWith('s-b', { forceReload: true, auto: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closing invalidates the main terminal's cached content for EVERY tiled id, and only those", () => {
|
||||||
|
const app = makeApp();
|
||||||
|
for (const id of [...IDS, 's-other']) {
|
||||||
|
app._xtermSnapshots.set(id, 'old');
|
||||||
|
app.terminalBufferCache.set(id, 'old');
|
||||||
|
localStore.set(`codeman-xs-${id}`, 'old');
|
||||||
|
}
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app.closeTileGrid();
|
||||||
|
|
||||||
|
for (const id of IDS) {
|
||||||
|
expect(app._xtermSnapshots.has(id)).toBe(false);
|
||||||
|
expect(app.terminalBufferCache.has(id)).toBe(false);
|
||||||
|
expect(localStore.has(`codeman-xs-${id}`)).toBe(false);
|
||||||
|
}
|
||||||
|
expect(app._xtermSnapshots.get('s-other')).toBe('old');
|
||||||
|
expect(localStore.get('codeman-xs-s-other')).toBe('old');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closing with reselect: false leaves the next selection to the caller', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
expect(app.selectSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** An app with the grid open on IDS (focus s-a), or closed, for the paired guard checks. */
|
||||||
|
function appWithGrid(open: boolean) {
|
||||||
|
const app = makeApp();
|
||||||
|
if (open) app.openTileGrid(IDS);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('guards: each stands aside while tiles own the terminal, and acts otherwise', () => {
|
||||||
|
it.each([true, false])('_onSessionTerminal (grid open: %s)', (open) => {
|
||||||
|
const app = appWithGrid(open);
|
||||||
|
app.pendingWrites = [];
|
||||||
|
app._onSessionTerminal({ id: 's-a', data: 'output' });
|
||||||
|
expect(app.batchTerminalWrite).toHaveBeenCalledTimes(open ? 0 : 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])('_onSessionNeedsRefresh (grid open: %s)', async (open) => {
|
||||||
|
const app = appWithGrid(open);
|
||||||
|
const result = await app._onSessionNeedsRefresh({ id: 's-a' });
|
||||||
|
if (open) expect(result).toBe(false);
|
||||||
|
expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(open ? 0 : 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])('_onSessionClearTerminal (grid open: %s)', async (open) => {
|
||||||
|
const app = appWithGrid(open);
|
||||||
|
app._resetTerminalForReplay = vi.fn();
|
||||||
|
app.sendResize = vi.fn();
|
||||||
|
await app._onSessionClearTerminal({ id: 's-a' });
|
||||||
|
expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(open ? 0 : 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])('_scheduleDroppedOutputRecovery (grid open: %s)', (open) => {
|
||||||
|
const app = appWithGrid(open);
|
||||||
|
app._scheduleDroppedOutputRecovery('s-a', 0, 1024);
|
||||||
|
expect(!!app._clientDropRecoveryTimer).toBe(!open);
|
||||||
|
clearTimeout(app._clientDropRecoveryTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])(
|
||||||
|
'_onSessionCompletion writes into the main terminal only when it is not parked (open: %s)',
|
||||||
|
(open) => {
|
||||||
|
const app = appWithGrid(open);
|
||||||
|
app.totalCost = 0;
|
||||||
|
app._onSessionCompletion({ id: 's-a', cost: 0.5 });
|
||||||
|
expect(app.terminal.writeln).toHaveBeenCalledTimes(open ? 0 : 2);
|
||||||
|
expect(app.totalCost).toBe(0.5);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([true, false])(
|
||||||
|
'_onSessionError writes into the main terminal only when it is not parked (open: %s)',
|
||||||
|
(open) => {
|
||||||
|
const app = appWithGrid(open);
|
||||||
|
app._onSessionError({ id: 's-a', error: 'boom' });
|
||||||
|
expect(app.terminal.writeln).toHaveBeenCalledTimes(open ? 0 : 1);
|
||||||
|
// The notification is not the terminal's: it fires either way.
|
||||||
|
expect(app._notifySession).toHaveBeenCalledTimes(1);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([true, false])('retryConnection re-arms the tiles, never the parked main socket (open: %s)', (open) => {
|
||||||
|
const app = appWithGrid(open);
|
||||||
|
app.retryConnection();
|
||||||
|
clearTimeout(app._offlineRetryTimer);
|
||||||
|
expect(app._connectWs).toHaveBeenCalledTimes(open ? 0 : 1);
|
||||||
|
if (open) expect(FakeTile.all.every((t) => t.reconnectNow.mock.calls.length === 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])('sendResize backstop (grid open: %s)', async (open) => {
|
||||||
|
const app = appWithGrid(open);
|
||||||
|
expect(await app.sendResize('s-a')).toBe(false);
|
||||||
|
expect(app._geometryForResizeRequest).toHaveBeenCalledTimes(open ? 0 : 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])('_maybeRefetchFullHistory backstop (grid open: %s)', async (open) => {
|
||||||
|
const app = appWithGrid(open);
|
||||||
|
app._fullHistoryRepullAt = new Map();
|
||||||
|
app._fullHistoryRepullUseless = new Set();
|
||||||
|
app._fetchTerminalCapture = vi.fn(async () => {
|
||||||
|
throw new Error('stop here');
|
||||||
|
});
|
||||||
|
await app._maybeRefetchFullHistory().catch(() => {});
|
||||||
|
expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(open ? 0 : 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])(
|
||||||
|
'the WebGL long-task observer counts nothing while tiles own the terminal (open: %s)',
|
||||||
|
(open) => {
|
||||||
|
const app = appWithGrid(open);
|
||||||
|
app._webglAddon = { dispose: vi.fn() };
|
||||||
|
app._webglLongTaskObserver = null;
|
||||||
|
app._disableWebGLSticky = vi.fn();
|
||||||
|
app._scheduleTerminalRepaint = vi.fn();
|
||||||
|
app._installWebGLLongTaskGuard();
|
||||||
|
advanceClock(60_000); // past the install grace period
|
||||||
|
const longTasks = [0, 1, 2].map((i) => ({ duration: 400, startTime: clockNow() - 100 * i }));
|
||||||
|
perfObserverCallbacks.at(-1)?.({ getEntries: () => longTasks });
|
||||||
|
expect(app._disableWebGLSticky).toHaveBeenCalledTimes(open ? 0 : 1);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleInit with the grid open (SSE back after a server restart)', () => {
|
||||||
|
function init(app: App, liveIds: string[]) {
|
||||||
|
app._initGeneration = 1; // gen > 1 on this call: a reconnect, not a page load
|
||||||
|
app._resetAllAppState = vi.fn(() => app.sessions.clear());
|
||||||
|
for (const name of [
|
||||||
|
'_clearTimer',
|
||||||
|
'_updateCjkInputState',
|
||||||
|
'syncSessionOrder',
|
||||||
|
'_loadTabLayout',
|
||||||
|
'cleanupAllFloatingWindows',
|
||||||
|
'startSystemStatsPolling',
|
||||||
|
'stopSystemStatsPolling',
|
||||||
|
]) {
|
||||||
|
app[name] = vi.fn();
|
||||||
|
}
|
||||||
|
app.$ = () => null;
|
||||||
|
app._onSessionNeedsRefresh = vi.fn(async () => false);
|
||||||
|
app.handleInit({
|
||||||
|
sessions: liveIds.map((id) => ({ id, name: id, mode: 'claude', pid: 1 })),
|
||||||
|
scheduledRuns: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps live tiles (never rebuilt), reconnects them now, and never touches the main socket', () => {
|
||||||
|
const app = appWithGrid(true);
|
||||||
|
const before = [...FakeTile.all];
|
||||||
|
init(app, [...IDS, 's-other']);
|
||||||
|
|
||||||
|
expect(FakeTile.all).toEqual(before);
|
||||||
|
expect(before.every((t) => t.destroy.mock.calls.length === 0)).toBe(true);
|
||||||
|
expect(before.every((t) => t.reconnectNow.mock.calls.length === 1)).toBe(true);
|
||||||
|
expect(app._connectWs).not.toHaveBeenCalled();
|
||||||
|
expect(app._onSessionNeedsRefresh).not.toHaveBeenCalled();
|
||||||
|
expect(app.selectSession).not.toHaveBeenCalled();
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the tile of a session that did not come back and focuses a live one', () => {
|
||||||
|
const app = appWithGrid(true);
|
||||||
|
init(app, ['s-b', 's-c', 's-other']);
|
||||||
|
|
||||||
|
const tileA = FakeTile.all.find((t) => t.sessionId === 's-a');
|
||||||
|
expect(tileA?.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
expect(app._connectWs).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an active web tab alone when the focused tile is still live (no re-select on every SSE blip)', () => {
|
||||||
|
const app = appWithGrid(true);
|
||||||
|
app.activeWebviewId = 'w1';
|
||||||
|
app._hideWebviewLayer.mockClear();
|
||||||
|
init(app, [...IDS, 's-other']);
|
||||||
|
expect(app._hideWebviewLayer).not.toHaveBeenCalled();
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without the grid, the same reconnect resyncs the main terminal (the guard is what differs)', () => {
|
||||||
|
const app = appWithGrid(false);
|
||||||
|
init(app, [...IDS, 's-other']);
|
||||||
|
expect(app._connectWs).toHaveBeenCalledWith('s-a');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the header connection state follows the tile sockets', () => {
|
||||||
|
it('reads connected only when every live tile socket is open; a stopped tile does not count', () => {
|
||||||
|
const app = appWithGrid(true);
|
||||||
|
// The main socket is parked on purpose; it must not decide the header.
|
||||||
|
app._wsState = 'disconnected';
|
||||||
|
for (const t of FakeTile.all) t._wsReady = true;
|
||||||
|
expect(app._computeConnectionDescriptor().dotClass).toBe('connection-dot connected');
|
||||||
|
|
||||||
|
FakeTile.all[1]._wsReady = false;
|
||||||
|
expect(app._computeConnectionDescriptor().dotClass).toBe('connection-dot reconnecting');
|
||||||
|
|
||||||
|
FakeTile.all[1]._stoppedCode = 4009;
|
||||||
|
expect(app._computeConnectionDescriptor().dotClass).toBe('connection-dot connected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('panes and fonts while the grid is open', () => {
|
||||||
|
it('_focusedPane() is the focused tile even when DOM focus left every terminal', () => {
|
||||||
|
const app = appWithGrid(true);
|
||||||
|
app._noteFocusedTile(null);
|
||||||
|
const pane = app._focusedPane();
|
||||||
|
expect(pane.isPrimary).toBe(false);
|
||||||
|
expect(pane.sessionId).toBe('s-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the main terminal's window-resize timer leaves grid tiles to the grid's own observer", () => {
|
||||||
|
// A window resize fires both: the grid's ResizeObserver refits every tile
|
||||||
|
// (tile-grid.js _scheduleTileGridRefit) and the main terminal's trailing
|
||||||
|
// timer used to refit them again, twelve fit() calls for six tiles and
|
||||||
|
// nothing more sent (measured). The timer lives inside initTerminal(), so
|
||||||
|
// this reads its source; the split's Pane B is still refitted there.
|
||||||
|
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||||
|
const start = src.indexOf('const throttledResize = () => {');
|
||||||
|
const end = src.indexOf("window.addEventListener('resize', throttledResize)", start);
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
expect(end).toBeGreaterThan(start);
|
||||||
|
const calls = src.slice(start, end).match(/this\._forEachTile\?\.\([^;]*;/g) ?? [];
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]).toContain('{ grid: false }');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_forEachTile reaches every grid tile; { grid: false } skips them', () => {
|
||||||
|
const app = appWithGrid(true);
|
||||||
|
const seen: string[] = [];
|
||||||
|
app._forEachTile((t: FakeTile) => seen.push(t.sessionId));
|
||||||
|
expect(seen).toEqual(IDS);
|
||||||
|
const none: string[] = [];
|
||||||
|
app._forEachTile((t: FakeTile) => none.push(t.sessionId), { grid: false });
|
||||||
|
expect(none).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl +/- sizes the tiles (their own per-device font), refitting each, not the main terminal', () => {
|
||||||
|
const app = appWithGrid(true);
|
||||||
|
app.setFontSize = vi.fn();
|
||||||
|
app.increaseFontSize();
|
||||||
|
expect(app.setFontSize).not.toHaveBeenCalled();
|
||||||
|
expect(localStore.get('codeman-tile-font-size')).toBe('15');
|
||||||
|
expect(FakeTile.all.every((t) => t.terminal.options.fontSize === 15 && t.fit.mock.calls.length > 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// test/tile-grid-per-device-setting.test.ts
|
||||||
|
// Port: none (pure static analysis, runs in CI, no browser/server).
|
||||||
|
//
|
||||||
|
// `showTileGridButton` (the header Tiles button, and the Ctrl+Shift+G chord) is
|
||||||
|
// a PER-DEVICE setting, default OFF, with the same three-way rule as
|
||||||
|
// showSplitButton (test/split-pane-per-device-setting.test.ts, read it for the
|
||||||
|
// history): in settings-ui.js's displayKeys merge policy, stripped out of the
|
||||||
|
// object saveAppSettings() PUTs (SettingsUpdateSchema is .strict(), so sending
|
||||||
|
// it would 400 the whole save), and never declared in the schema. Plus the
|
||||||
|
// parts that make the setting reachable: the App Settings chip, its load and
|
||||||
|
// save lines, the mobile default, and the `--hidden` marker's display:none
|
||||||
|
// rule (also covered generically by split-pane-hidden-button-css.test.ts).
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
|
||||||
|
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
const PUBLIC = join(HERE, '../src/web/public');
|
||||||
|
const read = (file: string) => readFileSync(join(PUBLIC, file), 'utf8');
|
||||||
|
|
||||||
|
describe('showTileGridButton stays per-device: display key, stripped from the PUT, absent from the schema', () => {
|
||||||
|
const settingsUi = read('settings-ui.js');
|
||||||
|
const schemas = readFileSync(join(HERE, '../src/web/schemas.ts'), 'utf8');
|
||||||
|
|
||||||
|
it('is in the client-side displayKeys merge policy', () => {
|
||||||
|
const start = settingsUi.indexOf('const displayKeys = new Set([');
|
||||||
|
const displayKeys = settingsUi.slice(start, settingsUi.indexOf('])', start));
|
||||||
|
expect(displayKeys).toContain("'showTileGridButton'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stripped out of the object saveAppSettings() PUTs to the server', () => {
|
||||||
|
expect(settingsUi).toContain('showTileGridButton: _stg,');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is never declared in the .strict() SettingsUpdateSchema', () => {
|
||||||
|
expect(schemas).not.toContain('showTileGridButton');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults OFF on handhelds (and reads as OFF when absent everywhere)', () => {
|
||||||
|
expect(settingsUi).toMatch(/showSplitButton: false,\s*showTileGridButton: false,/);
|
||||||
|
expect(settingsUi).toContain(
|
||||||
|
"document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is saved from its App Settings chip and applied to the header', () => {
|
||||||
|
expect(settingsUi).toContain(
|
||||||
|
"showTileGridButton: document.getElementById('appSettingsShowTileGridButton').checked,"
|
||||||
|
);
|
||||||
|
expect(settingsUi).toContain('this._applyTileGridButtonVisibility?.(showTileGridButton);');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('App Settings search finds Split and Tiles', () => {
|
||||||
|
// The real filter (settings-ui.js) over the real markup: it matches each chip
|
||||||
|
// by its own data-search and its text, so the chips carry their own keywords.
|
||||||
|
const html = read('index.html');
|
||||||
|
const settingsSrc = read('settings-ui.js');
|
||||||
|
const start = settingsSrc.indexOf(' _filterSettings(query) {');
|
||||||
|
const end = settingsSrc.indexOf('\n },', start);
|
||||||
|
const dom = new JSDOM(html);
|
||||||
|
const doc = dom.window.document;
|
||||||
|
const filter = new Function('document', `return ({${settingsSrc.slice(start, end + 4)}});`)(doc) as {
|
||||||
|
_filterSettings(q: string): void;
|
||||||
|
};
|
||||||
|
const shown = (id: string) => !doc.getElementById(id)!.closest('.set-chip')!.classList.contains('set-hit-hidden');
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['tiles', true, false],
|
||||||
|
['tile grid', true, false],
|
||||||
|
['side by side', true, true],
|
||||||
|
['split', false, true],
|
||||||
|
['split pane', false, true],
|
||||||
|
])('"%s": Tiles shown %s, Split shown %s', (query, tiles, split) => {
|
||||||
|
filter._filterSettings(query);
|
||||||
|
expect(shown('appSettingsShowTileGridButton')).toBe(tiles);
|
||||||
|
expect(shown('appSettingsShowSplitButton')).toBe(split);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the Header buttons group names both in its keywords', () => {
|
||||||
|
expect(html).toMatch(/data-search="header buttons [^"]*\bsplit tiles\b[^"]*"/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markup and styles', () => {
|
||||||
|
const html = read('index.html');
|
||||||
|
const css = read('styles.css');
|
||||||
|
|
||||||
|
it('has a header chip in App Settings, beside Split', () => {
|
||||||
|
expect(html).toMatch(
|
||||||
|
/data-preview-order="11\.6"><input type="checkbox" id="appSettingsShowTileGridButton">[\s\S]*?<span>Tiles<\/span>/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ships the header button hidden by its marker class, which has a display:none rule', () => {
|
||||||
|
expect(html).toContain('class="btn-icon-header btn-tile-grid btn-tile-grid--hidden"');
|
||||||
|
expect(css).toMatch(/\.btn-tile-grid--hidden\s*\{\s*display: none !important;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The grid survives a page reload, per device (`codeman:tile-grid`).
|
||||||
|
*
|
||||||
|
* - What is stored: ids, focus, a zoom the user chose and the divider
|
||||||
|
* fractions, never content. Closing the grid (Tiles, a pick outside it)
|
||||||
|
* keeps it remembered as `open: false` for one-click return; the last tile
|
||||||
|
* leaving forgets it. Never written or read in a solo window.
|
||||||
|
* - The restore runs INSIDE handleInit, in place of its single-view
|
||||||
|
* `selectSession(restoreId, { auto: true })`: with a stored open grid the
|
||||||
|
* main terminal never loads on that page load (no select, no socket, no
|
||||||
|
* capture), deleted / detached / duplicate ids are dropped, and the stored
|
||||||
|
* fractions and zoom come back. A narrow window keeps the single view.
|
||||||
|
* - A `#session=` link on load wins, and leaves the stored grid remembered
|
||||||
|
* but closed.
|
||||||
|
* - Leaving the grid invalidates the main terminal's cached content for every
|
||||||
|
* tiled id (snapshot, its localStorage copy, buffer cache).
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { FakeTile, localStore, makeGridApp, resetGridHarness, windowStub, type GridApp } from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c', 's-d'];
|
||||||
|
const KEY = 'codeman:tile-grid';
|
||||||
|
const stored = () => JSON.parse(localStore.get(KEY) ?? 'null');
|
||||||
|
|
||||||
|
/** A fresh page: the app as handleInit leaves it on its FIRST run (gen 1). */
|
||||||
|
function pageLoad(liveIds: string[], setup: (app: GridApp) => void = () => {}) {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app._initGeneration = 0;
|
||||||
|
app.activeSessionId = null;
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
app._fetchTerminalCapture = vi.fn();
|
||||||
|
app._resetAllAppState = vi.fn(() => app.sessions.clear());
|
||||||
|
for (const name of [
|
||||||
|
'_clearTimer',
|
||||||
|
'_updateCjkInputState',
|
||||||
|
'syncSessionOrder',
|
||||||
|
'_loadTabLayout',
|
||||||
|
'cleanupAllFloatingWindows',
|
||||||
|
'startSystemStatsPolling',
|
||||||
|
'stopSystemStatsPolling',
|
||||||
|
'updateCost',
|
||||||
|
]) {
|
||||||
|
app[name] = vi.fn();
|
||||||
|
}
|
||||||
|
app.$ = () => null;
|
||||||
|
setup(app);
|
||||||
|
app.handleInit({
|
||||||
|
sessions: liveIds.map((id) => ({ id, name: id, mode: 'claude', pid: 1 })),
|
||||||
|
scheduledRuns: [],
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('what is stored', () => {
|
||||||
|
it('opening the grid stores ids, focus and fractions, nothing else', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS, { focusedId: 's-b' });
|
||||||
|
expect(stored()).toEqual({
|
||||||
|
v: 1,
|
||||||
|
open: true,
|
||||||
|
ids: IDS,
|
||||||
|
focused: 's-b',
|
||||||
|
zoomed: null,
|
||||||
|
colFr: [1, 1],
|
||||||
|
rowFr: [1, 1],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focus, a zoom by hand and a divider drag are written as they happen', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app.selectSession('s-c');
|
||||||
|
expect(stored().focused).toBe('s-c');
|
||||||
|
app.zoomTile('s-c');
|
||||||
|
expect(stored().zoomed).toBe('s-c');
|
||||||
|
app.zoomTile('s-c');
|
||||||
|
app._tileGrid.colFr = [2, 1];
|
||||||
|
app._persistTileGrid();
|
||||||
|
expect(stored().colFr).toEqual([2, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an automatic zoom (window too small) is not stored: it is worked out again', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app._tileGrid.zoomedId = 's-a';
|
||||||
|
app._tileGrid.autoZoom = true;
|
||||||
|
app._persistTileGrid();
|
||||||
|
expect(stored().zoomed).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closing keeps it remembered (open: false); the last tile leaving forgets it', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
expect(stored()).toMatchObject({ open: false, ids: IDS });
|
||||||
|
|
||||||
|
app.openTileGrid(['s-a']);
|
||||||
|
app.removeTile('s-a');
|
||||||
|
expect(localStore.has(KEY)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a solo window never writes', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
localStore.delete(KEY);
|
||||||
|
app.isSoloWindow = true;
|
||||||
|
app._persistTileGrid();
|
||||||
|
expect(localStore.has(KEY)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('page load with a stored open grid', () => {
|
||||||
|
const storeGrid = (state: Record<string, unknown>) =>
|
||||||
|
localStore.set(KEY, JSON.stringify({ v: 1, open: true, zoomed: null, ...state }));
|
||||||
|
|
||||||
|
it('restores the grid IN PLACE of the single view: the main terminal never loads', () => {
|
||||||
|
storeGrid({ ids: IDS, focused: 's-c' });
|
||||||
|
const app = pageLoad([...IDS, 's-other']);
|
||||||
|
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
expect(app._tileGrid.ids).toEqual(IDS);
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
expect(app.selectSession).not.toHaveBeenCalled();
|
||||||
|
expect(app._connectWs).not.toHaveBeenCalled();
|
||||||
|
expect(app._fetchTerminalCapture).not.toHaveBeenCalled();
|
||||||
|
// Restoring is the app's choice of focus: no idle alert spent.
|
||||||
|
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a session that no longer exists, and stores the cleaned list', () => {
|
||||||
|
storeGrid({ ids: ['s-a', 'gone', 's-b'], focused: 'gone' });
|
||||||
|
const app = pageLoad(IDS);
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
|
expect(stored().ids).toEqual(['s-a', 's-b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('brings back the fractions (same layout only) and a zoom the user chose', () => {
|
||||||
|
storeGrid({ ids: IDS, focused: 's-a', zoomed: 's-b', colFr: [2, 1], rowFr: [1, 1, 1] });
|
||||||
|
const app = pageLoad(IDS);
|
||||||
|
expect(app._tileGrid.colFr).toEqual([2, 1]);
|
||||||
|
// Three row fractions do not fit a 2x2: equal rows.
|
||||||
|
expect(app._tileGrid.rowFr).toEqual([1, 1]);
|
||||||
|
expect(app._tileGrid.zoomedId).toBe('s-b');
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a stored 3x3 (before the cap of 6) comes back as its first six, focus kept, a dropped zoom cleared', () => {
|
||||||
|
const nine = Array.from({ length: 9 }, (_, i) => `n-${i + 1}`);
|
||||||
|
storeGrid({ ids: nine, focused: 'n-5', zoomed: 'n-8', colFr: [2, 1, 1], rowFr: [1, 1, 1] });
|
||||||
|
const app = pageLoad(nine);
|
||||||
|
expect(app._tileGrid.ids).toEqual(nine.slice(0, 6));
|
||||||
|
expect(FakeTile.all.filter((t) => !t._destroyed)).toHaveLength(6);
|
||||||
|
expect(app.activeSessionId).toBe('n-5');
|
||||||
|
expect(app._tileGrid.zoomedId).toBeNull();
|
||||||
|
// 3x2 now: the columns still match, the three stored rows do not.
|
||||||
|
expect(app._tileGrid.colFr).toEqual([2, 1, 1]);
|
||||||
|
expect(app._tileGrid.rowFr).toEqual([1, 1]);
|
||||||
|
expect(stored().ids).toEqual(nine.slice(0, 6));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a stored closed grid leaves the single view, and the Tiles toggle brings it back', () => {
|
||||||
|
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' }));
|
||||||
|
localStore.set('codeman:tile-count', '2');
|
||||||
|
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(app.selectSession).toHaveBeenCalledWith('s-a', { auto: true });
|
||||||
|
|
||||||
|
app.activeSessionId = 's-a';
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the toggle fills a stored grid to the remembered count: its tiles first, in their cells, then tab order', () => {
|
||||||
|
// Default 6 (owner answer 1, superseding decision 8's "exactly the stored set").
|
||||||
|
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' }));
|
||||||
|
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
|
||||||
|
app.activeSessionId = 's-a';
|
||||||
|
app.toggleTileGrid();
|
||||||
|
// The two stay in the first row (2x1 to 2x2 keeps them), the rest join
|
||||||
|
// in tab order: every live session, fewer than the count.
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-b', 's-c', 's-a', 's-d']);
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a stored hole is filled first when the count needs more tiles', () => {
|
||||||
|
// A 2x2 of three with the hole first; the count asks for four.
|
||||||
|
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: [null, 's-b', 's-c', 's-d'], focused: 's-b' }));
|
||||||
|
localStore.set('codeman:tile-count', '4');
|
||||||
|
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
|
||||||
|
app.toggleTileGrid();
|
||||||
|
// s-a joins in the hole (packing would have put it last).
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a reload brings back exactly the stored grid, whatever the count', () => {
|
||||||
|
localStore.set('codeman:tile-count', '6');
|
||||||
|
storeGrid({ ids: ['s-b', 's-c'], focused: 's-c' });
|
||||||
|
const app = pageLoad(IDS);
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a window too narrow for the grid keeps the single view (the stored grid waits)', () => {
|
||||||
|
storeGrid({ ids: IDS, focused: 's-a' });
|
||||||
|
windowStub.innerWidth = 1100;
|
||||||
|
const app = pageLoad(IDS);
|
||||||
|
expect(app._tileGrid?.open ?? false).toBe(false);
|
||||||
|
expect(app.selectSession).toHaveBeenCalled();
|
||||||
|
expect(stored().open).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a solo window never restores it', () => {
|
||||||
|
storeGrid({ ids: IDS, focused: 's-a' });
|
||||||
|
const app = pageLoad(IDS, (a) => {
|
||||||
|
a.isSoloWindow = true;
|
||||||
|
a._applySoloMode = vi.fn();
|
||||||
|
});
|
||||||
|
expect(app._tileGrid?.open ?? false).toBe(false);
|
||||||
|
expect(FakeTile.all).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['another version', JSON.stringify({ v: 2, open: true, ids: IDS })],
|
||||||
|
['not JSON', '{oops'],
|
||||||
|
['not an object', '[1,2]'],
|
||||||
|
])('ignores a stored value that is %s', (_label, raw) => {
|
||||||
|
localStore.set(KEY, raw);
|
||||||
|
const app = pageLoad(IDS);
|
||||||
|
expect(app._tileGrid?.open ?? false).toBe(false);
|
||||||
|
expect(app.selectSession).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a #session= link on load wins, and the grid stays remembered, closed', () => {
|
||||||
|
storeGrid({ ids: IDS, focused: 's-a' });
|
||||||
|
const app = pageLoad(IDS, (a) => {
|
||||||
|
a._urlSessionId = 's-d';
|
||||||
|
});
|
||||||
|
expect(app._tileGrid?.open ?? false).toBe(false);
|
||||||
|
expect(app.selectSession).toHaveBeenCalledWith('s-d', { auto: true, leaveTiles: true });
|
||||||
|
expect(stored()).toMatchObject({ open: false, ids: IDS });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leaving the grid', () => {
|
||||||
|
it("invalidates the main terminal's cached content for every tiled id", () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
for (const id of [...IDS, 's-other']) {
|
||||||
|
app._xtermSnapshots.set(id, 'old');
|
||||||
|
app.terminalBufferCache.set(id, 'old');
|
||||||
|
localStore.set(`codeman-xs-${id}`, 'old');
|
||||||
|
}
|
||||||
|
app.openTileGrid(['s-a', 's-b']);
|
||||||
|
app.closeTileGrid();
|
||||||
|
for (const id of ['s-a', 's-b']) {
|
||||||
|
expect(app._xtermSnapshots.has(id)).toBe(false);
|
||||||
|
expect(app.terminalBufferCache.has(id)).toBe(false);
|
||||||
|
expect(localStore.has(`codeman-xs-${id}`)).toBe(false);
|
||||||
|
}
|
||||||
|
expect(app._xtermSnapshots.get('s-c')).toBe('old');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The tile branch of `selectSession`, and focus moving between tiles.
|
||||||
|
*
|
||||||
|
* With the tile grid open the main terminal is parked, so a selection must
|
||||||
|
* never reach the half of selectSession that cleans it up, replays into it or
|
||||||
|
* reconnects its socket. A TILED session is focused in its tile
|
||||||
|
* (`_selectTiledSession`: an `activeSessionId` change plus `xterm.focus()`, the
|
||||||
|
* same panel refresh as a normal switch). Decision 1: a USER-initiated pick of
|
||||||
|
* a session that is NOT tiled leaves the grid for the single view (the grid is
|
||||||
|
* remembered), and so does an explicit `leaveTiles` (a followed link); an
|
||||||
|
* app-driven pick (`auto: true`) never collapses the grid.
|
||||||
|
*
|
||||||
|
* Focus and alert rules: only a human selection acknowledges an idle alert.
|
||||||
|
* Pressing a tile, Ctrl+Tab / Alt+[ ] (which cycle through the tiles) and a tab
|
||||||
|
* click are human; the delete fallback and a popped-out tile are the app's.
|
||||||
|
*
|
||||||
|
* Real code: constants.js + app.js + terminal-ui.js + tile-grid.js (the shared
|
||||||
|
* vm harness in test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
FakeTile,
|
||||||
|
bySelector,
|
||||||
|
flushFrames,
|
||||||
|
idleCallbacks,
|
||||||
|
localStore,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
type GridApp,
|
||||||
|
tileEl,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c'];
|
||||||
|
|
||||||
|
/** A grid open on IDS (focus s-a), with selectSession REAL and the main terminal's select path tripwired. */
|
||||||
|
function openGrid(): GridApp {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
// The tiles' terminals are built one per frame (_connectTilesPaced).
|
||||||
|
flushFrames();
|
||||||
|
delete app.selectSession; // the prototype's, for real
|
||||||
|
app._cleanupPreviousSession.mockClear();
|
||||||
|
app.markIdleAlertSeen.mockClear();
|
||||||
|
// Anything past the tile branch: the main terminal's own select path.
|
||||||
|
app._shouldFocusTerminalForTabSwitch = vi.fn(() => false);
|
||||||
|
app._setTerminalLoadState = vi.fn(() => {
|
||||||
|
throw new Error('the main terminal select path ran');
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selectSession with the grid open', () => {
|
||||||
|
it('focuses a tiled session in its tile and never touches the main terminal', async () => {
|
||||||
|
const app = openGrid();
|
||||||
|
await app.selectSession('s-b');
|
||||||
|
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
expect(app._tileGrid.focusedId).toBe('s-b');
|
||||||
|
expect(app._cleanupPreviousSession).not.toHaveBeenCalled();
|
||||||
|
expect(app._connectWs).not.toHaveBeenCalled();
|
||||||
|
expect(app.sendResize).not.toHaveBeenCalled();
|
||||||
|
expect(FakeTile.all.find((t) => t.sessionId === 's-b')?.terminal.focus).toHaveBeenCalled();
|
||||||
|
expect(app._focusedPane().sessionId).toBe('s-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focus changes leave at most one glow listener on a tab, and the glow runs again once it ended', async () => {
|
||||||
|
// On every skin but OG the glow is `animation: none`: animationend never
|
||||||
|
// fires, and every focus used to add one more once-listener to the tab.
|
||||||
|
const app = openGrid();
|
||||||
|
const tab = new FakeEl();
|
||||||
|
tab.className = 'session-tab active';
|
||||||
|
bySelector.set('.session-tab.active[data-id="s-b"]', tab);
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await app.selectSession('s-b');
|
||||||
|
await app.selectSession('s-a');
|
||||||
|
}
|
||||||
|
expect(tab.classList.contains('tab-glow')).toBe(true);
|
||||||
|
expect(tab.listeners.animationend).toHaveLength(1);
|
||||||
|
|
||||||
|
// OG: the animation ends, the class goes, and the next focus glows again.
|
||||||
|
tab.dispatch('animationend');
|
||||||
|
tab.listeners.animationend = [];
|
||||||
|
expect(tab.classList.contains('tab-glow')).toBe(false);
|
||||||
|
await app.selectSession('s-b');
|
||||||
|
expect(tab.classList.contains('tab-glow')).toBe(true);
|
||||||
|
expect(tab.listeners.animationend).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a user-initiated focus acknowledges the idle alert; an `auto` one does not', async () => {
|
||||||
|
const app = openGrid();
|
||||||
|
await app.selectSession('s-b');
|
||||||
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-b');
|
||||||
|
|
||||||
|
app.markIdleAlertSeen.mockClear();
|
||||||
|
await app.selectSession('s-c', { auto: true });
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the same deferred panel refresh as a normal switch, tagged with its own generation', async () => {
|
||||||
|
const app = openGrid();
|
||||||
|
idleCallbacks.length = 0;
|
||||||
|
await app.selectSession('s-b');
|
||||||
|
const genB = app._selectGeneration;
|
||||||
|
expect(app._refreshSessionPanels).not.toHaveBeenCalled(); // deferred
|
||||||
|
for (const cb of idleCallbacks.splice(0)) cb();
|
||||||
|
expect(app._refreshSessionPanels).toHaveBeenCalledWith('s-b', genB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an `auto` selection of a session that is NOT tiled leaves the grid open and changes nothing', async () => {
|
||||||
|
const app = openGrid();
|
||||||
|
await app.selectSession('s-other', { auto: true });
|
||||||
|
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
|
expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a user-initiated selection of a session that is NOT tiled leaves the grid, remembered', async () => {
|
||||||
|
const app = openGrid();
|
||||||
|
let activeAtCleanup: unknown = 'unset';
|
||||||
|
app._cleanupPreviousSession = vi.fn(() => {
|
||||||
|
activeAtCleanup = app.activeSessionId;
|
||||||
|
});
|
||||||
|
app._setTerminalLoadState = vi.fn();
|
||||||
|
app._clearTerminalLoadState = vi.fn();
|
||||||
|
app._renderHistoryTruncationBanner = vi.fn();
|
||||||
|
app._isStaleSelect = vi.fn(() => true); // stop right after the switch itself
|
||||||
|
await app.selectSession('s-other').catch(() => {});
|
||||||
|
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 1)).toBe(true);
|
||||||
|
expect(JSON.parse(localStore.get('codeman:tile-grid')!)).toMatchObject({ open: false, ids: IDS, focused: 's-a' });
|
||||||
|
// The parked terminal's stale content must not be saved as s-a's snapshot.
|
||||||
|
expect(activeAtCleanup).toBeNull();
|
||||||
|
expect(app.activeSessionId).toBe('s-other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('`leaveTiles` makes even an `auto` selection leave the grid (a followed link is navigation)', async () => {
|
||||||
|
const app = openGrid();
|
||||||
|
app._setTerminalLoadState = vi.fn();
|
||||||
|
app._clearTerminalLoadState = vi.fn();
|
||||||
|
app._renderHistoryTruncationBanner = vi.fn();
|
||||||
|
app._isStaleSelect = vi.fn(() => true);
|
||||||
|
await app.selectSession('s-other', { auto: true, leaveTiles: true }).catch(() => {});
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a followed `#session=` link passes leaveTiles, and a tiled one just focuses its tile', async () => {
|
||||||
|
const app = openGrid();
|
||||||
|
app._urlSessionId = 's-c';
|
||||||
|
app._selectUrlSession();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('moving focus between tiles', () => {
|
||||||
|
it('pressing a tile is a human selection of its session', async () => {
|
||||||
|
const app = openGrid();
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
const tileB = tileEl('s-b');
|
||||||
|
tileB?.dispatch('pointerdown');
|
||||||
|
expect(app.selectSession).toHaveBeenCalledWith('s-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Tab / Alt+] and Alt+[ cycle through the tiles (wrapping), user-initiated, and only the tiles', () => {
|
||||||
|
const app = openGrid();
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
// From the last tile, the next one wraps to the first tile; the tab order
|
||||||
|
// would have wrapped to s-other (first in sessionOrder, not tiled).
|
||||||
|
app.activeSessionId = 's-c';
|
||||||
|
app.nextSession();
|
||||||
|
expect(app.selectSession.mock.calls).toEqual([['s-a']]);
|
||||||
|
app.selectSession.mockClear();
|
||||||
|
app.activeSessionId = 's-a';
|
||||||
|
app.prevSession();
|
||||||
|
expect(app.selectSession.mock.calls).toEqual([['s-c']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without the grid they walk the tab order as before', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
app.nextSession();
|
||||||
|
expect(app.selectSession).toHaveBeenLastCalledWith('s-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tiled session popped out to its own window leaves the grid; a neighbour takes focus', () => {
|
||||||
|
const app = openGrid();
|
||||||
|
app.$ = () => null;
|
||||||
|
app._markDetached('s-a', true);
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('going Home leaves the grid, remembered', () => {
|
||||||
|
const app = openGrid();
|
||||||
|
app.goHome();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(JSON.parse(localStore.get('codeman:tile-grid')!)).toMatchObject({ open: false, ids: IDS });
|
||||||
|
expect(app.activeSessionId).toBeNull();
|
||||||
|
expect(app.showWelcome).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user