Class: Rubino::CLI::Chat::BangShell
- Inherits:
-
Object
- Object
- Rubino::CLI::Chat::BangShell
- Defined in:
- lib/rubino/cli/chat/bang_shell.rb
Overview
The ‘!` bang prefix — the human shell escape (Claude Code’s bash mode, also shipped by Gemini CLI, Codex CLI, opencode, and aider’s ‘/run`). `! npm test` at the chat prompt runs the command in the user’s shell IMMEDIATELY, streams its output into the transcript, and then injects command + output into the session as two user-role messages so the model can reference them next turn:
<bash-input>npm test</bash-input>
<bash-stdout>...</bash-stdout><bash-stderr>...</bash-stderr>
That tagged, user-role shape replicates exactly what Claude Code persists for its bash mode (verified against real Claude Code session transcripts). Because the messages live in the session store, they are part of every later turn’s context AND survive resume/branch like any other message.
HUMAN semantics, deliberately distinct from the model’s ‘shell` tool:
* no approval prompt and no hardline floor — the human typed the
command at their own terminal, the same trust as their normal
shell (this mirrors Claude Code, which runs `!` commands with no
gate of any kind);
* `bash -lc` (login shell) so the user's profile PATH applies, and
no `pipefail` — the model's tool adds pipefail for ITS pipelines
(#156), but a human's `!` line should behave like their shell;
* no timeout — Ctrl+C terminates the command (SIGTERM, then SIGKILL
after a grace period) without killing rubino.
Defined Under Namespace
Classes: Result
Constant Summary collapse
- PREFIX =
"!"- MAX_CONTEXT_CHARS =
Per-stream cap on what enters the model context — Claude Code’s bash output cap (30k chars). Over the cap we keep the head and the tail with an explicit omission marker, so both the start of a build log and its failing end survive.
30_000- KILL_GRACE_SECONDS =
Grace between SIGTERM and SIGKILL on Ctrl+C, mirroring ShellTool.
1.5- BASH_INPUT_RE =
%r{\A<bash-input>(.*)</bash-input>\z}m- BASH_OUTPUT_RE =
%r{\A<bash-stdout>(.*)</bash-stdout><bash-stderr>(.*)</bash-stderr>\z}m
Class Method Summary collapse
-
.replay(ui, content, at: nil) ⇒ Object
Replays a persisted bang message during –resume/-c history replay: the <bash-input> message renders as the ‘! <command>` line the user originally typed, the <bash-stdout>/<bash-stderr> message as the dim output block — never the raw tags.
Instance Method Summary collapse
-
#handle(input, runner, ui) ⇒ Object
Dispatch entry point, called by the REPL loop before slash dispatch.
Class Method Details
.replay(ui, content, at: nil) ⇒ Object
Replays a persisted bang message during –resume/-c history replay: the <bash-input> message renders as the ‘! <command>` line the user originally typed, the <bash-stdout>/<bash-stderr> message as the dim output block — never the raw tags. Returns true when the content was a bang message (caller skips the generic user replay), false otherwise.
75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/rubino/cli/chat/bang_shell.rb', line 75 def self.replay(ui, content, at: nil) # rubocop:disable Naming/PredicateMethod -- a renderer that reports whether it handled the message text = content.to_s if (m = BASH_INPUT_RE.match(text)) ui.replay_user_input("! #{m[1]}", at: at) true elsif (m = BASH_OUTPUT_RE.match(text)) merged = [m[1], m[2]].reject(&:empty?).join("\n") ui.tool_body(merged.empty? ? "(no output)" : merged) true else false end end |
Instance Method Details
#handle(input, runner, ui) ⇒ Object
Dispatch entry point, called by the REPL loop before slash dispatch. Returns nil for a non-bang line (fall through to normal dispatch), :handled for a bare ‘!` (usage shown, nothing run/persisted), and :ran after a command actually executed and was injected.
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/rubino/cli/chat/bang_shell.rb', line 52 def handle(input, runner, ui) return nil unless input.start_with?(PREFIX) command = input.delete_prefix(PREFIX).strip if command.empty? # Bare `!`: error-with-usage (the simpler of the two industry # behaviours — Gemini CLI's persistent shell-mode toggle is noted # as a follow-up). ui.status("usage: ! <command> — runs it in your shell now (no approval); output joins the context") return :handled end result = execute(command) render_outcome(result) inject!(runner, command, result) :ran end |