feat(run,ui): make the lane console usable — terminal focus, live-exit recovery, auto permission mode, model aliases

Terminal (issue #33):
- `convertEol` is gone from xterm. It rewrote every bare LF as CR+LF, so a
  tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the
  column it was on) had the rest of that draw land at column 0, stamping a
  line's first characters into the left margin of the rows below — the torn
  left edge that only showed up once content scrolled.
- The attach carries the client's real size (`?cols=&rows=`, `sizeFromQuery`
  in `server/websocket.js`, bounded because it reaches a TIOCSWINSZ ioctl).
  `attach()` spawned `tmux attach-session` at a hardcoded 80x24 on every
  attach, and tmux sizes the window to the newest client, so `claude` redrew
  its whole UI at 80 columns until the first `resize` message landed.
- The terminal takes the keyboard on mount, so typing reaches tmux without
  clicking the pane first.
- A pane that ends (double Ctrl+C, `exit`, a crash) reports itself through a
  new `onExit` prop and `LaneConsolePane` runs `newRun()`, so the console
  returns to the setup form instead of leaving a dead pane behind a
  "New run" button.

Pane environment:
- `server/lib/desktop-env.js` forwards the graphical session's DISPLAY /
  WAYLAND_DISPLAY / XAUTHORITY / XDG_RUNTIME_DIR (from
  `systemctl --user show-environment`, this process's own values winning)
  into the pane. The server normally runs as a systemd user service with none
  of them, so `claude` there could not read the clipboard and answered "no
  image found in clipboard" on every image paste.

Run setup:
- `auto` is a permission mode and is now the default everywhere — allowlists,
  the `--permission-mode` fallback, the select, and the pane's initial state.
- The model picker sends the CLI's aliases (`fable`/`opus`/`sonnet`/`haiku`)
  instead of pinned ids. `claude --model` resolves an alias to the latest
  model itself, so a release like Fable 5.1 needs no edit here; pricing is
  unaffected because it keys off the resolved id the transcript reports.
- **Create/update readme file** moved from the Add-lane modal into the lane's
  Tools menu: creating a lane and writing its docs are separate decisions,
  and the second is worth re-running long after the first.
- The Workspace's no-lane state is a real banner; the lane strip (add tile
  included) renders only once a lane exists.

Refs #32, #33.

Tests: npm run test:server (1828 pass), client vitest (558 pass).
This commit is contained in:
2026-09-21 17:03:27 +07:00
parent 64017c6ca5
commit f77049c238
30 changed files with 579 additions and 204 deletions
+63
View File
@@ -0,0 +1,63 @@
/**
* @file desktop-env.test.js
* @description Unit tests for the graphical-session variables forwarded into a
* lane's tmux pane. Without them a `claude` started by the dashboard has no
* DISPLAY/WAYLAND_DISPLAY at all and answers "no image found in clipboard" on
* a paste, while the same CLI works in a normal terminal.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const { desktopEnv, FORWARDED, __setExecImpl } = require("../lib/desktop-env");
const saved = {};
describe("desktopEnv", () => {
beforeEach(() => {
for (const name of FORWARDED) {
saved[name] = process.env[name];
delete process.env[name];
}
});
afterEach(() => {
for (const name of FORWARDED) {
if (saved[name] === undefined) delete process.env[name];
else process.env[name] = saved[name];
}
});
it("reads the systemd user manager's environment", () => {
__setExecImpl(
() => "XDG_RUNTIME_DIR=/run/user/1001\nDISPLAY=:0\nXAUTHORITY=/run/user/1001/gdm/Xauthority\n"
);
assert.deepEqual(desktopEnv(), {
DISPLAY: ":0",
XAUTHORITY: "/run/user/1001/gdm/Xauthority",
XDG_RUNTIME_DIR: "/run/user/1001",
});
});
it("prefers this process's own value — a checkout run from a terminal has the real one", () => {
process.env.DISPLAY = ":1";
__setExecImpl(() => "DISPLAY=:0\n");
assert.equal(desktopEnv().DISPLAY, ":1");
});
it("forwards nothing when there is no systemd user manager (macOS, a container)", () => {
__setExecImpl(() => {
throw new Error("systemctl: not found");
});
assert.deepEqual(desktopEnv(), {});
});
it("forwards only the clipboard-related names, never the whole session", () => {
__setExecImpl(() => "DISPLAY=:0\nSSH_AUTH_SOCK=/tmp/agent\nPATH=/usr/bin\n");
assert.deepEqual(Object.keys(desktopEnv()), ["DISPLAY"]);
});
it("drops a value carrying a newline — it becomes a tmux -e argv", () => {
__setExecImpl(() => "");
process.env.WAYLAND_DISPLAY = "wayland-0\ninjected";
assert.equal(desktopEnv().WAYLAND_DISPLAY, undefined);
});
});
+4 -1
View File
@@ -193,7 +193,10 @@ describe("pty-run", () => {
// `--dangerously-skip-permissions` (which would enable it) is never passed.
assert.ok(!newSessionArgv.includes("--dangerously-skip-permissions"));
const modeAt = newSessionArgv.indexOf("--permission-mode");
assert.equal(newSessionArgv[modeAt + 1], "acceptEdits");
// `auto` is the default when the caller named no mode: the CLI's own
// classifier asks for what it reads as risky and stays out of the way
// otherwise.
assert.equal(newSessionArgv[modeAt + 1], "auto");
});
it("spawnRun appends a positional initial prompt after argv flags", () => {
@@ -0,0 +1,31 @@
/**
* @file websocket-pty-size.test.js
* @description Regression test for issue #33: the `/ws-pty/:runId` attach
* spawns `tmux attach-session` at the size the CLIENT reports in the attach
* URL, not a hardcoded 80×24. tmux sizes the window to the newest client, so
* attaching at 80 columns and fixing it from the first `resize` message made
* `claude` redraw its whole UI at 80 columns — wreckage that stays in the
* scrollback and shows up as torn left-margin fragments when scrolling back.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { sizeFromQuery } = require("../websocket");
const query = (search) => new URL(`http://x/ws-pty/agenthub-lane-1${search}`).searchParams;
describe("sizeFromQuery — the attach carries the client's real size", () => {
it("reads the terminal's own cols/rows", () => {
assert.deepEqual(sizeFromQuery(query("?cols=226&rows=29")), { cols: 226, rows: 29 });
});
it("falls back to 80x24 when the client named no size", () => {
assert.deepEqual(sizeFromQuery(query("")), { cols: 80, rows: 24 });
});
it("refuses anything out of bounds — this number reaches an ioctl", () => {
assert.deepEqual(sizeFromQuery(query("?cols=0&rows=-5")), { cols: 80, rows: 24 });
assert.deepEqual(sizeFromQuery(query("?cols=99999&rows=99999")), { cols: 80, rows: 24 });
assert.deepEqual(sizeFromQuery(query("?cols=abc&rows=12.5")), { cols: 80, rows: 12 });
});
});