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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user