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
|
||||
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`)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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? }
|
||||
// — 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
|
||||
// .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
|
||||
}
|
||||
```
|
||||
@@ -57,10 +59,14 @@ interface CliEntry {
|
||||
|
||||
### 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.
|
||||
|
||||
`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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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`
|
||||
(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
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
**Author**: Claude (session with Tim), 2026-09-15
|
||||
**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
|
||||
|
||||
Codeman's terminal area shows exactly one active session (pane) at a time —
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,13 +9,16 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
|
||||
| Shortcut | Action |
|
||||
| ------------------------------- | --------------------------------------------------------------- |
|
||||
| `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. |
|
||||
| `Alt+[` / `Alt+]` | Previous / next tab. |
|
||||
| `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. |
|
||||
| `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
|
||||
|
||||
| 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.
|
||||
|
||||
## 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
|
||||
|
||||
| Shortcut | Action |
|
||||
|
||||
@@ -131,7 +131,7 @@ the tmux server or rebooting the machine.
|
||||
| To do this | Do that |
|
||||
| ------------------------- | ------------------------------------------------------------------- |
|
||||
| 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 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:
|
||||
|
||||
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,
|
||||
Ultracode Windows, Cron.
|
||||
|
||||
@@ -69,11 +69,11 @@ by folder** (per device, on by default) shows changed files under collapsed fold
|
||||
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.
|
||||
**Header Stats Style** picks how the system stats and plan usage are drawn: *Tiles*
|
||||
(default; label over value with a bar underneath), *Compact* (two pills with a ring beside every value) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device.
|
||||
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
|
||||
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
|
||||
every session or only the active tab.
|
||||
@@ -88,9 +88,7 @@ every session or only the active tab.
|
||||
| Interface Language | English or Simplified Chinese. Per device. |
|
||||
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
|
||||
| Tab Orientation | Keeps the header list but turns the strip vertical beside the terminal, resizable, with detailed rows by default. Desktop and tablet only. |
|
||||
| Tab Layout | *By state* (default): a row each for needs you, waiting, working and idle, sections in the rail and sidebar. *By case*: one box per case. *Ledger*: an aligned column grid. *Classic*: the single list as before. See [The Dashboard](The-Dashboard#tab-layouts). |
|
||||
| State Order | For *By state*: needs you on top (default) or at the bottom, right above the terminal. |
|
||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With *By state* or *By case* it orders the rows inside each section. |
|
||||
| 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. |
|
||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
|
||||
|
||||
@@ -27,7 +27,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
|
||||
|
||||
| Layout | Behaviour |
|
||||
| -------------------- | --------------------------------------------------------------------------------- |
|
||||
| **Header tab strip** | The default. On desktop it is one row per state by default (see [Tab layouts](#tab-layouts)); it scrolls sideways on a phone. |
|
||||
| **Header tab strip** | The default. Wraps to a second row on desktop, scrolls sideways on a phone. |
|
||||
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
|
||||
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. Desktop and tablet only. |
|
||||
|
||||
@@ -35,40 +35,6 @@ It is the same list either way, just re-hosted: tab order, drag-to-reorder, the
|
||||
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
|
||||
per device, so a sidebar on your desktop does not force one onto your phone.
|
||||
|
||||
## Tab layouts
|
||||
|
||||
**App Settings → Appearance → Tabs → Tab Layout** picks how the tabs are arranged. Per device.
|
||||
|
||||
| Layout | What it does |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------ |
|
||||
| **By state** (default) | Groups the tabs by what each session needs from you (below). |
|
||||
| **By case** | One box per case, labelled with the case and its tab count. Inside a box, `w75-api-gateway` reads just `w75`. A case with one tab gets a box with a colour swatch. |
|
||||
| **Ledger** | The same list on an aligned column grid: equal cells, monospace names, a coloured bar on the left of each cell instead of the dot (yellow waiting, red needs you). Desktop header only. |
|
||||
| **Classic** | The single list in tab order, as before. |
|
||||
|
||||
**By state** groups the tabs like this, most urgent on top:
|
||||
|
||||
| Group | Who is in it |
|
||||
| ------------- | ----------------------------------------------------------------------------------- |
|
||||
| **Needs you** | Red: a question or permission prompt is blocking the agent. A failed session too. |
|
||||
| **Waiting** | Yellow: the agent finished its turn and is waiting for your next prompt. |
|
||||
| **Working** | A turn is running. |
|
||||
| **Idle** | Everything quiet, including ended sessions, agents that exited inside their pane, and web tabs. |
|
||||
|
||||
In the header each group is a row with its name and count on the left (Idle, the quiet
|
||||
default, carries no label); a group with more tabs than fit on one line continues on the
|
||||
next line. **State Order → Needs you at the
|
||||
bottom** turns the rows the other way up, so the needs-you row sits right above the
|
||||
terminal. Empty groups are not shown. These are the same states the phone overview and the
|
||||
desktop home rail use, and tabs move between groups on their own as their state changes.
|
||||
|
||||
Both groupings also apply to the vertical rail and the left sidebar, as labelled sections.
|
||||
Inside a group or a box tabs keep your tab order (on a rail sorted *By activity*, the
|
||||
activity order), and the `Alt+1` to `Alt+9` numbers never change. Dragging reorders tabs
|
||||
within a group or box. On a phone the strip stays a single scrolling row in group order,
|
||||
without labels or boxes. If you have named tab groups in the vertical rail, those take
|
||||
precedence there.
|
||||
|
||||
## Session tabs
|
||||
|
||||
One tab per session, in your order, and that order syncs across your devices.
|
||||
@@ -98,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) |
|
||||
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
|
||||
| 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`) |
|
||||
|
||||
Tabs can also be dragged to reorder.
|
||||
@@ -136,7 +102,7 @@ The right side of the header. Almost all of these are off until you enable them
|
||||
| ---------------------- | ------------------ | ------------------------------------------------------------------------------- |
|
||||
| Connection dot | Always on | SSE connection health. Green is connected. |
|
||||
| Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. |
|
||||
| CPU / MEM | On | Server resource use. Drawn as tiles by default; see Header Stats Style below. |
|
||||
| CPU / MEM bars | On | Server resource use. |
|
||||
| File Viewer | On | Toggles the file browser panel. |
|
||||
| Settings gear | Always on | App Settings. |
|
||||
| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. |
|
||||
@@ -152,22 +118,10 @@ The right side of the header. Almost all of these are off until you enable them
|
||||
| Cron ⏰ | Off | Scheduled jobs. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| Admin panel | Multi-user only | User administration. |
|
||||
|
||||
### Header Stats Style
|
||||
|
||||
The connection readout, CPU, MEM and the plan usage windows can be drawn three ways
|
||||
(**App Settings → Header & Panels → Header Stats Style**, per device, desktop only):
|
||||
|
||||
| Style | Look |
|
||||
| -------------- | -------------------------------------------------------------------------------------- |
|
||||
| **Tiles** | The default. One small tile each (`WS live`, `CPU 22%`, `MEM 14.4G`, `5H 28%`, `7D 35%`): label over value, a thin bar underneath, no icons. |
|
||||
| **Compact** | Two slim pills, `WS · CPU · MEM` and the plan windows, with a small ring beside every value. Hands the tabs back the most room. |
|
||||
| **As before** | The bars and the `5H · 7D` chip, exactly as they were. |
|
||||
|
||||
Hiding System Stats or Plan Usage still hides them in every style.
|
||||
|
||||
New header controls never appear on phones. Phone layout is deliberately minimal and is
|
||||
covered in [Mobile Guide](Mobile-Guide).
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -11,6 +11,7 @@
|
||||
**Using it**
|
||||
|
||||
- [The Dashboard](The-Dashboard)
|
||||
- [Tile Grid](Tile-Grid)
|
||||
- [Agent CLIs](Agent-CLIs)
|
||||
- [Custom Model Endpoints](Custom-Model-Endpoints)
|
||||
- [Working With Files](Working-With-Files)
|
||||
|
||||
@@ -119,3 +119,16 @@ export function compileVersionRegex(source: string): RegExp | 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 { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
|
||||
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.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. */
|
||||
const cliId = z
|
||||
@@ -370,6 +370,43 @@ const capabilitiesSchema = z
|
||||
model: z
|
||||
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
|
||||
.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
|
||||
.array(
|
||||
z
|
||||
|
||||
@@ -622,6 +622,16 @@ const CODEX: CliEntry = {
|
||||
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
|
||||
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 `•`/`›`/`⚠`
|
||||
// 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,
|
||||
@@ -1222,6 +1232,35 @@ const DEEPSEEK: CliEntry = {
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||
// Model is NOT a session field for dsh — it is a profile composition entry.
|
||||
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
|
||||
// launcher's own default, `workspace-write`, which already asks. Clamping to
|
||||
// `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`). */
|
||||
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. */
|
||||
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
|
||||
|
||||
@@ -462,6 +465,41 @@ export interface CliCapabilities {
|
||||
statusLineTelemetry: boolean;
|
||||
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
|
||||
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.
|
||||
* 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 { basename } from 'node:path';
|
||||
import { execSync, execFileSync } from 'node:child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as pty from 'node-pty';
|
||||
@@ -137,7 +138,18 @@ import {
|
||||
sanitizeAttachmentHistory,
|
||||
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
||||
} 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 { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
||||
@@ -550,6 +562,27 @@ export class Session extends EventEmitter {
|
||||
private _watchingWindow = WATCHING_TAIL_LINES;
|
||||
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
|
||||
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 _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
||||
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
||||
@@ -807,6 +840,8 @@ export class Session extends EventEmitter {
|
||||
claudeSessionChain?: string[];
|
||||
/** Restored agent-exit observation for this session's pane (see `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. */
|
||||
discoveredMuxSession?: boolean;
|
||||
/** 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
|
||||
// browser panel arms and disarms — see `startPaneExitWatcher`.
|
||||
this.setPaneExit(config.paneExit);
|
||||
this._reportedModel = restoredReportedModel(config.displayModel) ?? null;
|
||||
// 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
|
||||
// both the id and the saved parent come from disk.
|
||||
@@ -1267,6 +1303,9 @@ export class Session extends EventEmitter {
|
||||
} finally {
|
||||
this._paneLifecycleOps--;
|
||||
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,
|
||||
advisorModel: this._advisorModel,
|
||||
customModel: this.customModel,
|
||||
displayModel: this.displayModel,
|
||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||
// intent before restarting a crash-looped session. Deliberately NOT restored
|
||||
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
||||
@@ -3134,9 +3174,130 @@ export class Session extends EventEmitter {
|
||||
this._lastPaneProbeWorking =
|
||||
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
|
||||
this._readWatching(text);
|
||||
this._readScreenModel(text);
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -641,6 +641,24 @@ export interface CustomModelSelection {
|
||||
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
|
||||
* 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`.
|
||||
*/
|
||||
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. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
/**
|
||||
|
||||
@@ -156,6 +156,11 @@ const STOCK_NON_INTERACTIVE_PROFILES = new Map<string, DeepSeekProfileKind>([
|
||||
/** Profile directory names that are not profiles. */
|
||||
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 {
|
||||
const haystack = [name, ...bundles].join(' ');
|
||||
// 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 {
|
||||
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 rawBundles = parsed?.dsh?.profile?.bundles;
|
||||
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[];
|
||||
try {
|
||||
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);
|
||||
} catch {
|
||||
return [];
|
||||
|
||||
+437
-549
File diff suppressed because it is too large
Load Diff
+520
-210
@@ -658,194 +658,6 @@ function sortSessionsByActivity(rows) {
|
||||
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
|
||||
}
|
||||
|
||||
// Tab grouping by state (`tabArrangement: 'state'`, Discussion #426 option C).
|
||||
//
|
||||
// The tab list answers "who wants me?" the way the home screens do: a row (the
|
||||
// header strip) or a section (the flat side rail, the sidebar) per state, most
|
||||
// urgent on top. The states are the home screens' own, from
|
||||
// `_mobileOverviewState()` (mobile-overview.js); this only folds the six into
|
||||
// four groups a strip can hold:
|
||||
//
|
||||
// needs red: a permission or question dialog is blocking the agent. A
|
||||
// failed session joins it, since it also needs a human and the home
|
||||
// screens rank it right below.
|
||||
// waiting yellow: the agent finished its turn and is waiting on you.
|
||||
// working a turn is running.
|
||||
// idle everything quiet: idle, ended, and an agent that exited inside a
|
||||
// live pane (#446), which may still read as working on screen but is
|
||||
// running nothing. Web tabs close the group.
|
||||
//
|
||||
// Applied as the flex `order` property, never by reordering the DOM, the same
|
||||
// design as the sorted rail (`_tabRailSortOrder`, app.js): `#sessionTabs` stays
|
||||
// in `sessionOrder`, so Alt+N, drag-and-drop and the keyboard walk keep reading
|
||||
// the list they always read, and a state change moves one inline style instead
|
||||
// of rebuilding the strip. Each group owns a band of `TAB_TRIAGE_STRIDE` order
|
||||
// values: its heading at the start of the band, its rows after it, its web tabs
|
||||
// after those and the line break that ends the header row at the very end.
|
||||
//
|
||||
// Pure: no DOM, no `this`. Unit-tested in test/tab-triage.test.ts.
|
||||
// `quiet` groups keep their heading element (it anchors the row and holds the
|
||||
// row's place beside the brand) but draw no text: everything quiet is the
|
||||
// default state of a tab, so naming it only adds noise.
|
||||
const TAB_TRIAGE_GROUPS = [
|
||||
{ key: 'needs', label: 'Needs you' },
|
||||
{ key: 'waiting', label: 'Waiting' },
|
||||
{ key: 'working', label: 'Working' },
|
||||
{ key: 'idle', label: 'Idle', quiet: true },
|
||||
];
|
||||
|
||||
const TAB_TRIAGE_GROUP_OF_STATE = {
|
||||
needs: 'needs',
|
||||
error: 'needs',
|
||||
waiting: 'waiting',
|
||||
working: 'working',
|
||||
idle: 'idle',
|
||||
done: 'idle',
|
||||
};
|
||||
|
||||
const TAB_TRIAGE_STRIDE = 10000;
|
||||
/** Offset of a group's web tabs inside its band, past any plausible session count. */
|
||||
const TAB_TRIAGE_WEB_OFFSET = 5000;
|
||||
|
||||
/**
|
||||
* Which group a session belongs to.
|
||||
* @param {string} state a `_mobileOverviewState()` value
|
||||
* @param {boolean} exited the agent inside the pane has exited (`_mobileOverviewExit()` non-null)
|
||||
* @returns {'needs'|'waiting'|'working'|'idle'}
|
||||
*/
|
||||
function tabTriageGroupFor(state, exited) {
|
||||
const group = TAB_TRIAGE_GROUP_OF_STATE[state] || 'idle';
|
||||
return exited && group === 'working' ? 'idle' : group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order values and visible groups for one pass.
|
||||
*
|
||||
* @param {Array<{id: string, state: string, exited?: boolean, pos?: number}>} rows
|
||||
* live sessions; `pos` ranks a row inside its group (tab order on the header
|
||||
* strip, the activity sort's position on a sorted rail). Rows without one keep
|
||||
* the order they were passed in.
|
||||
* @param {Array<string>} webviewIds open web tabs, in their own tab order
|
||||
* @param {{reverse?: boolean}} [options] `reverse` puts the groups the other
|
||||
* way up (`tabStateOrder: 'urgent-last'`): idle first, needs you last, for a
|
||||
* strip read from the bottom. Rows inside a group keep their order.
|
||||
* @returns {{
|
||||
* order: Map<string, number>,
|
||||
* webOrder: Map<string, number>,
|
||||
* groups: Array<{key: string, label: string, quiet: boolean, count: number, headOrder: number, breakOrder: number}>
|
||||
* }} `groups` lists only the non-empty groups, in display order (most urgent
|
||||
* first, or last with `reverse`).
|
||||
*/
|
||||
function computeTabTriageLayout(rows, webviewIds, options) {
|
||||
const list = Array.isArray(rows) ? rows : [];
|
||||
const webs = Array.isArray(webviewIds) ? webviewIds : [];
|
||||
const sequence = options && options.reverse ? TAB_TRIAGE_GROUPS.slice().reverse() : TAB_TRIAGE_GROUPS;
|
||||
const baseOf = {};
|
||||
const counts = {};
|
||||
sequence.forEach((group, i) => {
|
||||
baseOf[group.key] = (i + 1) * TAB_TRIAGE_STRIDE;
|
||||
counts[group.key] = 0;
|
||||
});
|
||||
|
||||
const placed = list
|
||||
.filter((row) => row && typeof row.id === 'string')
|
||||
.map((row, i) => ({
|
||||
id: row.id,
|
||||
group: tabTriageGroupFor(row.state, !!row.exited),
|
||||
pos: Number.isFinite(row.pos) ? row.pos : i,
|
||||
index: i,
|
||||
}));
|
||||
const byGroup = {};
|
||||
for (const row of placed) (byGroup[row.group] = byGroup[row.group] || []).push(row);
|
||||
|
||||
const order = new Map();
|
||||
for (const key of Object.keys(byGroup)) {
|
||||
// Stable: equal positions keep the order the caller passed.
|
||||
byGroup[key].sort((a, b) => a.pos - b.pos || a.index - b.index);
|
||||
byGroup[key].forEach((row, i) => order.set(row.id, baseOf[key] + 1 + i));
|
||||
counts[key] = byGroup[key].length;
|
||||
}
|
||||
|
||||
const webOrder = new Map();
|
||||
webs.forEach((id, i) => {
|
||||
if (typeof id === 'string' && id) webOrder.set(id, baseOf.idle + TAB_TRIAGE_WEB_OFFSET + i);
|
||||
});
|
||||
counts.idle += webOrder.size;
|
||||
|
||||
const groups = sequence.filter((group) => counts[group.key] > 0).map((group) => ({
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
quiet: !!group.quiet,
|
||||
count: counts[group.key],
|
||||
headOrder: baseOf[group.key],
|
||||
breakOrder: baseOf[group.key] + TAB_TRIAGE_STRIDE - 1,
|
||||
}));
|
||||
|
||||
return { order, webOrder, groups };
|
||||
}
|
||||
|
||||
// Tab clusters by case (`tabArrangement: 'case'`, Discussion #426 option A).
|
||||
//
|
||||
// One cluster per case, in the order the case first appears in the tab order,
|
||||
// so the strip keeps the user's arrangement at the case level. Inside a cluster
|
||||
// with two or more tabs the `-<case>` part of a generated `w<n>-<case>` name is
|
||||
// redundant and is hidden (`tabClusterNameSplit()`), which is what lets 18 tabs
|
||||
// read as 7 things. A case with one tab is still its own (unlabelled) box.
|
||||
//
|
||||
// Cluster colours come from the session palette (`--session-<colour>`) by a
|
||||
// stable hash of the case key, so a case keeps its colour across reloads and
|
||||
// devices without anything being stored.
|
||||
//
|
||||
// Pure: no DOM, no `this`. Unit-tested in test/tab-clusters.test.ts.
|
||||
const TAB_CLUSTER_COLORS = ['blue', 'green', 'purple', 'orange', 'pink', 'yellow', 'red'];
|
||||
|
||||
/** Palette colour for a cluster key: a djb2 hash, so the same key always gets the same colour. */
|
||||
function tabClusterColorFor(key) {
|
||||
const text = String(key || '');
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
|
||||
return TAB_CLUSTER_COLORS[Math.abs(hash) % TAB_CLUSTER_COLORS.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group tabs by case.
|
||||
* @param {Array<{id: string, key: string, label: string}>} rows live sessions in
|
||||
* tab order; `key` identifies the case (its path), `label` names it
|
||||
* @returns {Array<{key: string, label: string, color: string, ids: string[]}>}
|
||||
* clusters in first-appearance order, members in tab order
|
||||
*/
|
||||
function computeTabClusters(rows) {
|
||||
const clusters = [];
|
||||
const byKey = new Map();
|
||||
for (const row of Array.isArray(rows) ? rows : []) {
|
||||
if (!row || typeof row.id !== 'string') continue;
|
||||
const key = typeof row.key === 'string' && row.key ? row.key : `session:${row.id}`;
|
||||
let cluster = byKey.get(key);
|
||||
if (!cluster) {
|
||||
cluster = { key, label: typeof row.label === 'string' ? row.label : '', color: tabClusterColorFor(key), ids: [] };
|
||||
byKey.set(key, cluster);
|
||||
clusters.push(cluster);
|
||||
}
|
||||
cluster.ids.push(row.id);
|
||||
}
|
||||
return clusters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a generated `w<n>-<case>` / `s<n>-<case>` name into the part a cluster
|
||||
* shows and the case suffix it hides, or null when the name is anything else
|
||||
* (a custom name, a described `w3-x: fix login`, another case's name). Case is
|
||||
* compared case-insensitively; the hidden part keeps its original spelling so
|
||||
* the full name is still in the DOM.
|
||||
* @returns {{shown: string, hidden: string}|null}
|
||||
*/
|
||||
function tabClusterNameSplit(name, label) {
|
||||
if (typeof name !== 'string' || typeof label !== 'string' || !label) return null;
|
||||
const match = name.match(/^([ws]\d+)(-.+)$/);
|
||||
if (!match) return null;
|
||||
return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
|
||||
}
|
||||
|
||||
// Terminal font stack — the single source for every xterm surface (the main
|
||||
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
|
||||
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
|
||||
@@ -1171,18 +983,6 @@ if (typeof window !== 'undefined') {
|
||||
compare: compareSessionActivity,
|
||||
sort: sortSessionsByActivity,
|
||||
};
|
||||
window.CodemanTabClusters = {
|
||||
COLORS: TAB_CLUSTER_COLORS,
|
||||
colorFor: tabClusterColorFor,
|
||||
compute: computeTabClusters,
|
||||
nameSplit: tabClusterNameSplit,
|
||||
};
|
||||
window.CodemanTabTriage = {
|
||||
GROUPS: TAB_TRIAGE_GROUPS,
|
||||
STRIDE: TAB_TRIAGE_STRIDE,
|
||||
groupFor: tabTriageGroupFor,
|
||||
layout: computeTabTriageLayout,
|
||||
};
|
||||
window.CodemanInputLimit = {
|
||||
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
|
||||
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
|
||||
@@ -1812,27 +1612,459 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
|
||||
const result = [];
|
||||
for (const id of sessionOrder) {
|
||||
if (id === excludeId) continue;
|
||||
// A detached (popped-out) session's own window already yields its PTY
|
||||
// size (see sendResize's detachedElsewhere guard in terminal-ui.js) —
|
||||
// Pane B's SplitTerminalPane._sendResize() has no such check, so letting
|
||||
// one into the picker put its detached window and Pane B in a fight over
|
||||
// the same PTY's dimensions.
|
||||
// A detached (popped-out) session's own window owns its PTY size (see
|
||||
// sendResize's detachedElsewhere guard in terminal-ui.js;
|
||||
// TerminalTile._sendResize() stands aside the same way), so Pane B could
|
||||
// only show it at a size it cannot set.
|
||||
if (detachedIds?.has?.(id)) continue;
|
||||
const session = sessions.get(id);
|
||||
if (!session) continue;
|
||||
// A session with no PTY attached (exited CLI, a crash-looped session
|
||||
// whose breaker tripped, a restore that failed to re-attach) has nothing
|
||||
// reading its tmux pane. SplitTerminalPane never does selectSession()'s
|
||||
// re-attach POST, so its socket would open onto a pane nothing feeds:
|
||||
// no terminal events, and Session.write() silently drops every keystroke
|
||||
// with no ack either way (Pane B sends no `seq`), so the loss is
|
||||
// invisible — the healthy socket never trips the disconnect banner.
|
||||
// reading its tmux pane, and the split never does selectSession()'s
|
||||
// re-attach POST: Pane B would open a healthy-looking socket onto a pane
|
||||
// that nothing feeds and nothing reads.
|
||||
if (session.pid === null) continue;
|
||||
result.push({ id, label: session.name || 'Session' });
|
||||
}
|
||||
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 ──────────────────────────────────────────────────────
|
||||
//
|
||||
// iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to
|
||||
@@ -2069,7 +2301,59 @@ function sessionIdFromFragment(hash) {
|
||||
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') {
|
||||
window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||
window.CodemanTerminalLines = { terminalLogicalLine };
|
||||
@@ -2079,6 +2363,32 @@ if (typeof window !== 'undefined') {
|
||||
buildSplitPickerSessions,
|
||||
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.CodemanFetchDeadline = {
|
||||
terminalFetchDeadlineMs,
|
||||
|
||||
+174
-1
@@ -96,6 +96,80 @@
|
||||
'Split: close the second session': '分屏:关闭第二个会话',
|
||||
'Close split': '关闭分屏',
|
||||
'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 智能体',
|
||||
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
|
||||
Notifications: '通知',
|
||||
@@ -221,7 +295,6 @@
|
||||
Running: '运行中',
|
||||
Idle: '空闲',
|
||||
Working: '工作中',
|
||||
Waiting: '等待中',
|
||||
Today: '今天',
|
||||
Home: '主页',
|
||||
Local: '本地',
|
||||
@@ -952,6 +1025,17 @@
|
||||
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) {
|
||||
const patterns = [
|
||||
[/^(\d+) tokens?$/, (_m, count) => `${count} 个 Token`],
|
||||
@@ -976,6 +1060,71 @@
|
||||
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
|
||||
(_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) {
|
||||
const match = source.match(pattern);
|
||||
@@ -1034,6 +1183,23 @@
|
||||
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) {
|
||||
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
||||
return shouldSkip(node) || Boolean(element?.closest(USER_TEXT_SELECTOR));
|
||||
@@ -1129,6 +1295,13 @@
|
||||
observer = new MutationObserver((mutations) => {
|
||||
if (applying) return;
|
||||
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 === 'attributes') translateAttributes(mutation.target);
|
||||
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.
|
||||
// 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).
|
||||
_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 terminal = target.terminal || this.terminal;
|
||||
const sessionId = target.sessionId || this.activeSessionId;
|
||||
|
||||
// Create a hidden contenteditable div to receive the paste
|
||||
const trap = document.createElement('div');
|
||||
@@ -93,11 +98,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
setTimeout(function() {
|
||||
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
||||
// Refocus the terminal
|
||||
if (self.terminal) self.terminal.focus();
|
||||
if (terminal) terminal.focus();
|
||||
}, 0);
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
self._uploadAndInsertImages(imageFiles);
|
||||
self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
|
||||
} else {
|
||||
// No image -- route text through xterm's paste() so bracketed-paste
|
||||
// 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
|
||||
// prompt-injection defenses.
|
||||
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.
|
||||
* 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 = {}) {
|
||||
const sessionId = this.activeSessionId;
|
||||
const sessionId = options.sessionId || this.activeSessionId;
|
||||
if (!sessionId) return [];
|
||||
|
||||
let files = Array.from(fileList || []);
|
||||
@@ -179,8 +185,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const paths = results.filter(Boolean);
|
||||
if (paths.length > 0 && options.insert !== false) {
|
||||
// Insert all paths in one shot, space-separated, in selection order.
|
||||
await this.sendInput(paths.join(' '));
|
||||
// Insert all paths in one shot, space-separated, in selection order, into
|
||||
// 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.
|
||||
|
||||
+28
-43
@@ -65,7 +65,7 @@
|
||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||
here and boot will contradict this value, animating the drawer open by
|
||||
itself on every load between 768 and 1023px. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='case'||T==='ledger'||T==='classic')?T:'state';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='state';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';}</script>
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -154,7 +154,6 @@
|
||||
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
||||
<span class="connection-dot" id="connectionDot"></span>
|
||||
<span class="connection-text" id="connectionText"></span>
|
||||
<span class="connection-tile"><span class="connection-tile-label" id="connectionTileLabel"></span><span class="connection-tile-value" id="connectionTileValue"></span></span>
|
||||
</div>
|
||||
<div class="header-font-controls">
|
||||
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)" aria-label="Decrease font size">A-</button>
|
||||
@@ -163,7 +162,6 @@
|
||||
</div>
|
||||
<div class="header-system-stats" id="headerSystemStats" title="System resource usage">
|
||||
<div class="stat-item">
|
||||
<span class="stat-ring" id="statCpuRing" aria-hidden="true"></span>
|
||||
<span class="stat-label">CPU</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill stat-bar-cpu" id="statCpuBar"></div>
|
||||
@@ -171,7 +169,6 @@
|
||||
<span class="stat-value" id="statCpu">--%</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-ring" id="statMemRing" aria-hidden="true"></span>
|
||||
<span class="stat-label">MEM</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill stat-bar-mem" id="statMemBar"></div>
|
||||
@@ -192,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>
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</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-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>
|
||||
<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;">
|
||||
@@ -438,6 +436,12 @@
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
</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
|
||||
terminal while a web tab is active. Frames stay mounted while hidden so
|
||||
switching tabs does not reload (and re-authenticate) a dashboard. -->
|
||||
@@ -810,7 +814,6 @@
|
||||
<section class="shortcut-section">
|
||||
<h4>Session</h4>
|
||||
<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</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>
|
||||
@@ -825,11 +828,23 @@
|
||||
<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>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>Enter</kbd> / <kbd>Space</kbd></div><div>Activate Focused Tab</div>
|
||||
</div>
|
||||
</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">
|
||||
<h4>Terminal</h4>
|
||||
<div class="shortcuts-grid">
|
||||
@@ -1907,7 +1922,7 @@
|
||||
<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>
|
||||
<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="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>
|
||||
@@ -1917,21 +1932,11 @@
|
||||
<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="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="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 class="set-row has-field" data-search="header stats style system cpu mem ws plan usage tiles compact rings">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Header Stats Style <span class="set-tag">desktop</span></span>
|
||||
<span class="set-row-desc">How WS, CPU, MEM and the plan-usage windows are drawn. Compact puts a ring beside each value in two pills; Tiles put each label over its value with a bar underneath.</span>
|
||||
</div>
|
||||
<select id="appSettingsHeaderStatsStyle" class="set-select">
|
||||
<option value="classic">As before (bars)</option>
|
||||
<option value="compact">Compact (pill + rings)</option>
|
||||
<option value="tiles">Tiles (default)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2084,28 +2089,6 @@
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row has-field" data-search="tab layout grouping group by state triage needs you waiting working idle case clusters ledger columns classic old new">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tab Layout</span>
|
||||
<span class="set-row-desc">By state: a row each for needs you, waiting, working and idle. By case: one box per case. Ledger: an aligned column grid. Classic: the single list, as before. By state and By case group the side rail and sidebar too. Alt+1..9 keeps the tab order.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabArrangement" class="set-select">
|
||||
<option value="state">By state (default)</option>
|
||||
<option value="case">By case (clusters)</option>
|
||||
<option value="ledger">Ledger (aligned columns)</option>
|
||||
<option value="classic">Classic (as before)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="state order reverse needs you bottom top rows sections">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">State Order</span>
|
||||
<span class="set-row-desc">For Tab Layout by state. At the bottom flips the rows, so needs you sits right above the terminal.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabStateOrder" class="set-select">
|
||||
<option value="urgent-first">Needs you on top (default)</option>
|
||||
<option value="urgent-last">Needs you at the bottom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="tab orientation horizontal vertical side rail">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tab Orientation</span>
|
||||
@@ -2129,7 +2112,7 @@
|
||||
<div class="set-row has-field" data-search="tab rail sort order activity manual drag reorder">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Vertical Rail Order</span>
|
||||
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. With Tab Layout by state or by case it orders the rows inside each section. Alt+1..9 always follows the tab order either way.</span>
|
||||
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. Alt+1..9 always follows the tab order either way.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabRailSort" class="set-select">
|
||||
<option value="activity">By activity (home screen order)</option>
|
||||
@@ -3955,7 +3938,9 @@
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="tab-rail-resize.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="tile-grid.js"></script>
|
||||
<script defer src="respawn-ui.js"></script>
|
||||
<script defer src="ralph-panel.js"></script>
|
||||
<script defer src="orchestrator-panel.js"></script>
|
||||
|
||||
@@ -755,25 +755,6 @@ html.mobile-init .file-browser-panel {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Grouped by state (tabArrangement 'state'), the phone strip keeps its one scrolling
|
||||
row: the tabs still come in state order, most urgent first, but the
|
||||
headings would cost chips and the dots already say which state is which.
|
||||
The phone overview is where the labelled sections live. */
|
||||
:where(.header) .tab-triage-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Clustered by case, the same holds: the boxes dissolve into the one chip
|
||||
row (in cluster order) and the labels go, since every chip still names
|
||||
its session. */
|
||||
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
:where(.header) .tab-cluster-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Only the active tab shows its action icons on a phone (see below), so on
|
||||
every other tab the container is empty but still a flex item, and its gap
|
||||
made the chip visibly wider on the right than on the left. */
|
||||
|
||||
@@ -6028,11 +6028,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
|
||||
// Rings for the Compact header style, kept current in every style (two
|
||||
// style writes a poll) so switching styles never shows an empty ring.
|
||||
this._setStatRing('statCpuRing', stats.cpu);
|
||||
this._setStatRing('statMemRing', stats.memory?.percent);
|
||||
|
||||
if (memEl && memBar) {
|
||||
const memGB = (stats.memory.usedMB / 1024).toFixed(1);
|
||||
memEl.textContent = `${memGB}G`;
|
||||
@@ -6050,20 +6045,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fill one stat ring (Compact header style) to `percent`, clamped to 0-100,
|
||||
* and flag it `high` above 80%, the same threshold at which the value next
|
||||
* to it turns red.
|
||||
*/
|
||||
_setStatRing(id, percent) {
|
||||
const ring = this.$(id);
|
||||
if (!ring) return;
|
||||
const value = Number(percent);
|
||||
const fill = Number.isFinite(value) ? Math.round(Math.min(100, Math.max(0, value))) : 0;
|
||||
ring.style.setProperty('--pu', String(fill));
|
||||
ring.classList.toggle('high', fill > 80);
|
||||
},
|
||||
|
||||
// ─── Clipboard ──────────────────────────────────────────────────────────────
|
||||
|
||||
async _onClipboardWrite(data) {
|
||||
|
||||
@@ -573,6 +573,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
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
|
||||
// needs the tab in the DOM before selectSession() marks it active.
|
||||
this._renderSessionTabsImmediate?.();
|
||||
|
||||
+14
-103
@@ -382,7 +382,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility settings
|
||||
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
|
||||
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
||||
document.getElementById('appSettingsHeaderStatsStyle').value = this.resolveHeaderStatsStyle(settings);
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false;
|
||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
|
||||
@@ -431,6 +430,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? 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('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Phone overview home screen: only meaningful under 600px, so the row is
|
||||
@@ -501,8 +501,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
|
||||
document.getElementById('appSettingsTabRailSort').value =
|
||||
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
|
||||
document.getElementById('appSettingsTabArrangement').value = this.resolveTabArrangement(settings);
|
||||
document.getElementById('appSettingsTabStateOrder').value = this.resolveTabStateOrder(settings);
|
||||
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
||||
document.getElementById('appSettingsSessionListLayout').value =
|
||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
@@ -2478,7 +2476,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility settings
|
||||
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
|
||||
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
|
||||
headerStatsStyle: document.getElementById('appSettingsHeaderStatsStyle').value,
|
||||
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
|
||||
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
|
||||
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
|
||||
@@ -2496,6 +2493,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showSplitButton: document.getElementById('appSettingsShowSplitButton').checked,
|
||||
showTileGridButton: document.getElementById('appSettingsShowTileGridButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||
@@ -2528,8 +2526,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||
tabRailSort: document.getElementById('appSettingsTabRailSort').value,
|
||||
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
|
||||
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
|
||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
||||
@@ -2736,6 +2732,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT
|
||||
// (moving it into displayKeys alone is not the strip; this is).
|
||||
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,
|
||||
terminalWheelLocalScrollback: _twls,
|
||||
// Copy-on-select. Per-device (clipboard access differs by device and by
|
||||
@@ -3426,6 +3425,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
ultracodeFloatingWindows: false,
|
||||
showMultiMonitorButton: false,
|
||||
showSplitButton: false,
|
||||
showTileGridButton: false,
|
||||
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
|
||||
// OFF so the phone header stays minimal and the mobile-header-buttons
|
||||
// policy guard keeps passing.
|
||||
@@ -3454,8 +3454,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabRailWidth: 256,
|
||||
tabRailDetail: 'rich',
|
||||
tabRailSort: 'activity',
|
||||
tabArrangement: 'state',
|
||||
tabStateOrder: 'urgent-first',
|
||||
sessionListLayout: 'header',
|
||||
sessionSidebarFontSize: 12,
|
||||
cjkInputEnabled: false,
|
||||
@@ -3557,79 +3555,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
return now === before ? undefined : now;
|
||||
},
|
||||
|
||||
/**
|
||||
* The stored tab layout, or the default. Anything but the four known values
|
||||
* (an absent key, a value from a newer build) reads as 'state', the default
|
||||
* (Discussion #426, option C).
|
||||
*/
|
||||
resolveTabArrangement(settings) {
|
||||
const value = settings?.tabArrangement ?? this.getDefaultSettings().tabArrangement;
|
||||
return value === 'case' || value === 'ledger' || value === 'classic' ? value : 'state';
|
||||
},
|
||||
|
||||
/** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */
|
||||
resolveTabStateOrder(settings) {
|
||||
const value = settings?.tabStateOrder ?? this.getDefaultSettings().tabStateOrder;
|
||||
return value === 'urgent-last' ? 'urgent-last' : 'urgent-first';
|
||||
},
|
||||
|
||||
/**
|
||||
* The stored header-stats style, or the default. Anything but the three
|
||||
* known values (an absent key, a value from a newer build) reads as 'tiles',
|
||||
* the default (the tile variant of Discussion #426's option G).
|
||||
*/
|
||||
resolveHeaderStatsStyle(settings) {
|
||||
const value = settings?.headerStatsStyle ?? this.getDefaultSettings().headerStatsStyle;
|
||||
return value === 'classic' || value === 'compact' ? value : 'tiles';
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply a header-stats style: the `data-header-stats` attribute every rule in
|
||||
* the "Header stats styles" block of styles.css keys on, plus the two DOM
|
||||
* moves the clustered styles need.
|
||||
*
|
||||
* The template keeps the classic order, where the connection indicator sits
|
||||
* before the font controls and the plan-usage chip near the end of the header.
|
||||
* Compact and Tiles draw them as ONE cluster (WS · CPU · MEM, then the plan
|
||||
* windows), so the indicator moves into #headerSystemStats as its first child
|
||||
* and the chip moves right after it. Comment anchors left at the template
|
||||
* positions are what 'classic' moves them back to, so switching back restores
|
||||
* the header exactly.
|
||||
*
|
||||
* ⚠️ The indicator only joins the pill while System Stats is shown: the pill
|
||||
* is hidden with `display: none`, and the WS readout must not disappear with
|
||||
* it. Both elements keep their ids, so every writer (setConnectionStatus,
|
||||
* updatePlanUsageChip) finds them wherever they sit.
|
||||
*
|
||||
* @param {{style: 'classic'|'compact'|'tiles', showSystemStats: boolean}} opts
|
||||
*/
|
||||
applyHeaderStatsStyle({ style, showSystemStats }) {
|
||||
document.documentElement.dataset.headerStats = style;
|
||||
const stats = document.getElementById('headerSystemStats');
|
||||
const conn = document.getElementById('connectionIndicator');
|
||||
const plan = document.getElementById('planUsageChip');
|
||||
if (!stats || !conn || !plan) return;
|
||||
if (!this._headerStatsAnchors) {
|
||||
const connAnchor = document.createComment(' connection indicator (classic position) ');
|
||||
const planAnchor = document.createComment(' plan usage chip (classic position) ');
|
||||
conn.before(connAnchor);
|
||||
plan.before(planAnchor);
|
||||
this._headerStatsAnchors = { conn: connAnchor, plan: planAnchor };
|
||||
}
|
||||
const anchors = this._headerStatsAnchors;
|
||||
const clustered = style !== 'classic';
|
||||
if (clustered && showSystemStats) {
|
||||
if (stats.firstElementChild !== conn) stats.prepend(conn);
|
||||
} else if (anchors.conn.nextSibling !== conn) {
|
||||
anchors.conn.after(conn);
|
||||
}
|
||||
if (clustered) {
|
||||
if (stats.nextElementSibling !== plan) stats.after(plan);
|
||||
} else if (anchors.plan.nextSibling !== plan) {
|
||||
anchors.plan.after(plan);
|
||||
}
|
||||
},
|
||||
|
||||
applyHeaderVisibilitySettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
@@ -3660,12 +3585,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (tokenCountEl) {
|
||||
tokenCountEl.style.display = showTokenCount ? '' : 'none';
|
||||
}
|
||||
// After the System Stats visibility above: whether WS joins the stats pill
|
||||
// depends on the pill being shown.
|
||||
this.applyHeaderStatsStyle({
|
||||
style: compactHeader || this.isSoloWindow ? 'classic' : this.resolveHeaderStatsStyle(settings),
|
||||
showSystemStats,
|
||||
});
|
||||
|
||||
// Hide lifecycle log button when setting is disabled
|
||||
// Default OFF: the lifecycle-log document icon is opt-in; the default header
|
||||
@@ -3719,6 +3638,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||
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.
|
||||
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
|
||||
// from the mobile-header-buttons-policy guard.
|
||||
@@ -3833,19 +3756,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity';
|
||||
root.dataset.tabRailSort = sort;
|
||||
|
||||
// The tab layout rides on a fourth attribute, for the same reason: it is
|
||||
// applied by the render paths (inline `order` plus headings, or cluster
|
||||
// boxes), so a flip has to re-render, and the gates in app.js
|
||||
// (`isTabTriage()`, `isTabClusters()`, `isTabLedger()`) read one attribute
|
||||
// per pass instead of re-parsing localStorage.
|
||||
const previousArrangement = root.dataset.tabArrangement || 'state';
|
||||
const arrangement = this.resolveTabArrangement(settings);
|
||||
root.dataset.tabArrangement = arrangement;
|
||||
// Which end the state groups start from; read by _tabTriageLayout().
|
||||
const previousStateOrder = root.dataset.tabStateOrder || 'urgent-first';
|
||||
const stateOrder = this.resolveTabStateOrder(settings);
|
||||
root.dataset.tabStateOrder = stateOrder;
|
||||
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const rail = document.getElementById('tabRail');
|
||||
const headerHost = document.getElementById('sessionTabsHost');
|
||||
@@ -3869,7 +3779,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the row template, not toggled by CSS — same reasoning as the sidebar's
|
||||
// detail half in applySessionListLayout(). Taller rows also move every
|
||||
// connector anchored to a tab rect.
|
||||
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder;
|
||||
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;
|
||||
if (orientationChanged) {
|
||||
this.updateTabOverflowMode?.();
|
||||
if (!settleRailWidth) this.syncTerminalGeometry?.();
|
||||
@@ -4139,10 +4049,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
|
||||
// are NOT display keys — they control server-side behavior and must sync from server.
|
||||
const displayKeys = new Set([
|
||||
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||
'language',
|
||||
@@ -4153,6 +4063,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'mobileOverviewEnabled',
|
||||
'sessionLineageLines',
|
||||
'showSplitButton',
|
||||
'showTileGridButton',
|
||||
]);
|
||||
// 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
|
||||
|
||||
+871
-719
File diff suppressed because one or more lines are too long
+146
-649
@@ -1,640 +1,16 @@
|
||||
// src/web/public/terminal-split.js
|
||||
|
||||
/**
|
||||
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
|
||||
* ("Pane B") for split-view sessions. Deliberately plainer than the primary
|
||||
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
|
||||
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
|
||||
* feature by nature — see docs/split-pane-sessions-plan.md.
|
||||
* @fileoverview Split-pane orchestration: opens a second live session
|
||||
* ("Pane B") beside the active one, in a TerminalTile (terminal-tile.js), with
|
||||
* a draggable divider, a session picker, and auto-collapse when either
|
||||
* session ends. Desktop-only; see docs/split-pane-sessions-plan.md.
|
||||
*
|
||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
||||
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
|
||||
* @dependency terminal-tile.js (window.TerminalTile)
|
||||
* @dependency constants.js (window.CodemanSplitPane, SPLIT_PANE_MIN_WIDTH)
|
||||
* @loadorder 7.5 of 16, loaded after terminal-tile.js and before tile-grid.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, {
|
||||
/**
|
||||
* 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
|
||||
// `document` and self-closed the menu it just opened.
|
||||
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) {
|
||||
this.closeSplitPane();
|
||||
return;
|
||||
@@ -763,6 +142,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// see _applySplitButtonVisibility's comment for why both a JS check and
|
||||
// a CSS backstop exist.
|
||||
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
|
||||
// (the welcome overlay is showing) — without this, a split opened from
|
||||
// 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
|
||||
// sessionId === activeSessionId: two live WebSockets to the same
|
||||
// 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;
|
||||
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
|
||||
// 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');
|
||||
paneB.className = 'terminal-pane-b';
|
||||
paneB.innerHTML = `
|
||||
<div class="terminal-pane-b-header">
|
||||
<span class="session-name">${escapeHtml(session?.name || 'Session')}</span>
|
||||
<button type="button" class="terminal-pane-b-close" onclick="app.closeSplitPane()" aria-label="Close split">×</button>
|
||||
</div>
|
||||
<div class="terminal-pane-b-container"></div>
|
||||
`;
|
||||
// Pane B's header: the harness logo, the name and the model, as on a grid
|
||||
// tile, and the close button at a tile button's size.
|
||||
const headerB = this._buildSplitPaneHeader();
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
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);
|
||||
wrap.insertBefore(headerA.el, wrap.firstChild);
|
||||
container.appendChild(wrap);
|
||||
wrap.style.flexBasis = '50%';
|
||||
container.appendChild(divider);
|
||||
container.appendChild(paneB);
|
||||
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,
|
||||
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
||||
detachedSessions: this.detachedSessions,
|
||||
@@ -828,6 +225,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
initial load — live output still arrives once/if the socket connects. */
|
||||
});
|
||||
this._splitSessionId = sessionId;
|
||||
this._renderSplitChrome();
|
||||
|
||||
// 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
|
||||
@@ -855,6 +253,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._splitPane.destroy();
|
||||
this._splitPane = 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);
|
||||
|
||||
const container = document.querySelector('.terminal-split-container');
|
||||
@@ -865,14 +266,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
parent.insertBefore(wrap, container);
|
||||
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
|
||||
// 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;
|
||||
// the promoted session gets its own resize from selectSession().
|
||||
if (!options.skipPrimaryResize) {
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
}
|
||||
// the promoted session gets its own resize from selectSession(), and the
|
||||
// terminal is only refitted here.
|
||||
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
|
||||
@@ -880,6 +283,92 @@ Object.assign(CodemanApp.prototype, {
|
||||
// nothing on the button said which. `.split-open` + aria-pressed give it
|
||||
// the same active-state language as the codebase's other toggle buttons
|
||||
// (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) {
|
||||
const btn = document.querySelector('.btn-split');
|
||||
if (!btn) return;
|
||||
@@ -900,10 +389,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// frame). Coalesced to one call per animation frame below — a raw
|
||||
// mousemove stream fires far faster than the browser repaints, and
|
||||
// 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
|
||||
// no client-side "dims unchanged" skip), which fanned out into a
|
||||
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
|
||||
// of each dragging across half a wide viewport.
|
||||
// panes AND sent Pane B a `{t:'z'}` resize frame, which fanned out
|
||||
// into a `tmux resize-window` child plus a SIGWINCH per frame, roughly
|
||||
// fifty of each dragging across half a wide viewport.
|
||||
const applyDragPercent = (clientX) => {
|
||||
const container = divider.parentElement;
|
||||
// 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
|
||||
// still attached to — two live WebSockets to one session, each claiming PTY
|
||||
// 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;
|
||||
CodemanApp.prototype.selectSession = function (sessionId, ...args) {
|
||||
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._installShiftDragSelection();
|
||||
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
|
||||
// non-composing keyCode 229 is how an active IME commits numbers and
|
||||
@@ -587,6 +590,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
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
|
||||
// ^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
|
||||
@@ -1325,6 +1333,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 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.
|
||||
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).
|
||||
// This block used to fit unconditionally and skip only the SIGWINCH,
|
||||
// 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 —
|
||||
// a reflow nothing is rendering for buys nothing and costs correctness.
|
||||
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
|
||||
// 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
|
||||
@@ -1415,8 +1428,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// frame — this observer only ever measured Pane A's container, so
|
||||
// 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
|
||||
// stayed at whatever size it was last dragged to.
|
||||
this._splitPane?.fit();
|
||||
// stayed at whatever size it was last dragged to. Grid tiles are left
|
||||
// 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
|
||||
};
|
||||
|
||||
@@ -1827,9 +1843,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Register a custom link provider for xterm.js that detects file paths
|
||||
* in terminal output and makes them clickable.
|
||||
* 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 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
|
||||
let lastInvokedLine = -1;
|
||||
@@ -1842,7 +1873,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
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
|
||||
const line = buffer.getLine(bufferLineNumber - 1);
|
||||
|
||||
@@ -1866,7 +1897,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
|
||||
buffer,
|
||||
bufferLineNumber - 1,
|
||||
self.terminal.cols,
|
||||
terminal.cols,
|
||||
MAX_STITCHED_ROWS
|
||||
);
|
||||
if (!logical) {
|
||||
@@ -1919,10 +1950,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
window.open(text, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
hover() {
|
||||
self._linkHovered = true;
|
||||
setHovered(true);
|
||||
},
|
||||
leave() {
|
||||
self._linkHovered = false;
|
||||
setHovered(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1978,17 +2009,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
// path clicked in the response viewer previewed fine. The preview
|
||||
// reads those through the guarded attachment routes, so external
|
||||
// paths route there and the two surfaces agree.
|
||||
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) {
|
||||
self.openFilePreview(text, self.activeSessionId);
|
||||
const sessionId = getSessionId();
|
||||
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) {
|
||||
self.openFilePreview(text, sessionId);
|
||||
return;
|
||||
}
|
||||
self.openLogViewerWindow(text, self.activeSessionId);
|
||||
self.openLogViewerWindow(text, sessionId);
|
||||
},
|
||||
hover() {
|
||||
self._linkHovered = true;
|
||||
setHovered(true);
|
||||
},
|
||||
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
|
||||
// rather than growing a second, driftable copy of the patterns.
|
||||
// See _terminalLinkAtPoint.
|
||||
this._terminalLinkProvider = provider;
|
||||
this.terminal.registerLinkProvider(provider);
|
||||
if (isPrimary) this._terminalLinkProvider = provider;
|
||||
terminal.registerLinkProvider(provider);
|
||||
|
||||
console.log('[LinkProvider] File path link provider registered');
|
||||
return provider;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -4480,8 +4513,56 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 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() {
|
||||
this.terminal.clear();
|
||||
this._focusedPane().terminal?.clear();
|
||||
},
|
||||
|
||||
/** 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").
|
||||
*/
|
||||
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) {
|
||||
this.showToast('No active session', 'warning');
|
||||
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
|
||||
// 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
|
||||
* a future xterm renames it. SelectionMode.COLUMN is 3.
|
||||
*/
|
||||
cleanedTerminalSelection(text) {
|
||||
const raw = text ?? (this.terminal?.hasSelection?.() ? this.terminal.getSelection() : '');
|
||||
cleanedTerminalSelection(text, target = {}) {
|
||||
// `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 (this.terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
||||
if (terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
||||
const clean = window.CodemanCopySelection?.clean;
|
||||
if (!clean) return raw;
|
||||
const range = this._normalisedSelectionRange();
|
||||
const range = this._normalisedSelectionRange(terminal);
|
||||
return clean(raw, {
|
||||
margin: this._cliGutterColumns(),
|
||||
margin: this._cliGutterColumns(target.sessionId),
|
||||
firstLinePartial: !!range && range.start.x > 0,
|
||||
});
|
||||
},
|
||||
@@ -4717,8 +4812,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
|
||||
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
|
||||
// option serves plain HTTP, where navigator.clipboard is undefined.
|
||||
async copyTerminalSelection(text) {
|
||||
const selection = this.cleanedTerminalSelection(text);
|
||||
async copyTerminalSelection(text, target = {}) {
|
||||
// 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
|
||||
// alone, which are truthy, and a bare newline pasted into a chat composer
|
||||
// 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
|
||||
// later interrupt; it cleans to '' and the press reaches the PTY. What the
|
||||
// clear avoids is a highlight that sits there having copied nothing.
|
||||
this.terminal?.clearSelection?.();
|
||||
terminal?.clearSelection?.();
|
||||
this.showToast('Nothing to copy', 'warning');
|
||||
return false;
|
||||
}
|
||||
@@ -4735,14 +4833,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (ok) {
|
||||
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already
|
||||
// drops the selection on any keypress, so this matches existing feel).
|
||||
this.terminal.clearSelection?.();
|
||||
terminal.clearSelection?.();
|
||||
this.showToast('Copied to clipboard', 'success');
|
||||
} else {
|
||||
this.showToast('Failed to copy', 'error');
|
||||
}
|
||||
// The execCommand fallback focuses a temp textarea, so hand focus back. This
|
||||
// is the CJK-aware focus router, not xterm's raw focus().
|
||||
this.terminal.focus();
|
||||
terminal.focus();
|
||||
return ok;
|
||||
},
|
||||
|
||||
@@ -5671,11 +5769,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
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;
|
||||
this.setFontSize(Math.min(current + 2, 24));
|
||||
},
|
||||
|
||||
decreaseFontSize() {
|
||||
if (this._tilesOwnTerminal?.()) {
|
||||
this.setTileFontSize(Math.max(this._tileGridFontSize() - 2, 10));
|
||||
return;
|
||||
}
|
||||
const current = this.terminal.options.fontSize || 14;
|
||||
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
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontSize = size;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
this._forEachTile?.(
|
||||
(tile) => {
|
||||
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._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontFamily = resolved;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.fontFamily = resolved;
|
||||
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 */
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontWeight = fontWeight;
|
||||
this._splitPane.terminal.options.fontWeightBold = fontWeightBold;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.fontWeight = fontWeight;
|
||||
tile.terminal.options.fontWeightBold = fontWeightBold;
|
||||
tile.fit(); // a font change is a size change: tell its PTY too (#464)
|
||||
});
|
||||
},
|
||||
|
||||
loadFontSize() {
|
||||
@@ -5995,6 +6105,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 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.
|
||||
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
|
||||
// xterm is actually holding (or, while another device holds the width,
|
||||
// the numbers this container would hold if the PTY followed).
|
||||
@@ -6245,13 +6358,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
this._splitPane.terminal.options.theme = { ...theme };
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
tile.terminal.options.theme = { ...theme };
|
||||
try {
|
||||
this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1);
|
||||
tile.terminal.refresh(0, tile.terminal.rows - 1);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -554,6 +554,11 @@ const VoiceInput = {
|
||||
_analyserSource: null, // MediaStreamSource for level meter
|
||||
_audioContext: null, // AudioContext 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() {
|
||||
this._initRecognition();
|
||||
@@ -663,10 +668,12 @@ const VoiceInput = {
|
||||
|
||||
start() {
|
||||
if (this.isRecording) return;
|
||||
if (!app.activeSessionId) {
|
||||
const target = app._focusedPane?.()?.sessionId || app.activeSessionId;
|
||||
if (!target) {
|
||||
app.showToast('No active session', 'warning');
|
||||
return;
|
||||
}
|
||||
this._targetSessionId = target;
|
||||
this._retryCount = 0;
|
||||
|
||||
const provider = this._resolveProvider();
|
||||
@@ -959,8 +966,31 @@ const VoiceInput = {
|
||||
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) {
|
||||
if (!app.activeSessionId || !text.trim()) return;
|
||||
const target = this._targetSession();
|
||||
if (!target || !text.trim()) return;
|
||||
const trimmed = text.trim();
|
||||
const mode = this._getDeepgramConfig().insertMode || 'direct';
|
||||
|
||||
@@ -975,14 +1005,17 @@ const VoiceInput = {
|
||||
this._showComposeOverlay(trimmed);
|
||||
}
|
||||
} else {
|
||||
// Direct mode: inject into local echo overlay if available, else send to PTY
|
||||
if (app._localEchoEnabled && app._localEchoOverlay) {
|
||||
// Direct mode: inject into local echo overlay if available, else send to PTY.
|
||||
// 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);
|
||||
} else {
|
||||
app.sendInput(trimmed).catch(() => {});
|
||||
this._sendToTarget(target, trimmed).catch(() => {});
|
||||
}
|
||||
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
|
||||
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;
|
||||
// otherwise just send \r directly to the PTY
|
||||
if (app._localEchoEnabled && app._localEchoOverlay) {
|
||||
// otherwise just send \r directly to the PTY. Both the overlay and the
|
||||
// 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 || '';
|
||||
app._localEchoOverlay.clear();
|
||||
app._localEchoOverlay.suppressBufferDetection();
|
||||
@@ -1065,7 +1103,7 @@ const VoiceInput = {
|
||||
const send = () => {
|
||||
const val = textarea.value.trim();
|
||||
overlay.remove();
|
||||
if (val) app.sendInput(val + '\r').catch(() => {});
|
||||
if (val) this._sendToTarget(this._targetSession(), val + '\r').catch(() => {});
|
||||
};
|
||||
const cancel = () => overlay.remove();
|
||||
const newInput = () => {
|
||||
|
||||
@@ -775,6 +775,20 @@ export function resolveOmpConfigForCreate(
|
||||
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
|
||||
* (`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) => {
|
||||
const routeStartedAt = performance.now();
|
||||
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);
|
||||
|
||||
// `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
|
||||
// to tell the client what size the frame it is about to render was built
|
||||
// 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
|
||||
? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
|
||||
? {
|
||||
fullHistory: true,
|
||||
historyLimitLines: captureHistoryLines(query.lines, tmuxHistoryLimit),
|
||||
maxCaptureBytes: terminalBufferMaxBytes,
|
||||
}
|
||||
: {};
|
||||
const liveMuxBuffer =
|
||||
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
* the header "Plan Usage Limits" chip. Auth-exempt like `/api/hook-event`
|
||||
* (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
|
||||
* 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
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
import { SessionStatusTelemetry } from '../sse-events.js';
|
||||
import { setLatestPlanUsage } from '../plan-usage-latest.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 {
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
// present and changed (the statusline fires on every assistant message).
|
||||
const telemetry = parseStatusTelemetry(payload);
|
||||
|
||||
@@ -1403,25 +1403,6 @@ export const SettingsUpdateSchema = z
|
||||
* rail drag-reorderable.
|
||||
*/
|
||||
tabRailSort: z.enum(['activity', 'manual']).optional(),
|
||||
/**
|
||||
* Tab layout, the arrangement of the tab list (Discussion #426). Display key
|
||||
* (per-device).
|
||||
* 'state' = a row per state in the header strip (needs you, waiting,
|
||||
* working, idle; option C), sections in the flat side rail and
|
||||
* the sidebar. The default.
|
||||
* 'case' = one cluster per case (option A): a labelled box in the strip,
|
||||
* a section in the side rail and the sidebar.
|
||||
* 'ledger' = the flat list on an aligned column grid with a status bar
|
||||
* per cell (option B). Header strip on desktop only.
|
||||
* 'classic' = one flat list in tab order, as before.
|
||||
*/
|
||||
tabArrangement: z.enum(['state', 'case', 'ledger', 'classic']).optional(),
|
||||
/**
|
||||
* Which end the state groups start from when `tabArrangement` is 'state'.
|
||||
* Display key (per-device). 'urgent-first' = needs you on top (the
|
||||
* default); 'urgent-last' = the other way up, needs you in the bottom row.
|
||||
*/
|
||||
tabStateOrder: z.enum(['urgent-first', 'urgent-last']).optional(),
|
||||
/**
|
||||
* Session list layout. Display key (per-device).
|
||||
* 'header' = horizontal tab strip
|
||||
@@ -1453,14 +1434,6 @@ export const SettingsUpdateSchema = z
|
||||
// UI visibility
|
||||
showFontControls: z.boolean().optional(),
|
||||
showSystemStats: z.boolean().optional(),
|
||||
/**
|
||||
* How the header draws its WS / CPU / MEM / plan-usage cluster. Display key
|
||||
* (per-device), desktop only (the cluster is hidden below 768px).
|
||||
* 'classic' = the bars and the 5H · 7D chip, as before
|
||||
* 'compact' = two pills (WS/CPU/MEM, the plan windows), a ring beside every value
|
||||
* 'tiles' = label over value with a bar underneath, no icons. The default.
|
||||
*/
|
||||
headerStatsStyle: z.enum(['classic', 'compact', 'tiles']).optional(),
|
||||
showTokenCount: z.boolean().optional(),
|
||||
showCost: z.boolean().optional(),
|
||||
showLifecycleLog: z.boolean().optional(),
|
||||
|
||||
@@ -3542,6 +3542,10 @@ export class WebServer extends EventEmitter {
|
||||
// pane-exit watcher's own tick replaces it with a first-hand
|
||||
// reading (not the stats collector — see `startPaneExitWatcher`).
|
||||
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
|
||||
// apparent locality is a guess (see `MuxSession.discovered`).
|
||||
discoveredMuxSession: muxSession.discovered,
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface SessionListenerRefs {
|
||||
working: () => void;
|
||||
idle: () => void;
|
||||
watchingChanged: () => void;
|
||||
displayModelChanged: () => void;
|
||||
taskCreated: (task: BackgroundTask) => void;
|
||||
taskUpdated: (task: BackgroundTask) => void;
|
||||
taskCompleted: (task: BackgroundTask) => void;
|
||||
@@ -275,6 +276,16 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
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 ──────────────────────────────
|
||||
|
||||
/** 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('idle', refs.idle);
|
||||
session.on('watchingChanged', refs.watchingChanged);
|
||||
session.on('displayModelChanged', refs.displayModelChanged);
|
||||
session.on('taskCreated', refs.taskCreated);
|
||||
session.on('taskUpdated', refs.taskUpdated);
|
||||
session.on('taskCompleted', refs.taskCompleted);
|
||||
@@ -545,6 +557,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.off('working', refs.working);
|
||||
session.off('idle', refs.idle);
|
||||
session.off('watchingChanged', refs.watchingChanged);
|
||||
session.off('displayModelChanged', refs.displayModelChanged);
|
||||
session.off('taskCreated', refs.taskCreated);
|
||||
session.off('taskUpdated', refs.taskUpdated);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
* (`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.
|
||||
*
|
||||
* 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';
|
||||
|
||||
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
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
// 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
|
||||
// same way the toolbar's #runModeMenu exempts it (shell needs no CLI).
|
||||
"mobile-overview.js::mode !== 'shell'": {
|
||||
@@ -177,7 +187,7 @@ function actualCounts(): Map<string, number> {
|
||||
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)', () => {
|
||||
// If this drops to zero the scanner or the file list drifted and every
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* @fileoverview The header-stats styles (`headerStatsStyle`, Discussion #426
|
||||
* option G): 'classic' (as before), 'compact' (one system pill with rings
|
||||
* plus a plan-ring pill) and 'tiles' (label over value, bar underneath; the
|
||||
* default).
|
||||
*
|
||||
* Pinned here:
|
||||
* - The default is 'tiles', and only the three known values are honoured.
|
||||
* - The two clustered styles move the connection indicator INTO the system
|
||||
* stats pill and the plan chip right after it, on the REAL header markup
|
||||
* from index.html, and 'classic' puts both back exactly where the template
|
||||
* had them.
|
||||
* - The WS readout never disappears with a hidden System Stats pill.
|
||||
* - The parts only the new styles draw (stat rings, plan rings, meters, tile words)
|
||||
* are rendered with sane values and hidden by default in CSS, which is what
|
||||
* keeps 'classic' looking exactly as before.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
const INDEX = read('index.html');
|
||||
const HEADER = INDEX.slice(INDEX.indexOf('<header class="header">'), INDEX.indexOf('</header>') + '</header>'.length);
|
||||
|
||||
let CodemanApp: { prototype: Record<string, any> };
|
||||
let window: any;
|
||||
let document: Document;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
window = dom.window;
|
||||
document = window.document;
|
||||
window.setInterval = () => 0;
|
||||
window.requestAnimationFrame = () => 0;
|
||||
window.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => "desktop" }, ' +
|
||||
'KeyboardHandler = {}, SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\n' +
|
||||
read('settings-ui.js') +
|
||||
'\n' +
|
||||
read('panels-ui.js') +
|
||||
'\n;window.__HeaderStatsCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = window.__HeaderStatsCodemanApp;
|
||||
});
|
||||
|
||||
function makeApp() {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Element children of the header's right side, by id (or first class). */
|
||||
const rightSide = () =>
|
||||
[...document.getElementById('headerRight')!.children].map((el) => el.id || el.classList[0]);
|
||||
const statsChildren = () =>
|
||||
[...document.getElementById('headerSystemStats')!.children].map((el) => el.id || el.classList[0]);
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = HEADER;
|
||||
delete document.documentElement.dataset.headerStats;
|
||||
});
|
||||
|
||||
describe('resolveHeaderStatsStyle', () => {
|
||||
it("defaults to 'tiles' and honours only the three known styles", () => {
|
||||
const app = makeApp();
|
||||
expect(app.resolveHeaderStatsStyle({})).toBe('tiles');
|
||||
expect(app.resolveHeaderStatsStyle(undefined)).toBe('tiles');
|
||||
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'classic' })).toBe('classic');
|
||||
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'compact' })).toBe('compact');
|
||||
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'tiles' })).toBe('tiles');
|
||||
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'neon' })).toBe('tiles');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyHeaderStatsStyle', () => {
|
||||
it('clusters WS, the system stats and the plan chip for tiles and compact', () => {
|
||||
for (const style of ['tiles', 'compact']) {
|
||||
document.body.innerHTML = HEADER;
|
||||
const app = makeApp();
|
||||
app.applyHeaderStatsStyle({ style, showSystemStats: true });
|
||||
expect(document.documentElement.dataset.headerStats).toBe(style);
|
||||
expect(statsChildren()[0]).toBe('connectionIndicator');
|
||||
const right = rightSide();
|
||||
expect(right[right.indexOf('headerSystemStats') + 1]).toBe('planUsageChip');
|
||||
expect(right).not.toContain('connectionIndicator');
|
||||
}
|
||||
});
|
||||
|
||||
it("'classic' puts both back exactly where the template had them", () => {
|
||||
const template = rightSide();
|
||||
const app = makeApp();
|
||||
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
|
||||
app.applyHeaderStatsStyle({ style: 'compact', showSystemStats: true });
|
||||
app.applyHeaderStatsStyle({ style: 'classic', showSystemStats: true });
|
||||
expect(rightSide()).toEqual(template);
|
||||
expect(statsChildren()).not.toContain('connectionIndicator');
|
||||
expect(document.documentElement.dataset.headerStats).toBe('classic');
|
||||
});
|
||||
|
||||
it('keeps WS out of a hidden System Stats pill', () => {
|
||||
const template = rightSide();
|
||||
const app = makeApp();
|
||||
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
|
||||
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: false });
|
||||
expect(statsChildren()).not.toContain('connectionIndicator');
|
||||
expect(rightSide().indexOf('connectionIndicator')).toBe(template.indexOf('connectionIndicator'));
|
||||
});
|
||||
|
||||
it('is idempotent: re-applying the same style moves nothing', () => {
|
||||
const app = makeApp();
|
||||
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
|
||||
const once = document.getElementById('headerRight')!.innerHTML;
|
||||
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
|
||||
expect(document.getElementById('headerRight')!.innerHTML).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the parts the new styles draw', () => {
|
||||
it('gives every plan window a ring and a meter with a clamped fill, the label the real number', () => {
|
||||
const app = makeApp();
|
||||
app.updatePlanUsageChip({ fiveHour: { usedPercentage: 28 }, sevenDay: { usedPercentage: 104 } });
|
||||
const wins = [...document.querySelectorAll<HTMLElement>('#planUsageChip .pu-win')];
|
||||
expect(wins).toHaveLength(2);
|
||||
expect(wins[0].querySelector<HTMLElement>('.pu-ring')!.style.getPropertyValue('--pu')).toBe('28');
|
||||
expect(wins[0].querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('28%');
|
||||
expect(wins[0].querySelector('.pu-ring')!.classList.contains('pu-green')).toBe(true);
|
||||
expect(wins[1].querySelector('.pu-val')!.textContent).toBe('104%');
|
||||
expect(wins[1].querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('100%');
|
||||
expect(wins[1].querySelector('.pu-meter')!.classList.contains('pu-red')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an idle Claude window as a dimmed slot with an empty ring and meter', () => {
|
||||
const app = makeApp();
|
||||
app.updatePlanUsageChip({ sevenDay: { usedPercentage: 35 } });
|
||||
const idle = document.querySelector<HTMLElement>('#planUsageChip .pu-win-idle')!;
|
||||
expect(idle.querySelector('.pu-val')!.textContent).toBe('—');
|
||||
expect(idle.querySelector<HTMLElement>('.pu-ring')!.style.getPropertyValue('--pu')).toBe('0');
|
||||
expect(idle.querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('0%');
|
||||
});
|
||||
|
||||
it('names the connection state in two short words per dot state', () => {
|
||||
const app = makeApp();
|
||||
const words = (dotClass: string, text = '') => app._connectionTileWords({ dotClass, text });
|
||||
expect(words('connection-dot connected', 'WS')).toMatchObject({ label: 'WS', value: 'live', state: 'connected' });
|
||||
expect(words('connection-dot fallback', 'HTTP')).toMatchObject({ label: 'HTTP', value: 'fallback' });
|
||||
expect(words('connection-dot offline', 'Offline')).toMatchObject({ label: 'NET', value: 'offline' });
|
||||
expect(words('connection-dot draining', 'Sending...')).toMatchObject({ label: 'SEND', value: 'queued' });
|
||||
expect(words('connection-dot reconnecting', 'WS…')).toMatchObject({ label: 'WS', value: 'retry' });
|
||||
expect(words('connection-dot reconnecting', 'Reconnecting...')).toMatchObject({ label: 'SSE', value: 'retry' });
|
||||
});
|
||||
|
||||
it('writes the tile words beside the classic text, which stays as it was', () => {
|
||||
const app = makeApp();
|
||||
app._computeConnectionDescriptor = () => ({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot connected',
|
||||
text: 'WS · 2.0KB queued',
|
||||
title: 'Terminal connected over WebSocket',
|
||||
});
|
||||
app._updateConnectionIndicator();
|
||||
expect(document.getElementById('connectionText')!.textContent).toBe('WS · 2.0KB queued');
|
||||
expect(document.getElementById('connectionTileLabel')!.textContent).toBe('WS');
|
||||
expect(document.getElementById('connectionTileValue')!.textContent).toBe('live');
|
||||
expect(document.getElementById('connectionTileValue')!.className).toBe('connection-tile-value connected');
|
||||
});
|
||||
|
||||
it('fills the CPU and MEM rings from the stats poll, clamped, red past 80%', () => {
|
||||
const app = makeApp();
|
||||
const ring = (id: string) => {
|
||||
const el = document.getElementById(id)!;
|
||||
return [el.style.getPropertyValue('--pu'), el.classList.contains('high')];
|
||||
};
|
||||
app.updateSystemStatsDisplay({ cpu: 22, memory: { usedMB: 14.4 * 1024, percent: 45.6 } });
|
||||
expect(ring('statCpuRing')).toEqual(['22', false]);
|
||||
expect(ring('statMemRing')).toEqual(['46', false]);
|
||||
app.updateSystemStatsDisplay({ cpu: 140, memory: { usedMB: 30 * 1024, percent: 81 } });
|
||||
expect(ring('statCpuRing')).toEqual(['100', true]);
|
||||
expect(ring('statMemRing')).toEqual(['81', true]);
|
||||
// A ring comes first in its stat, before the label, like the plan rings.
|
||||
expect(document.getElementById('statCpuRing')!.nextElementSibling!.className).toBe('stat-label');
|
||||
});
|
||||
});
|
||||
|
||||
describe('header stats wiring (static)', () => {
|
||||
const css = read('styles.css');
|
||||
|
||||
it('hides the new-style parts by default, so classic looks exactly as before', () => {
|
||||
expect(css).toMatch(
|
||||
/\.stat-ring,\s*\.connection-tile,\s*\.header-plan-usage \.pu-ring,\s*\.header-plan-usage \.pu-meter \{\s*display: none;/
|
||||
);
|
||||
});
|
||||
|
||||
it('lays a tile out as three rows, so its height never depends on the font', () => {
|
||||
// A bar laid over the bottom of a fixed-height tile is what let a taller
|
||||
// system mono (SF Mono) push the value into it.
|
||||
expect(css).toMatch(/grid-template-rows: 9px 14px 2px;\s*row-gap: 2px;/);
|
||||
const tiles = css.slice(css.indexOf('/* --- Tiles: label over value'));
|
||||
const bar = tiles.slice(tiles.indexOf("html[data-header-stats='tiles'] .header-system-stats .stat-bar,"));
|
||||
expect(bar.slice(0, bar.indexOf('}'))).not.toContain('position: absolute');
|
||||
expect(bar.slice(0, bar.indexOf('}'))).toContain('grid-row: 3;');
|
||||
});
|
||||
|
||||
it('gives the header buttons the tile box beside the tiles', () => {
|
||||
expect(css).toMatch(
|
||||
/html\[data-header-stats='tiles'\] \.header-right > \.btn-icon-header \{\s*width: 36px;\s*height: 36px;/
|
||||
);
|
||||
expect(css).toMatch(/html\[data-header-stats='tiles'\] \.header-right > \.btn-icon-header > svg \{\s*width: 18px;/);
|
||||
});
|
||||
|
||||
it('stamps data-header-stats before first paint, tiles by default and classic on narrow screens', () => {
|
||||
expect(INDEX).toContain(
|
||||
"dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles'"
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the three styles with tiles marked as the default', () => {
|
||||
expect(INDEX).toMatch(
|
||||
/<select id="appSettingsHeaderStatsStyle"[^>]*>\s*<option value="classic">[^<]+<\/option>\s*<option value="compact">[^<]+<\/option>\s*<option value="tiles">Tiles \(default\)<\/option>/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -31,14 +31,16 @@ function escapeRegExp(value: string): string {
|
||||
|
||||
function expectShortcut(html: string, keys: string[], label: string): void {
|
||||
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', () => {
|
||||
const helpModal = normalizedHtml(extractElementById(INDEX_HTML, 'helpModal'));
|
||||
|
||||
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, ['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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expectShortcut(helpModal, ['Ctrl', 'C'], 'Copy Selection');
|
||||
expectShortcut(helpModal, ['Ctrl', 'Shift', 'C'], 'Copy Selection');
|
||||
|
||||
@@ -91,6 +91,100 @@ describe('custom display name and browser localization', () => {
|
||||
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', () => {
|
||||
const dom = makeDom('');
|
||||
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.showToast = vi.fn();
|
||||
app.sendInput = vi.fn(async () => {});
|
||||
app._sendInputAsync = vi.fn();
|
||||
app._normalizeImageForUpload = vi.fn(async (file) => file);
|
||||
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
|
||||
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(app.sendInput).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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' }]);
|
||||
|
||||
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', () => {
|
||||
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\)/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
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 */
|
||||
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', () => {
|
||||
const handler = appSource.slice(
|
||||
appSource.indexOf('_onWsInputAck(seq, msg)'),
|
||||
appSource.indexOf('_onWsInputAck(seq, msg, sessionId'),
|
||||
appSource.indexOf('/** Called from ws.onopen')
|
||||
);
|
||||
|
||||
|
||||
@@ -1037,6 +1037,62 @@ describe('session-routes', () => {
|
||||
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 () => {
|
||||
// The full-history capture is the rendered form of everything already in
|
||||
// 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,
|
||||
* NO rebroadcast on context-only drift, null-tolerance of Claude's undocumented
|
||||
* 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 { registerStatusTelemetryRoutes } from '../../src/web/routes/status-telemetry-routes.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
|
||||
|
||||
@@ -100,3 +102,56 @@ describe('POST /api/status-telemetry', () => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
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';
|
||||
|
||||
describe('session listener wiring', () => {
|
||||
@@ -37,6 +41,27 @@ describe('session listener wiring', () => {
|
||||
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. */
|
||||
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');
|
||||
});
|
||||
|
||||
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();
|
||||
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', () => {
|
||||
@@ -115,8 +132,12 @@ describe('the exited-agent badge in a tab', () => {
|
||||
});
|
||||
|
||||
it('builds the full render path accessible name from the same helper', () => {
|
||||
expect(appJs).toContain('aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}"');
|
||||
expect(appJs).toContain('<span class="tab-exited-badge" data-i18n-skip aria-hidden="true">');
|
||||
expect(appJs).toContain(
|
||||
'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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
// 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
|
||||
@@ -119,7 +173,7 @@ describe('selectSession acknowledgement gate', () => {
|
||||
['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { 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 });'],
|
||||
['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) => {
|
||||
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');
|
||||
|
||||
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');
|
||||
|
||||
it('swallows every event type for Shift/Ctrl+Enter', () => {
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* 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
|
||||
* terminal-ui.js (the main pane, `app.terminal`) and terminal-split.js (Pane B, a real
|
||||
* `SplitTerminalPane`) attach. Nothing restates their predicate. What stands in for the server is
|
||||
* terminal-ui.js (the main pane, `app.terminal`) and terminal-tile.js (Pane B, a real
|
||||
* `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
|
||||
* 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');
|
||||
mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;';
|
||||
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(() => {});
|
||||
// What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket.
|
||||
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([]);
|
||||
});
|
||||
|
||||
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');
|
||||
expect(shift.focused).toBe(true);
|
||||
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 { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
@@ -6,7 +6,7 @@ import { WebServer } from '../src/web/server.js';
|
||||
const PORT = 3175;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('SplitTerminalPane in a real browser', () => {
|
||||
describe('TerminalTile in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
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
|
||||
// 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
|
||||
// SplitTerminalPane.
|
||||
// TerminalTile.
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id;
|
||||
});
|
||||
@@ -48,7 +48,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
||||
mount.style.height = '300px';
|
||||
document.body.appendChild(mount);
|
||||
|
||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
||||
const pane = new (window as any).TerminalTile(id, mount);
|
||||
pane.connect();
|
||||
|
||||
// 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;
|
||||
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
|
||||
// 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
|
||||
@@ -135,7 +135,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
||||
mount.style.height = '300px';
|
||||
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();
|
||||
|
||||
// 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';
|
||||
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 new Promise((resolve) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,305 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Tab layouts by case and ledger (`tabArrangement: 'case'` and
|
||||
* `'ledger'`, Discussion #426 options A and B).
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - By case, each case's tabs sit in ONE box, in the order the case first
|
||||
* appears in the tab order, with a colour that is a pure function of the
|
||||
* case (no storage, the same on every device).
|
||||
* - Inside a box with company, a generated `w75-api-gateway` reads `w75`, but
|
||||
* the full name stays in the DOM and in the accessible name; a custom or
|
||||
* described name is never touched.
|
||||
* - The Alt+N badges keep counting the tab order, and an incremental pass
|
||||
* patches tabs in place unless the cluster structure changed.
|
||||
* - Drag only reorders inside a box. Named groups in the vertical rail win.
|
||||
* - The ledger changes no markup at all: it is a class on #sessionTabs and
|
||||
* CSS scoped to the desktop header strip.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
|
||||
type Cluster = { key: string; label: string; color: string; ids: string[] };
|
||||
type Clusters = {
|
||||
COLORS: string[];
|
||||
colorFor: (key: string) => string;
|
||||
compute: (rows: Array<{ id: string; key: string; label: string }>) => Cluster[];
|
||||
nameSplit: (name: string, label: string) => { shown: string; hidden: string } | null;
|
||||
};
|
||||
|
||||
function loadClusters(): Clusters {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanTabClusters: Clusters }).CodemanTabClusters;
|
||||
}
|
||||
|
||||
describe('CodemanTabClusters (pure)', () => {
|
||||
const C = loadClusters();
|
||||
|
||||
it('groups by case in first-appearance order, members in tab order', () => {
|
||||
const out = C.compute([
|
||||
{ id: 'a', key: '/c/web', label: 'web' },
|
||||
{ id: 'b', key: '/c/api', label: 'api' },
|
||||
{ id: 'c', key: '/c/web', label: 'web' },
|
||||
]);
|
||||
expect(out.map((c) => [c.key, c.ids])).toEqual([
|
||||
['/c/web', ['a', 'c']],
|
||||
['/c/api', ['b']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives a session with no case a cluster of its own', () => {
|
||||
const out = C.compute([
|
||||
{ id: 'a', key: '', label: '' },
|
||||
{ id: 'b', key: '', label: '' },
|
||||
]);
|
||||
expect(out.map((c) => c.ids)).toEqual([['a'], ['b']]);
|
||||
});
|
||||
|
||||
it('colours a case from the session palette, the same way every time', () => {
|
||||
for (const key of ['/c/web', '/c/api', '/home/x/codeman-cases/long-name', '']) {
|
||||
expect(C.COLORS).toContain(C.colorFor(key));
|
||||
expect(C.colorFor(key)).toBe(C.colorFor(key));
|
||||
}
|
||||
});
|
||||
|
||||
it('only shortens a generated name that carries this case', () => {
|
||||
expect(C.nameSplit('w75-api-gateway', 'api-gateway')).toEqual({ shown: 'w75', hidden: '-api-gateway' });
|
||||
expect(C.nameSplit('s2-API-Gateway', 'api-gateway')).toEqual({ shown: 's2', hidden: '-API-Gateway' });
|
||||
expect(C.nameSplit('w75-api-gateway', 'webshop')).toBeNull();
|
||||
expect(C.nameSplit('w3-x: fix login', 'x')).toBeNull();
|
||||
expect(C.nameSplit('DocsBot', 'docsbot')).toBeNull();
|
||||
expect(C.nameSplit('w1-webshop', '')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab layouts by case and ledger (app.js)', () => {
|
||||
let CodemanApp: { prototype: Record<string, any> };
|
||||
let window: any;
|
||||
let document: Document;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
window = dom.window;
|
||||
document = window.document;
|
||||
window.setInterval = () => 0;
|
||||
window.requestAnimationFrame = () => 0;
|
||||
window.CSS = { escape: (value: string) => value };
|
||||
window.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\n' +
|
||||
read('mobile-overview.js') +
|
||||
'\n' +
|
||||
read('webview-tabs.js') +
|
||||
'\n;window.__ClusterCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = window.__ClusterCodemanApp;
|
||||
});
|
||||
|
||||
function makeApp(arrangement: 'state' | 'case' | 'ledger' | 'classic' = 'case') {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
const root = document.documentElement;
|
||||
root.setAttribute('data-tab-orientation', 'horizontal');
|
||||
root.dataset.tabRailSort = 'activity';
|
||||
root.dataset.tabArrangement = arrangement;
|
||||
document.body.innerHTML = '<div class="session-tabs-host"><div id="sessionTabs" class="session-tabs"></div></div>';
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.cases = [
|
||||
{ name: 'webshop', path: '/c/webshop' },
|
||||
{ name: 'api-gateway', path: '/c/api-gateway' },
|
||||
];
|
||||
app.sessions = new Map([
|
||||
['s1', { id: 's1', name: 'w1-webshop', status: 'idle', workingDir: '/c/webshop' }],
|
||||
['s2', { id: 's2', name: 'w75-api-gateway', status: 'busy', workingDir: '/c/api-gateway' }],
|
||||
['s3', { id: 's3', name: 'w3-webshop', status: 'idle', workingDir: '/c/webshop/sub' }],
|
||||
['s4', { id: 's4', name: 'landing', status: 'idle', workingDir: '/srv/landing/' }],
|
||||
['s5', { id: 's5', name: 'w4-webshop: fix login', status: 'busy', workingDir: '/c/webshop' }],
|
||||
]);
|
||||
app.sessionOrder = ['s1', 's2', 's3', 's4', 's5'];
|
||||
app.pendingHooks = new Map();
|
||||
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
|
||||
app.webviewOrder = ['w1'];
|
||||
app.activeSessionId = 's2';
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = null;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => false;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => {};
|
||||
app.updateTabOverflowMode = () => {};
|
||||
app.updateConnectionLines = vi.fn();
|
||||
app._applyTabEntrances = () => {};
|
||||
app._scrollActiveTabIntoView = () => {};
|
||||
app._refreshMobileOverviewIfVisible = () => {};
|
||||
app._refreshHomeSessionsIfVisible = () => {};
|
||||
app.applySidebarFilter = () => {};
|
||||
return app;
|
||||
}
|
||||
|
||||
const container = () => document.getElementById('sessionTabs')!;
|
||||
const tab = (id: string) => container().querySelector<HTMLElement>(`.session-tab[data-id="${id}"]`)!;
|
||||
const boxes = () =>
|
||||
[...container().querySelectorAll<HTMLElement>(':scope > .tab-cluster')].map((box) => ({
|
||||
name: box.querySelector('.tab-cluster-name')?.textContent ?? null,
|
||||
count: box.querySelector('.tab-cluster-count')?.textContent ?? null,
|
||||
single: box.classList.contains('tab-cluster--single'),
|
||||
rows: [...box.querySelectorAll<HTMLElement>('.session-tab')].map((t) => t.dataset.id || `web:${t.dataset.webviewId}`),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('puts each case in one box, in first-appearance order, a web tab in its own', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('tabs-clusters')).toBe(true);
|
||||
expect(boxes()).toEqual([
|
||||
// s3 sits in a subdirectory of the case and still joins it.
|
||||
{ name: 'webshop', count: '3', single: false, rows: ['s1', 's3', 's5'] },
|
||||
{ name: 'api-gateway', count: '1', single: true, rows: ['s2'] },
|
||||
// No case matches: the directory names the cluster, trailing slash and all.
|
||||
{ name: 'landing', count: '1', single: true, rows: ['s4'] },
|
||||
{ name: null, count: null, single: true, rows: ['web:w1'] },
|
||||
]);
|
||||
for (const box of container().querySelectorAll<HTMLElement>(':scope > .tab-cluster:not(.tab-cluster--web)')) {
|
||||
expect(box.getAttribute('style')).toMatch(/^--cluster-color: var\(--session-(blue|green|purple|orange|pink|yellow|red)\)$/);
|
||||
expect(box.getAttribute('role')).toBe('presentation');
|
||||
}
|
||||
});
|
||||
|
||||
it('drops the case from a generated name inside a box with company, and only there', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
const nameOf = (id: string) => tab(id).querySelector('.tab-name')!;
|
||||
expect(nameOf('s1').innerHTML).toBe('w1<span class="tab-name-case">-webshop</span>');
|
||||
expect(nameOf('s1').textContent).toBe('w1-webshop');
|
||||
expect(tab('s1').getAttribute('aria-label')).toContain('w1-webshop');
|
||||
// A described name keeps its #232 treatment.
|
||||
expect(nameOf('s5').querySelector('.tab-name-prefix')?.textContent).toBe('w4-webshop: ');
|
||||
// Alone in its box, nothing is redundant, so nothing is hidden.
|
||||
expect(nameOf('s2').innerHTML).toBe('w75-api-gateway');
|
||||
});
|
||||
|
||||
it('keeps the Alt+N badges on the tab order', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
expect(tab('s3').querySelector('.tab-number')?.textContent).toBe('3');
|
||||
expect(tab('s2').querySelector('.tab-number')?.textContent).toBe('2');
|
||||
expect(container().querySelector('.session-tab[data-webview-id="w1"] .tab-number')?.textContent).toBe('6');
|
||||
});
|
||||
|
||||
it('patches in place while the clusters stay the same, and rebuilds when they change', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const s1 = tab('s1');
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
app.sessions.get('s1').status = 'busy';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect(tab('s1')).toBe(s1);
|
||||
// s4 moves into the webshop case: a different box, so a full rebuild.
|
||||
app.sessions.get('s4').workingDir = '/c/webshop';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).toHaveBeenCalledTimes(1);
|
||||
expect(boxes()[0].rows).toEqual(['s1', 's3', 's4', 's5']);
|
||||
});
|
||||
|
||||
it('only accepts a drop inside the same box', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
app.draggedTabId = 's1';
|
||||
expect(app._isTabDropAcrossGroups(tab('s3'))).toBe(false);
|
||||
expect(app._isTabDropAcrossGroups(tab('s2'))).toBe(true);
|
||||
});
|
||||
|
||||
it('lets named groups in the vertical rail win', () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
|
||||
app.tabLayout = {
|
||||
version: 1,
|
||||
groups: [{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 's1' }] }],
|
||||
ungrouped: [],
|
||||
};
|
||||
app._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('session-tabs--grouped')).toBe(true);
|
||||
expect(container().classList.contains('tabs-clusters')).toBe(false);
|
||||
expect(container().querySelector('.tab-cluster, .tab-name-case')).toBeNull();
|
||||
});
|
||||
|
||||
it('switching back to classic leaves no boxes and no name splits', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
document.documentElement.dataset.tabArrangement = 'classic';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(container().querySelector('.tab-cluster, .tab-name-case')).toBeNull();
|
||||
expect(container().classList.contains('tabs-clusters')).toBe(false);
|
||||
});
|
||||
|
||||
it('draws the ledger with the classic markup and one class', () => {
|
||||
makeApp('classic')._fullRenderSessionTabs();
|
||||
const classic = container().innerHTML;
|
||||
makeApp('ledger')._fullRenderSessionTabs();
|
||||
expect(container().innerHTML).toBe(classic);
|
||||
expect(container().classList.contains('tabs-ledger')).toBe(true);
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab layouts by case and ledger (static)', () => {
|
||||
const css = read('styles.css');
|
||||
const mobileCss = read('mobile.css');
|
||||
|
||||
it('hides the case part of a name only in the clustered strip', () => {
|
||||
expect(css).toMatch(/\.session-tabs\.tabs-clusters \.tab-name-case \{\s*display: none;/);
|
||||
});
|
||||
|
||||
it('keeps the ledger to the desktop header strip', () => {
|
||||
const media = css.indexOf('@media (min-width: 768px) {\n .session-tabs-host > .session-tabs.tabs-ledger {');
|
||||
expect(media).toBeGreaterThan(-1);
|
||||
// Every ledger rule lives inside that block.
|
||||
const before = css.slice(0, media);
|
||||
expect(before).not.toContain('.tabs-ledger');
|
||||
});
|
||||
|
||||
it('keeps every ledger row one height and makes the active cell stand out', () => {
|
||||
const ledger = css.slice(css.indexOf('@media (min-width: 768px) {\n .session-tabs-host > .session-tabs.tabs-ledger {'));
|
||||
expect(ledger).toContain('align-items: stretch;');
|
||||
expect(ledger).toContain('min-height: 30px;');
|
||||
expect(ledger).toMatch(
|
||||
/\.tabs-ledger > \.session-tab\.active \{[^}]*box-shadow: inset 0 0 0 1px var\(--accent\), inset 4px 0 0 var\(--accent\) !important;/
|
||||
);
|
||||
});
|
||||
|
||||
it('dissolves the boxes into the one chip row on phones', () => {
|
||||
expect(mobileCss).toMatch(
|
||||
/:where\(\.header\) \.session-tabs-host > \.session-tabs\.tabs-clusters > \.tab-cluster \{\s*display: contents;/
|
||||
);
|
||||
expect(mobileCss).toMatch(/:where\(\.header\) \.tab-cluster-label \{\s*display: none;/);
|
||||
});
|
||||
});
|
||||
+10
-11
@@ -205,11 +205,10 @@ describe('vertical tab rail row order', () => {
|
||||
it('keeps the Alt+N badge on the tab index while the cards are sorted', () => {
|
||||
// `_tabIdx` counts the loop over sessionOrder, and only `style="order:…"`
|
||||
// moves the card — so the badge names a shortcut, not a row position.
|
||||
// `listOrder` is the rail sort's map, or the state grouping's when that is
|
||||
// on (test/tab-triage.test.ts); either way it only sets the inline order.
|
||||
expect(appJs).toContain('const liveIds = tabOrder.filter((id) => this.sessions.has(id));');
|
||||
expect(appJs).toContain('const railSortOrder = this._tabRailSortOrder(liveIds);');
|
||||
expect(appJs).toContain('` style="order:${listOrder.get(id)}"`');
|
||||
expect(appJs).toContain(
|
||||
'const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));'
|
||||
);
|
||||
expect(appJs).toContain('` style="order:${railSortOrder.get(id)}"`');
|
||||
expect(appJs).toMatch(/_tabIdx < 9 \? '<span class="tab-number">' \+ \(_tabIdx \+ 1\)/);
|
||||
// The loop itself still walks the user's order, which is what makes the
|
||||
// badge, drag-and-drop and the arrow-key walk agree with each other.
|
||||
@@ -220,11 +219,12 @@ describe('vertical tab rail row order', () => {
|
||||
// A session going working→idle never adds or removes a tab, so the full
|
||||
// rebuild is not reached — and a rebuild here would restart every card's
|
||||
// animation on every SSE tick anyway.
|
||||
expect(appJs).toContain('const liveIds = this.sessionOrder.filter((sid) => this.sessions.has(sid));');
|
||||
expect(appJs).toContain('const railSortOrder = this._tabRailSortOrder(liveIds);');
|
||||
expect(appJs).toContain(
|
||||
'const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid)));'
|
||||
);
|
||||
expect(appJs).toContain('if (tab.style.order !== railOrder) tab.style.order = railOrder;');
|
||||
// An empty string is what clears the property when the rail stops sorting.
|
||||
expect(appJs).toContain("const railOrder = listOrder?.has(id) ? String(listOrder.get(id)) : '';");
|
||||
expect(appJs).toContain("const railOrder = railSortOrder?.has(id) ? String(railSortOrder.get(id)) : '';");
|
||||
});
|
||||
|
||||
it('drops the drag affordance while sorting, so a card cannot snap back', () => {
|
||||
@@ -280,10 +280,9 @@ describe('vertical tab rail sort setting', () => {
|
||||
expect(html).toContain("document.documentElement.dataset.tabRailSort='activity';");
|
||||
expect(settingsUi).toContain('root.dataset.tabRailSort = sort;');
|
||||
// A sort flip leaves orientation on 'vertical' both times, so it has to
|
||||
// count as a change of its own or nothing re-renders. The same holds for a
|
||||
// tab layout or state order flip (test/tab-triage.test.ts).
|
||||
// count as a change of its own or nothing re-renders.
|
||||
expect(settingsUi).toContain(
|
||||
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder;'
|
||||
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,466 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Tab grouping by state (`tabArrangement: 'state'`, Discussion #426
|
||||
* option C): the pure layout in constants.js and the render paths in app.js.
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - The four groups and their order (needs you, waiting, working, idle) and
|
||||
* which home-screen state lands in which, including the two folds that are
|
||||
* easy to get wrong: a failed session joins "needs you", and an agent that
|
||||
* exited inside a live pane (#446) is idle even while its status says busy.
|
||||
* - It is applied as flex `order` only. The DOM stays in tab order, so Alt+N
|
||||
* badges, drag and the keyboard walk keep reading the list they always read.
|
||||
* - A state change is an INCREMENTAL pass: the tab element survives, only its
|
||||
* `order` and the heading counts move, and a group that empties loses its
|
||||
* heading.
|
||||
* - `tabArrangement: 'classic'` leaves no trace (no headings, no inline order, no
|
||||
* class), and named groups in the vertical rail win over it.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
|
||||
* `document` below is that window's.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
|
||||
type Row = { id: string; state: string; exited?: boolean; pos?: number };
|
||||
type Layout = {
|
||||
order: Map<string, number>;
|
||||
webOrder: Map<string, number>;
|
||||
groups: Array<{ key: string; label: string; count: number; headOrder: number; breakOrder: number }>;
|
||||
};
|
||||
type Triage = {
|
||||
GROUPS: Array<{ key: string; label: string }>;
|
||||
STRIDE: number;
|
||||
groupFor: (state: string, exited: boolean) => string;
|
||||
layout: (rows: Row[], webviewIds?: string[]) => Layout;
|
||||
};
|
||||
|
||||
function loadTriage(): Triage {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanTabTriage: Triage }).CodemanTabTriage;
|
||||
}
|
||||
|
||||
describe('CodemanTabTriage (pure)', () => {
|
||||
const triage = loadTriage();
|
||||
|
||||
it('has four groups, most urgent first', () => {
|
||||
expect(triage.GROUPS.map((g) => g.key)).toEqual(['needs', 'waiting', 'working', 'idle']);
|
||||
expect(triage.GROUPS.map((g) => g.label)).toEqual(['Needs you', 'Waiting', 'Working', 'Idle']);
|
||||
// Only idle is quiet: it keeps a heading element but draws no label.
|
||||
expect(triage.GROUPS.filter((g) => (g as { quiet?: boolean }).quiet).map((g) => g.key)).toEqual(['idle']);
|
||||
});
|
||||
|
||||
it('folds the six home-screen states into the four groups', () => {
|
||||
expect(triage.groupFor('needs', false)).toBe('needs');
|
||||
expect(triage.groupFor('error', false)).toBe('needs');
|
||||
expect(triage.groupFor('waiting', false)).toBe('waiting');
|
||||
expect(triage.groupFor('working', false)).toBe('working');
|
||||
expect(triage.groupFor('idle', false)).toBe('idle');
|
||||
expect(triage.groupFor('done', false)).toBe('idle');
|
||||
expect(triage.groupFor('something-new', false)).toBe('idle');
|
||||
});
|
||||
|
||||
it('puts an exited agent with idle, never with working', () => {
|
||||
expect(triage.groupFor('working', true)).toBe('idle');
|
||||
expect(triage.groupFor('idle', true)).toBe('idle');
|
||||
// A human being blocked still outranks the agent having exited.
|
||||
expect(triage.groupFor('needs', true)).toBe('needs');
|
||||
expect(triage.groupFor('waiting', true)).toBe('waiting');
|
||||
});
|
||||
|
||||
it('gives each group a band of order values and lists only non-empty groups', () => {
|
||||
const out = triage.layout([
|
||||
{ id: 'a', state: 'idle', pos: 0 },
|
||||
{ id: 'b', state: 'needs', pos: 1 },
|
||||
{ id: 'c', state: 'idle', pos: 2 },
|
||||
]);
|
||||
expect(out.groups.map((g) => [g.key, g.count])).toEqual([
|
||||
['needs', 1],
|
||||
['idle', 2],
|
||||
]);
|
||||
const needs = out.groups[0];
|
||||
const idle = out.groups[1];
|
||||
expect(out.order.get('b')).toBeGreaterThan(needs.headOrder);
|
||||
expect(out.order.get('b')).toBeLessThan(needs.breakOrder);
|
||||
// Rows keep their relative position inside a group.
|
||||
expect(out.order.get('a')).toBeLessThan(out.order.get('c')!);
|
||||
expect(out.order.get('a')).toBeGreaterThan(idle.headOrder);
|
||||
// A heading opens its band, the break closes it, and bands never overlap.
|
||||
expect(needs.breakOrder).toBeLessThan(idle.headOrder);
|
||||
expect(Math.floor(out.order.get('b')! / triage.STRIDE)).toBe(Math.floor(needs.headOrder / triage.STRIDE));
|
||||
});
|
||||
|
||||
it('ranks rows inside a group by `pos`, stably', () => {
|
||||
const out = triage.layout([
|
||||
{ id: 'x', state: 'working', pos: 5 },
|
||||
{ id: 'y', state: 'working', pos: 1 },
|
||||
{ id: 'z', state: 'working', pos: 1 },
|
||||
]);
|
||||
const sorted = [...out.order.entries()].sort((p, q) => p[1] - q[1]).map(([id]) => id);
|
||||
expect(sorted).toEqual(['y', 'z', 'x']);
|
||||
});
|
||||
|
||||
it('closes the idle group with the web tabs and counts them', () => {
|
||||
const out = triage.layout([{ id: 's', state: 'idle', pos: 0 }], ['w1', 'w2']);
|
||||
expect(out.groups).toHaveLength(1);
|
||||
expect(out.groups[0]).toMatchObject({ key: 'idle', count: 3 });
|
||||
expect(out.webOrder.get('w1')).toBeGreaterThan(out.order.get('s')!);
|
||||
expect(out.webOrder.get('w2')).toBeGreaterThan(out.webOrder.get('w1')!);
|
||||
expect(out.webOrder.get('w2')).toBeLessThan(out.groups[0].breakOrder);
|
||||
});
|
||||
|
||||
it('turns the groups the other way up with reverse, rows unchanged inside them', () => {
|
||||
const rows = [
|
||||
{ id: 'a', state: 'idle', pos: 0 },
|
||||
{ id: 'b', state: 'needs', pos: 1 },
|
||||
{ id: 'c', state: 'working', pos: 2 },
|
||||
{ id: 'd', state: 'working', pos: 3 },
|
||||
];
|
||||
const out = triage.layout(rows, ['w1'], { reverse: true });
|
||||
expect(out.groups.map((g) => g.key)).toEqual(['idle', 'working', 'needs']);
|
||||
const sorted = [...out.order.entries()].sort((p, q) => p[1] - q[1]).map(([id]) => id);
|
||||
expect(sorted).toEqual(['a', 'c', 'd', 'b']);
|
||||
// Web tabs still close the idle group, which now comes first.
|
||||
expect(out.webOrder.get('w1')).toBeLessThan(out.order.get('c')!);
|
||||
for (let i = 1; i < out.groups.length; i++) {
|
||||
expect(out.groups[i].headOrder).toBeGreaterThan(out.groups[i - 1].breakOrder);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the idle group for web tabs alone, and nothing for nothing', () => {
|
||||
expect(triage.layout([], ['w1']).groups.map((g) => g.key)).toEqual(['idle']);
|
||||
expect(triage.layout([], []).groups).toEqual([]);
|
||||
expect(triage.layout(undefined as unknown as Row[], undefined).groups).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab grouping in the render paths (app.js)', () => {
|
||||
let CodemanApp: { prototype: Record<string, any> };
|
||||
let window: any;
|
||||
let document: Document;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
window = dom.window;
|
||||
document = window.document;
|
||||
window.setInterval = () => 0;
|
||||
window.requestAnimationFrame = () => 0;
|
||||
window.CSS = { escape: (value: string) => value };
|
||||
window.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\n' +
|
||||
read('mobile-overview.js') +
|
||||
'\n' +
|
||||
read('webview-tabs.js') +
|
||||
'\n;window.__TriageCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = window.__TriageCodemanApp;
|
||||
});
|
||||
|
||||
function makeApp(arrangement: 'state' | 'case' | 'ledger' | 'classic' = 'state') {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
const root = document.documentElement;
|
||||
root.setAttribute('data-tab-orientation', 'horizontal');
|
||||
root.dataset.tabRailSort = 'activity';
|
||||
root.dataset.tabArrangement = arrangement;
|
||||
delete root.dataset.tabStateOrder;
|
||||
document.body.innerHTML = '<div class="session-tabs-host"><div id="sessionTabs" class="session-tabs"></div></div>';
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map([
|
||||
['s1', { id: 's1', name: 'w1-alpha', status: 'idle' }],
|
||||
['s2', { id: 's2', name: 'w2-beta', status: 'busy' }],
|
||||
['s3', { id: 's3', name: 'w3-gamma', status: 'idle' }],
|
||||
['s4', { id: 's4', name: 'w4-delta', status: 'busy' }],
|
||||
]);
|
||||
app.sessionOrder = ['s1', 's2', 's3', 's4'];
|
||||
app.pendingHooks = new Map([
|
||||
['s3', new Set(['permission_prompt'])],
|
||||
['s1', new Set(['idle_prompt'])],
|
||||
]);
|
||||
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
|
||||
app.webviewOrder = ['w1'];
|
||||
app.activeSessionId = 's2';
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = null;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => false;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => {};
|
||||
app.updateTabOverflowMode = () => {};
|
||||
app.updateConnectionLines = vi.fn();
|
||||
app._applyTabEntrances = () => {};
|
||||
app._scrollActiveTabIntoView = () => {};
|
||||
app._refreshMobileOverviewIfVisible = () => {};
|
||||
app._refreshHomeSessionsIfVisible = () => {};
|
||||
app.applySidebarFilter = () => {};
|
||||
return app;
|
||||
}
|
||||
|
||||
const container = () => document.getElementById('sessionTabs')!;
|
||||
const tab = (id: string) => container().querySelector<HTMLElement>(`.session-tab[data-id="${id}"]`)!;
|
||||
const orderOf = (el: HTMLElement) => Number(el.style.order);
|
||||
/** What the eye sees: every ordered child, by `order`, as `head:<key>` / `<id>`. */
|
||||
const visual = () =>
|
||||
[...container().children]
|
||||
.filter((el) => !(el as HTMLElement).classList.contains('tab-triage-break'))
|
||||
.sort((a, b) => orderOf(a as HTMLElement) - orderOf(b as HTMLElement))
|
||||
.map((el) => {
|
||||
const h = el as HTMLElement;
|
||||
if (h.classList.contains('tab-triage-head')) return `head:${h.dataset.triageGroup}`;
|
||||
return h.dataset.webviewId ? `web:${h.dataset.webviewId}` : h.dataset.id!;
|
||||
});
|
||||
const heads = () =>
|
||||
[...container().querySelectorAll<HTMLElement>(':scope > .tab-triage-head')]
|
||||
.sort((a, b) => orderOf(a) - orderOf(b))
|
||||
.map((h) =>
|
||||
h.classList.contains('tab-triage-head--quiet')
|
||||
? `${h.dataset.triageGroup}:quiet`
|
||||
: `${h.querySelector('.tab-triage-label')!.textContent}:${h.querySelector('.tab-triage-count')!.textContent}`
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('draws a heading per non-empty state and places every tab in its group', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('tabs-triage')).toBe(true);
|
||||
expect(heads()).toEqual(['Needs you:1', 'Waiting:1', 'Working:2', 'idle:quiet']);
|
||||
// The idle row keeps its heading as an anchor, with nothing drawn in it.
|
||||
expect(container().querySelector('.tab-triage-head[data-triage-group="idle"]')!.textContent).toBe('');
|
||||
expect(visual()).toEqual([
|
||||
'head:needs',
|
||||
's3',
|
||||
'head:waiting',
|
||||
's1',
|
||||
'head:working',
|
||||
's2',
|
||||
's4',
|
||||
'head:idle',
|
||||
'web:w1',
|
||||
]);
|
||||
// One row break per heading; headings and breaks stay out of the tablist.
|
||||
expect(container().querySelectorAll(':scope > .tab-triage-break')).toHaveLength(4);
|
||||
for (const el of container().querySelectorAll(':scope > .tab-triage-head, :scope > .tab-triage-break')) {
|
||||
expect(el.getAttribute('aria-hidden')).toBe('true');
|
||||
}
|
||||
});
|
||||
|
||||
it("puts needs you in the bottom row with tabStateOrder 'urgent-last'", () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.dataset.tabStateOrder = 'urgent-last';
|
||||
app._fullRenderSessionTabs();
|
||||
expect(heads()).toEqual(['idle:quiet', 'Working:2', 'Waiting:1', 'Needs you:1']);
|
||||
expect(visual()).toEqual([
|
||||
'head:idle',
|
||||
'web:w1',
|
||||
'head:working',
|
||||
's2',
|
||||
's4',
|
||||
'head:waiting',
|
||||
's1',
|
||||
'head:needs',
|
||||
's3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks the first row heading as the lead, in either state order', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const lead = () =>
|
||||
[...container().querySelectorAll<HTMLElement>(':scope > .tab-triage-head--lead')].map((h) => h.dataset.triageGroup);
|
||||
expect(lead()).toEqual(['needs']);
|
||||
document.documentElement.dataset.tabStateOrder = 'urgent-last';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(lead()).toEqual(['idle']);
|
||||
});
|
||||
|
||||
it('keeps the DOM, and with it the Alt+N badges, in tab order', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
const domOrder = [...container().querySelectorAll<HTMLElement>('.session-tab[data-id]')].map((t) => t.dataset.id);
|
||||
expect(domOrder).toEqual(['s1', 's2', 's3', 's4']);
|
||||
expect(tab('s3').querySelector('.tab-number')?.textContent).toBe('3');
|
||||
});
|
||||
|
||||
it('moves a tab between groups on an incremental pass, without rebuilding it', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const s2 = tab('s2');
|
||||
const fullRender = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
// s2 finishes its turn; the dialog on s3 is answered.
|
||||
app.sessions.get('s2').status = 'idle';
|
||||
app.pendingHooks.delete('s3');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(fullRender).not.toHaveBeenCalled();
|
||||
expect(tab('s2')).toBe(s2);
|
||||
expect(heads()).toEqual(['Waiting:1', 'Working:1', 'idle:quiet']);
|
||||
expect(container().querySelector('.tab-triage-head[data-triage-group="needs"]')).toBeNull();
|
||||
expect(container().querySelector('.tab-triage-break[data-triage-group="needs"]')).toBeNull();
|
||||
expect(visual()).toEqual(['head:waiting', 's1', 'head:working', 's4', 'head:idle', 's2', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it('reconciles headings in place: an unchanged pass rewrites nothing', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
app._renderSessionTabsImmediate();
|
||||
const head = container().querySelector('.tab-triage-head[data-triage-group="working"]');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(container().querySelector('.tab-triage-head[data-triage-group="working"]')).toBe(head);
|
||||
expect(container().querySelectorAll(':scope > .tab-triage-head')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('files an agent that exited in a live pane under idle, a failed session under needs you', () => {
|
||||
const app = makeApp();
|
||||
app.pendingHooks = new Map();
|
||||
app.sessions.get('s2').paneExit = { at: 1, status: 0 };
|
||||
app.sessions.get('s1').status = 'error';
|
||||
app._fullRenderSessionTabs();
|
||||
expect(visual()).toEqual(['head:needs', 's1', 'head:working', 's4', 'head:idle', 's2', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it("leaves no trace with tabArrangement 'classic'", () => {
|
||||
makeApp('classic')._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
expect(container().querySelector('.tab-triage-head, .tab-triage-break')).toBeNull();
|
||||
expect(container().innerHTML).not.toContain('order:');
|
||||
expect(container().querySelector<HTMLElement>('.session-tab[data-webview-id]')!.style.order).toBe('');
|
||||
});
|
||||
|
||||
it('switching off removes the headings and every inline order it wrote', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
document.documentElement.dataset.tabArrangement = 'classic';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(container().querySelector('.tab-triage-head, .tab-triage-break')).toBeNull();
|
||||
for (const el of container().querySelectorAll<HTMLElement>('.session-tab')) expect(el.style.order).toBe('');
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
});
|
||||
|
||||
it('ranks rows inside each section by the activity sort on a sorted rail', () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
|
||||
app.pendingHooks = new Map();
|
||||
// Both working; s4 has been running longer, so the activity sort puts it first.
|
||||
app.sessions.get('s2').lastSubmitAt = 2_000;
|
||||
app.sessions.get('s4').lastSubmitAt = 1_000;
|
||||
app._fullRenderSessionTabs();
|
||||
expect(visual()).toEqual(['head:working', 's4', 's2', 'head:idle', 's1', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it('keeps tab order inside each row on the header strip', () => {
|
||||
const app = makeApp();
|
||||
app.pendingHooks = new Map();
|
||||
app.sessions.get('s2').lastSubmitAt = 2_000;
|
||||
app.sessions.get('s4').lastSubmitAt = 1_000;
|
||||
app._fullRenderSessionTabs();
|
||||
expect(visual()).toEqual(['head:working', 's2', 's4', 'head:idle', 's1', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it('lets named groups in the vertical rail win', () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
|
||||
app.tabLayout = {
|
||||
version: 1,
|
||||
groups: [{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 's1' }] }],
|
||||
ungrouped: [],
|
||||
};
|
||||
app._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('session-tabs--grouped')).toBe(true);
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
expect(container().querySelector('.tab-triage-head')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a drop into another group and allows one inside the same group', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
app.draggedTabId = 's2';
|
||||
expect(app._isTabDropAcrossGroups(tab('s4'))).toBe(false);
|
||||
expect(app._isTabDropAcrossGroups(tab('s1'))).toBe(true);
|
||||
expect(app._isTabDropAcrossGroups(tab('s3'))).toBe(true);
|
||||
document.documentElement.dataset.tabArrangement = 'classic';
|
||||
app._fullRenderSessionTabs();
|
||||
expect(app._isTabDropAcrossGroups(tab('s1'))).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades to the flat strip when mobile-overview.js is stale or missing', () => {
|
||||
const app = makeApp();
|
||||
app._mobileOverviewState = undefined;
|
||||
app._fullRenderSessionTabs();
|
||||
expect(container().querySelector('.tab-triage-head')).toBeNull();
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab grouping wiring (static)', () => {
|
||||
const html = read('index.html');
|
||||
const css = read('styles.css');
|
||||
const mobileCss = read('mobile.css');
|
||||
|
||||
it('stamps data-tab-arrangement and data-tab-state-order before first paint', () => {
|
||||
expect(html).toContain(
|
||||
"dataset.tabArrangement=(T==='case'||T==='ledger'||T==='classic')?T:'state'"
|
||||
);
|
||||
expect(html).toContain("dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first'");
|
||||
// The catch branch (localStorage threw) must set both too.
|
||||
expect(html).toContain(
|
||||
"document.documentElement.dataset.tabArrangement='state';document.documentElement.dataset.tabStateOrder='urgent-first';"
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the four layouts with "By state" as the default, and the state order', () => {
|
||||
expect(html).toMatch(
|
||||
/<select id="appSettingsTabArrangement"[^>]*>\s*<option value="state">By state \(default\)<\/option>\s*<option value="case">[^<]+<\/option>\s*<option value="ledger">[^<]+<\/option>\s*<option value="classic">Classic \(as before\)<\/option>/
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<select id="appSettingsTabStateOrder"[^>]*>\s*<option value="urgent-first">Needs you on top \(default\)<\/option>\s*<option value="urgent-last">/
|
||||
);
|
||||
});
|
||||
|
||||
it('only shows row breaks in a wrapping header strip', () => {
|
||||
expect(css).toMatch(/\.tab-triage-break \{\s*display: none;/);
|
||||
expect(css).toContain(
|
||||
'.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-break'
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the rows after the first start under the brand, labels left-aligned', () => {
|
||||
expect(css).toMatch(
|
||||
/\.header:has\(> \.session-tabs-host > \.session-tabs\.tabs-triage:is\(\.tabs-auto-wrap, \.tabs-two-rows\)\) > \.header-brand \{\s*position: absolute;/
|
||||
);
|
||||
expect(css).toMatch(/> \.tab-triage-head \{\s*justify-content: flex-start;/);
|
||||
expect(css).toMatch(
|
||||
/> \.tab-triage-head--lead \{\s*width: auto;\s*margin-left: calc\(var\(--tab-triage-brand, 100px\) - var\(--tab-triage-gutter, 92px\)\);/
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the headings on phones, whose strip stays one scrolling row', () => {
|
||||
expect(mobileCss).toMatch(/:where\(\.header\) \.tab-triage-head \{\s*display: none;/);
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,13 @@ function fakeTerminal(options: Record<string, unknown> = {}) {
|
||||
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 teammateFits: ReturnType<typeof vi.fn>[] = [];
|
||||
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 = {
|
||||
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
|
||||
// it moves cols/rows, and `applyTerminalFontWeights` now routes its refit
|
||||
// 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', () => {
|
||||
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', () => {
|
||||
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
|
||||
// Port: N/A (no server/browser; SplitTerminalPane is loaded via `vm`, like
|
||||
// test/terminal-tile-unit.test.ts
|
||||
// Port: N/A (no server/browser; TerminalTile is loaded via `vm`, like
|
||||
// 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
|
||||
// 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
|
||||
@@ -15,7 +16,7 @@
|
||||
// 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
|
||||
// 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 { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
@@ -69,13 +70,11 @@ type PaneUnderTest = {
|
||||
};
|
||||
|
||||
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. */
|
||||
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({
|
||||
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
|
||||
@@ -104,25 +103,24 @@ function loadSplitTerminalPane() {
|
||||
else clearTimeout(id as Parameters<typeof clearTimeout>[0]);
|
||||
},
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
requestAnimationFrame: (fn: () => void) => rafQueue.push(fn),
|
||||
// The constants.js globals the module reads at call time.
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
TERMINAL_TAIL_SIZE,
|
||||
});
|
||||
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
|
||||
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context);
|
||||
return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
||||
.SplitTerminalPane;
|
||||
return (context.window as { TerminalTile: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
||||
.TerminalTile;
|
||||
}
|
||||
|
||||
const SplitTerminalPane = loadSplitTerminalPane();
|
||||
const TerminalTile = loadTerminalTile();
|
||||
|
||||
function makePane(
|
||||
mode = 'claude',
|
||||
mount: unknown = {},
|
||||
opts: { detachedSessions?: Set<string> } = {}
|
||||
): PaneUnderTest & { terminal: FakeTerminal } {
|
||||
const pane = new SplitTerminalPane('s1', mount, { mode, ...opts });
|
||||
const pane = new TerminalTile('s1', mount, { mode, ...opts });
|
||||
pane.terminal = {
|
||||
// xterm invokes a write's callback once everything before it is parsed.
|
||||
write: vi.fn((_data: string, done?: () => void) => done?.()),
|
||||
@@ -175,19 +173,44 @@ function deferred<T>() {
|
||||
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. */
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
rafQueue.length = 0;
|
||||
deadlines.length = 0;
|
||||
clock = 0;
|
||||
});
|
||||
|
||||
describe('SplitTerminalPane.destroy()', () => {
|
||||
describe('TerminalTile.destroy()', () => {
|
||||
it('nulls every WebSocket handler, onclose included, before closing the socket', () => {
|
||||
const pane = makePane();
|
||||
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 () => {
|
||||
const pane = makePane();
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('one'));
|
||||
@@ -219,7 +242,8 @@ describe('SplitTerminalPane server-refresh single-flight', () => {
|
||||
await settle();
|
||||
|
||||
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._bufferLoading).toBe(false);
|
||||
});
|
||||
@@ -231,7 +255,7 @@ describe('SplitTerminalPane server-refresh single-flight', () => {
|
||||
pane._refreshBuffer();
|
||||
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 () => {
|
||||
@@ -262,40 +286,88 @@ describe('SplitTerminalPane server-refresh single-flight', () => {
|
||||
second.resolve(jsonResponse('replay-2'));
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenLastCalledWith('replay-2');
|
||||
expect(screenWrites(pane).at(-1)).toBe('replay-2');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(pane._bufferLoading).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();
|
||||
// Three chunks: two full ones plus a tail, so the last two are queued on
|
||||
// requestAnimationFrame and the replay is mid-write after the fetch lands.
|
||||
const xterm = holdParses(pane);
|
||||
// Three slices: two full ones plus a tail.
|
||||
const big = 'x'.repeat(TERMINAL_CHUNK_SIZE * 2 + 5);
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(big));
|
||||
|
||||
pane._refreshBuffer();
|
||||
await settle();
|
||||
expect(pane.terminal.write).toHaveBeenCalledTimes(1);
|
||||
expect(rafQueue).toHaveLength(1);
|
||||
// Every slice queued at once, then the empty write whose callback ends the
|
||||
// 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);
|
||||
|
||||
// A refresh mid-write must not clear the terminal under the chunks still
|
||||
// to come, nor start a second fetch.
|
||||
// A refresh mid-parse must not clear the terminal under the replay, nor
|
||||
// start a second fetch.
|
||||
pane._refreshBuffer();
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('after'));
|
||||
rafQueue.shift()!();
|
||||
rafQueue.shift()!();
|
||||
xterm.parse();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledTimes(4);
|
||||
expect(pane.terminal.write).toHaveBeenLastCalledWith('after');
|
||||
// Parsed: the coalesced refresh runs now, once.
|
||||
expect(pane.terminal.clear).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);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
const pane = makePane('shell');
|
||||
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 () => {
|
||||
const pane = makePane('shell');
|
||||
const term = pane.terminal;
|
||||
const xterm = holdParses(pane);
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
@@ -515,24 +588,26 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
await settle();
|
||||
|
||||
// 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');
|
||||
expect(bigReplay.length).toBeGreaterThan(TERMINAL_CHUNK_SIZE * 2);
|
||||
clock = 2; // the response arrives: this is the cutoff
|
||||
response.resolve(jsonResponse(bigReplay));
|
||||
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;
|
||||
pane._onLiveOutput('late');
|
||||
expect(term.write).not.toHaveBeenCalledWith('late');
|
||||
|
||||
rafQueue.shift()!();
|
||||
rafQueue.shift()!();
|
||||
// The replay parsed, then the pull's own settle write before it scrolls.
|
||||
xterm.parse();
|
||||
await settle();
|
||||
xterm.parse();
|
||||
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.
|
||||
expect(written.indexOf('early')).toBeLessThan(written.indexOf('\x1bc'));
|
||||
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)', () => {
|
||||
// connect() needs a whole xterm to run, so its wiring is pinned by source
|
||||
// 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._onLiveClear();');
|
||||
expect(connect).not.toContain('this.terminal.clear();');
|
||||
// The tests below drive the close through _onSocketClosed() directly.
|
||||
expect(connect).toContain('this.ws.onclose = () => this._onSocketClosed();');
|
||||
// The tests below drive the close through _onSocketClosed() directly; the
|
||||
// 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', () => {
|
||||
@@ -746,9 +830,9 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
void pane._pullHistory();
|
||||
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]);
|
||||
expect(writes.at(-1)).toEqual(expect.stringMatching(/Pane B disconnected/));
|
||||
expect(writes.at(-1)).toEqual(expect.stringMatching(/\[disconnected/));
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
|
||||
});
|
||||
|
||||
@@ -825,7 +909,7 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
second.resolve(jsonResponse('second'));
|
||||
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.lastIndexOf('second')).toBe(writes.length - 2);
|
||||
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 xterm = holdParses(pane);
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
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');
|
||||
response.resolve(jsonResponse(bigReplay));
|
||||
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();
|
||||
rafQueue.shift()!();
|
||||
rafQueue.shift()!();
|
||||
xterm.parse();
|
||||
await settle();
|
||||
xterm.parse();
|
||||
await pull;
|
||||
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
const writes = screenWrites(pane);
|
||||
expect(writes[0]).toBe('\x1bc');
|
||||
expect(writes.filter(isMarker)).toHaveLength(1);
|
||||
expect(isMarker(writes.at(-1))).toBe(true);
|
||||
@@ -1003,7 +1089,7 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
await settle();
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user