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
+33 -3
View File
@@ -20,10 +20,19 @@ import "@xterm/xterm/css/xterm.css";
interface TerminalViewProps {
runId: string;
wsBaseUrl: string;
/** The pane's process ended (a double Ctrl+C, `exit`, a `claude` that quit).
* There is nothing left to attach to, so the owner swaps this terminal back
* out for its run setup form instead of leaving a dead pane on screen. */
onExit?: () => void;
}
export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Read through a ref: an inline `onExit` from the parent changes identity on
// every render, and as an effect dep it would tear the terminal down and
// re-attach on each one.
const onExitRef = useRef(onExit);
onExitRef.current = onExit;
useEffect(() => {
// An explicit fontFamily pins every glyph to one bundled webfont. Left
@@ -35,8 +44,15 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
// already bundled for the rest of the UI; main.tsx additionally imports
// its `vietnamese` subset so this covers the diacritics too.
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
// cursor to column 0 on a line feed — and xterm's own docs say not to use
// it on a PTY, where termios already does that translation for the streams
// that want it. tmux scrolls a pane by emitting a bare LF and then keeps
// drawing at the column it was on; with this on, that draw landed at
// column 0 instead, stamping the first characters of the line into the
// left margin of the rows below. That is the torn left edge of issue #33,
// and why it only showed up once content scrolled.
const term = new Terminal({
convertEol: true,
fontSize: 13,
cursorBlink: true,
fontFamily: '"JetBrains Mono", monospace',
@@ -63,6 +79,10 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
}
fit.fit();
// Take the keyboard as soon as the pane is on screen: the terminal IS the
// page here, and having to click it first meant the first thing typed went
// nowhere. A click elsewhere still moves focus away, as in any editor.
term.focus();
// When the pane's program has NOT enabled wheel reporting, xterm falls back
// to converting each wheel notch on an alt-screen buffer into a cursor-key
@@ -116,7 +136,16 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
let disposed = false;
const connect = () => {
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
// The attach itself carries this terminal's size. tmux sizes the window
// to the client that attached most recently, so spawning the attach at a
// default 80×24 and correcting it from the first `resize` message a beat
// later makes the pane's program (claude) redraw its whole UI at 80
// columns first — and everything painted at that width stays in the
// scrollback as torn fragments along the left margin, visible as soon as
// you scroll back (issue #33). Happens on every attach: page load,
// lane switch, and each 1.5 s reconnect.
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
ws = sock;
// Server sends PTY bytes as binary frames — default binaryType ("blob")
// would hand onmessage a Blob that the string checks below never match,
@@ -143,6 +172,7 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
if (msg.type === "exit") {
ended = true;
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
onExitRef.current?.();
}
return;
} catch {