Skip to content

feat(chat): shell mode with ! in the conversation view, running the command locally and letting the agent respond (#79) - #80

Merged
vlinx-io merged 1 commit into
vlinx-io:devfrom
mguttmann:feat/chat-bash-mode
Sep 20, 2026
Merged

vlinx-io merged 1 commit into
vlinx-io:devfrom
mguttmann:feat/chat-bash-mode

Conversation

@mguttmann

@mguttmann mguttmann commented Sep 15, 2026 •

Copy link
Copy Markdown

Closes #79

What happens today

A message that starts with ! is sent to the agent verbatim. Claude Code's shell mode exists only in its terminal UI; over the stream-json protocol the conversation view speaks, ! az login --tenant <id> is plain text, so nothing runs and nothing comes back. Details and the probe are in #79.

What changes

  • !command runs locally. The composer routes a message starting with ! to a new chat_run_shell core function instead of chat_send. The command runs through the user's login shell (the same resolution the terminal view uses, extracted into resolve_shell), in the agent's working directory, with the environment the agent process gets (extracted into agent_environment, so the two cannot drift). The whole text after ! is passed to the shell as one argument; VelaTerm never splits or re-quotes it.
  • Live output, cancel, no timeout. stdout and stderr stream into a dedicated shell row (command line, output, running indicator, Cancel, exit code or Cancelled). There is no fixed timeout, because commands such as az login wait minutes for a browser login; the user cancels instead. Cancel kills the whole process group through one host helper. Output is capped at 200 KiB per stream, keeping the tail, with a visible truncation note. At most one shell command runs per session at a time.
  • The agent responds, as in the terminal. When the command ends, the engine sends one user message with the terminal's own tags, <bash-input>…</bash-input> and <bash-stdout>…</bash-stdout><bash-stderr>…</bash-stderr> (exit code and cancellation noted inside stderr). The terminal sends these as two consecutive messages; over stream-json two consecutive user messages produce two assistant turns (verified with a probe), so the view sends one. If no agent process is alive, it is started first, the way a normal message does it.
  • Never a raw bubble. The tagged message is recognized at both send paths, on echo, in the queue list and in history replay for every agent kind, and rendered as the shell row. Session auto-naming skips it.
  • Persistence and remote. Shell rows live in the agent timeline and come back from the transcript on reload; mirrored and remote browser views get them through the WebSocket transport.
  • Authorization. chat_run_shell and chat_cancel_shell are command_core functions wired as dispatch.rs arms (the desktop reaches dispatch through desktop_call, so there is no separate Tauri adapter, matching the existing chat commands). Paired clients get them with the same guard as chat_send; public share visitors are denied both, like pty_spawn (test added). Both names are listed in the diagnostics allowlists.
  • Texts: ten chat.shell.* keys in en and de, the other nine locales carry the English text with the repo's TODO marker. The manual gains a short section on shell mode in the conversation view.

Decisions worth a look

  • The tag format mirrors the terminal's tags as found in the CLI binary and in real transcripts; the terminal's exact handling of exit codes in stderr is not documented, so Exit code N on its own line is this PR's convention.
  • If a turn is running when the command ends, the context message is queued (not steered).
  • No interactive stdin: the child gets /dev/null, so a tool that prompts on stdin sees EOF and ends instead of hanging, matching the terminal's shell mode.
  • Windows sessions with a persisted WSL shell fall back to the Windows host shell in shell mode, as agent sessions already do.
  • The read-only recorded view still shows the tagged message as text; noted as a possible follow-up.

Known limits, left open on purpose (from the QA and Security reviews)

  • A shell command starts the agent process. Rows live in the agent timeline, so ! starts the agent first when none is alive, the way a normal message does. If the agent cannot start (not installed, sign-in pending), the command does not run either and the composer shows the start error.
  • Rewind right after a shell command. The context message is a user message in the agent's transcript, but not a rewind target in the view; a rewind attempted before the next real prompt may be refused by the agent. Not verifiable offline, to be checked on a real session.
  • Output containing the tags. The parser anchors on the first input seam, the last stdout/stderr seam and the closing tag at the end, so output that contains the tags (grepping this repository does) reads back correctly; only stderr containing the exact seam itself would still be misread, the same in-band limit the terminal has.
  • Detached children. Cancel kills the command's process group; a child that moved itself into its own group and keeps the pipes open leaves the row running until the conversation is stopped, as in the terminal.
  • Windows cmd.exe sessions. Rust's argument quoting does not match cmd.exe's rules for quotes inside the command; PowerShell (the default) and Git Bash are unaffected. Untested here (no Windows).
  • Wire load. A very chatty command re-sends its row (up to 200 KiB per stream) every 100 ms to every subscriber; a deferral like tool rows have is a follow-up.
  • Login shell, not interactive shell. zsh -l -c reads .zprofile, not .zshrc; a tool whose PATH is set only in .zshrc will not resolve in shell mode although it does in the terminal.

Verification

  • Rust: full cargo test 1064 passed, 0 failed, 15 ignored; the shell module's own tests cover the tag round trip including output that contains the tags, the tail cap, echo, exit 3, and cancelling a sleep with a process-group check. The headless vela-server build (--no-default-features) compiles.
  • Frontend: pnpm test 119 files / 1030 tests, pnpm build (type check across the eleven locales) and pnpm lint green. New tests cover composer routing (!x never reaches chat_send, bare ! and images show hints and keep the draft, a refusal keeps the draft), the shell row (running with Cancel, exit code, cancelled), the frontend parser, and the WebSocket dispatch parity for both call origins.
  • Manual check on the desktop (v0.2.1 build with this branch): ! ls in a Claude conversation ran locally in the project directory, the row showed the output, and the agent received it as the tagged message and responded to it. ! az login resolved az through the login shell, opened the browser, waited without a timeout, and Cancel ended the command; the agent received the output with the Command cancelled by the user line.

Note on linking: this PR comes from a fork without write access, so GitHub does not add it to the issue's Development sidebar on its own; Closes #79 here and in the commit closes the issue on merge.

🤖 Generated with Claude Code

…ommand locally and letting the agent respond

A message starting with ! was sent to the agent as plain text. Claude
Code's shell mode exists only in its terminal UI, and the stream-json
protocol the conversation view speaks has no such mode, so a line like
"! az login --tenant <id>" just sat there as a prompt.

The view now runs the command itself: through the user's login shell in
the agent's working directory with the agent's environment, output
streaming live into its own row with a Cancel button, no timeout because
browser logins take minutes, at most one command per session, output
capped per stream. When the command ends, command and output go to the
agent in the terminal's own format (<bash-input>, <bash-stdout>,
<bash-stderr>) as one message, because two consecutive user messages
over stream-json produce two turns; the agent then responds as in the
terminal. The tagged message is recognized on every path (live, echo,
history replay) and rendered as the command row, never as a bubble.
Public shares do not get the command, like pty_spawn; paired clients
get it with the same authorization as chat_send. The shell resolution
of the terminal and the agent environment of the engine are extracted
into shared functions so the two cannot drift; process trees are killed
through one host helper.

Closes vlinx-io#79

Co-Authored-By: Claude Fable 5.1 <[email protected]>
@mguttmann

Copy link
Copy Markdown
Author

Rebased onto v0.2.2 (7874081). One conflict, adjacent additions in src/ipc/chat.ts (your chatAutoContinueCancel next to chatRunShell and chatCancelShell); both kept. cargo test 1064 passed, build and lint green; pnpm test green apart from two known timeouts in src/ipc/transfer.test.ts under load, which pass 11/11 twice in isolation and are untouched by this branch.

@mguttmann
mguttmann changed the base branch from main to dev September 17, 2026 15:15
@vlinx-io
vlinx-io merged commit 7a10c79 into vlinx-io:dev Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Conversation view: ! bash mode is missing, shell commands are sent to the agent as plain text

2 participants