`command FOO=1 claude` and `nohup FOO=1 claude` are invalid shells (they
exec `FOO=1` as the program, exit 127), but the resolver skipped the
assignment unconditionally after any wrapper. Assignment prefixes are
only real at the start of a simple command or after a wrapper that itself
interprets them as env vars (env, sudo) or a keyword that restarts the
assignment grammar (time) — not after command, nohup, or a package
manager's subcommand, which just exec their next argument as the program.
Attach an accepts_assignment flag to each wrapper transition in both the
Rust and TS resolvers and gate the assignment-skip loop on the flag from
the most recently consumed wrapper. Extends the shared fixture with the
two reported cases plus every other wrapper transition and several nested
combinations (sudo+time valid, sudo+command / command+nohup / time+command
invalid, nohup+env valid) to lock the per-wrapper policy in for both
languages.
Fixes six issues in the shared command resolver (Rust display.rs and
its TS mirror toolIcon.ts), all locked in by shared fixture cases:
- An unescaped newline now ends resolution like `;` does, instead of
being folded in as whitespace; a backslash-newline is a line
continuation, removed entirely rather than embedded or splitting
the word.
- Assignment recognition (`FOO=bar`) now requires the `=` to come from
plain, unquoted/unescaped source text. `"FOO=1"` and `FOO\=1` are
executable words named `FOO=1`, not assignments — quote removal was
losing that provenance.
- `command -v`/`-V` (lookup only, no execution), `env -S` (embeds a
command string) and `npx -c`/`--call` (same) no longer resolve
through to a program they don't actually run. Restricted bash
`time`'s options to `-p`, the only flag the keyword itself parses
(`-o` etc. belong to external GNU time) — this corrects a
previously-wrong fixture case.
- `env`, `sudo` and `command`'s options may now bundle (`env -iv`,
`sudo -nE`) or take an attached value (`sudo -uroot`), the way real
getopt-based tools do, with missing-operand forms safely rejected.
- pnpm/yarn `dlx` and `bun x`/`bunx` now consume their own trailing
options (`--package foo`/`--package=foo`, `--bun`) before the
executable, matching each tool's documented CLI syntax.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
command_program/command_tool whitespace-split and only skipped whole
wrapper words, so quoted paths broke, sudo -u/env -i/nohup/time/npx
--yes/pnpm|yarn --silent dlx/bun x were rejected, and pnpm claude /
yarn claude (a package.json script, not `dlx claude`) resolved as a
Claude launch. Replace it with a tokenizer that honours quoting and
escaping without evaluating any expansion, and per-wrapper option
tables (env, sudo, command, nohup, time, npx, pnpm, yarn, bun) that
require dlx/x before looking through a package-manager wrapper.
Ambiguous shell grammar (pipelines, list operators, redirections,
substitution, unterminated quotes) is rejected outright rather than
guessed through; command_program still degrades to the naive first
word for display, but command_tool returns None.
Mirror the same resolver in web/src/toolIcon.ts and add
fixtures/command-resolution.json as a parity fixture both test
suites assert against.
The TUI's structured-view gate already delegates to command_tool, the
same resolver web/spawnForm.ts uses via splitCommand, so paths, env
assignments, wrappers, and configured presets already resolve like
daemon dispatch. Add coverage for those forms plus stale-selection
clearing on both preset switches and custom-command edits, so a
regression back to a naive basename match would be caught.
follow_selection=false stored scroll as a raw rendered-line offset into
lines rebuilt fresh every frame. A ProjectionUpdate::Patch growing a
streaming row above the viewport shifted every line below it, so the
raw offset kept pointing at a numerically-same but visually different
line and the viewport drifted even though the operator had scrolled
away deliberately.
Cache each frame's row-id-per-line layout and, before applying scroll
next frame, translate the old raw offset into (row id, intra-row line)
against that cache, then relocate it in the freshly rebuilt lines. Rows
above or intersecting the viewport growing, shrinking, or being
patched now shift the offset instead of the visible content. Falls
back to the nearest surviving neighbor row when the anchor row itself
is removed, and to the clamped raw offset when nothing nearby
survived. follow_selection and bottom-follow are unaffected — they
already recompute scroll from the live selection/tail each frame.
ConnectionPool.reconcileViews re-focused a repaired connection whenever
focusedId named it, but a same-tab sidebar preview moves the keyboard
into the sidebar without ever calling setFocus (multiview.ts's
syncFocus skips it while previewing) — so focusedId keeps naming the
pane the operator was in before the preview, even though the DOM
doesn't agree. Replacing that pane's connection during exactly that
window called onMount({focus:true}) and yanked the keyboard back out
of the sidebar mid-navigation.
Capture existing.el.contains(document.activeElement) before dispose()/
el.remove() detach it (afterward the browser has already moved
activeElement to <body>, too late to tell), and require both that and
focusedId===id to re-focus. document.body and a null activeElement
both naturally fail .contains(), so "nothing focused" and "focus
elsewhere" both correctly skip.
Regression tests: 3 new (real-focus requirement, same-tab preview
race, descendant focus, body-as-unfocused) — 498 tests total.
Three more fixes from acceptance review:
- structured.ts's header omitted the .sessions-btn attach.ts's terminal
header carries. The sidebar is off-canvas on mobile, so a structured
session had no way to reach it — add the same button and
requestToggleDrawer wiring.
- ConnectionPool.reconcileViews always remounted a repaired connection
with focus:false. If the mistyped pane was the one actually focused,
disposing it dropped DOM focus while `focusedId` still named it. Pass
`focus: this.focusedId === id` instead — `focusedId` only ever names a
real focus (multiview.ts skips setFocus while previewing), so a
preview or a merely-visible split neighbour still comes back
unfocused.
- onMount's deferred requestAnimationFrame focus had no way to tell it
had been superseded by an unmount (or a remount elsewhere) before it
fired, so a parked pane could steal focus a frame later. A token
bumped on every mount/unmount lets the callback recognize it's stale
and no-op.
Regression tests: 7 new (structured.test.ts, connections.test.ts). 495
tests total.
Three fixes from acceptance review:
- ConnectionPool.mount cached a connection's kind (terminal/structured)
permanently from whatever `info` it saw first. A deep link or restored
layout mounts before the catalog snapshot names a session's view, so
`info` is undefined and a structured session got an xterm connection
forever. The pool now tracks the view each cached connection was built
for, subscribes to the catalog itself, and replaces a mistyped
connection in place the moment the real view resolves — without
downgrading a resolved connection just because a later caller couldn't
resolve the session, and without treating a session merely absent from
a snapshot (mid-removal) as "Terminal".
- The approval card offered all three decisions unconditionally. Per
the daemon's decision mapping (session.rs's permission_outcome), a
decision with no matching PermissionOptionKind in the request's own
options resolves Cancelled silently — so "Allow for session" could
look like it worked while the daemon withdrew it. Buttons are now
gated on the options actually offered.
- Clicking a decision disabled the buttons and never re-enabled them if
the POST failed. The decision path is async now: a rejection restores
the buttons and surfaces the error only if the approval is still the
pending one — a superseded/resolved approval's stale rejection is a
no-op, not a wrongly revived control.
Regression tests: 12 new (connections.test.ts, structured.test.ts,
structuredRender.test.ts). 488 tests total.
Adds a transcript surface for a Structured-view session alongside the
existing xterm attach view: a WS /api/structured/:id connection
(structured.ts) mounted by ConnectionPool whenever a session's `view`
is `"Structured"`, and pure per-row/rail renderers (structuredRender.ts,
structuredDiff.ts) covering all eight transcript row kinds.
Hierarchy follows the design reference (artifact 88ecd690): the
approval is the only lifted, card-like element; a user turn is a
tinted block with an accent rail; agent prose is plain text; tool
calls are compact bordered rows expanding to output or a coloured,
horizontally-scrolling diff computed from ACP's full old/new text.
The client renders `reduced_state` and rows exactly as received, with
no raw-event folding or turn tracking of its own.
Extracted attach.ts's pane-header title building into paneTitle.ts and
its connection interface into pane.ts (PaneConnection) so both
connection kinds share one contract and one header.