From cf3433334ad65ee93f321926457e2aec93990a34 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 18 May 2026 21:29:04 +0200 Subject: [PATCH] run: add shell mode to prompt Press `!` on an empty prompt to enter shell mode and run a command through session.shell instead of sending a message --- .../src/cli/cmd/run/footer.prompt.tsx | 78 ++++++++- .../opencode/src/cli/cmd/run/footer.view.tsx | 62 ++++--- .../opencode/src/cli/cmd/run/prompt.shared.ts | 3 +- .../opencode/src/cli/cmd/run/runtime.queue.ts | 14 +- .../src/cli/cmd/run/stream.transport.ts | 160 +++++++++++++++++- packages/opencode/src/cli/cmd/run/tool.ts | 4 + packages/opencode/src/cli/cmd/run/types.ts | 1 + .../test/cli/run/runtime.queue.test.ts | 62 ++++++- 8 files changed, 341 insertions(+), 43 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index bed3009274..5658d8d34d 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -88,6 +88,7 @@ type PromptInput = { export type PromptState = { placeholder: Accessor bindings: Accessor + shell: Accessor visible: Accessor options: Accessor selected: Accessor @@ -110,9 +111,14 @@ function clonePrompt(prompt: RunPrompt): RunPrompt { return { text: prompt.text, parts: structuredClone(prompt.parts), + ...(prompt.mode ? { mode: prompt.mode } : {}), } } +function emptyPrompt(shell: boolean): RunPrompt { + return shell ? { text: "", parts: [], mode: "shell" } : { text: "", parts: [] } +} + function removeLineRange(input: string) { const hash = input.lastIndexOf("#") return hash === -1 ? input : input.slice(0, hash) @@ -274,7 +280,14 @@ export function RunPromptBody(props: { export function createPromptState(input: PromptInput): PromptState { const keys = createMemo(() => promptKeys(input.keybinds)) const bindings = createMemo(() => keys().bindings) + const [shell, setShell] = createSignal(false) const placeholder = createMemo(() => { + if (shell()) { + return new StyledText([ + bg(input.theme().surface)(fg(input.theme().muted)('Run a command... "git status"')), + ]) + } + if (!input.state().first) { return "" } @@ -301,6 +314,11 @@ export function createPromptState(input: PromptInput): PromptState { const [query, setQuery] = createSignal("") const visible = createMemo(() => mode() !== false) + const setShellMode = (value: boolean) => { + setShell(value) + draft = value ? { ...draft, mode: "shell" } : { text: draft.text, parts: structuredClone(draft.parts) } + } + const width = createMemo(() => Math.max(20, input.width() - 8)) const agents = createMemo(() => { return input @@ -577,6 +595,7 @@ export function createPromptState(input: PromptInput): PromptState { const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => { draft = clonePrompt(value) + setShell(value.mode === "shell") if (!area || area.isDestroyed) { return } @@ -596,7 +615,7 @@ export function createPromptState(input: PromptInput): PromptState { clearParts() hide() - draft = { text: "", parts: [] } + draft = emptyPrompt(shell()) if (!area || area.isDestroyed) { return } @@ -606,7 +625,7 @@ export function createPromptState(input: PromptInput): PromptState { } const replaceDraft = (text: string) => { - draft = { text, parts: [] } + draft = shell() ? { text, parts: [], mode: "shell" } : { text, parts: [] } if (!area || area.isDestroyed) { return } @@ -614,7 +633,7 @@ export function createPromptState(input: PromptInput): PromptState { hide() area.setText(text) clearParts() - draft = { text: area.plainText, parts: [] } + draft = shell() ? { text: area.plainText, parts: [], mode: "shell" } : { text: area.plainText, parts: [] } area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText)) scheduleRows() area.focus() @@ -705,10 +724,16 @@ export function createPromptState(input: PromptInput): PromptState { } syncParts() - draft = { - text: area.plainText, - parts: structuredClone(parts), - } + draft = shell() + ? { + text: area.plainText, + parts: structuredClone(parts), + mode: "shell", + } + : { + text: area.plainText, + parts: structuredClone(parts), + } } const push = (value: RunPrompt) => { @@ -943,6 +968,35 @@ export function createPromptState(input: PromptInput): PromptState { } } + if ( + key.name === "!" && + !shell() && + !event.ctrl && + !event.meta && + !event.super && + area && + !area.isDestroyed && + area.cursorOffset === 0 + ) { + event.preventDefault() + setShellMode(true) + return + } + + if (shell() && !visible()) { + if (key.name === "escape") { + event.preventDefault() + setShellMode(false) + return + } + + if (key.name === "backspace" && area && !area.isDestroyed && area.cursorOffset === 0) { + event.preventDefault() + setShellMode(false) + return + } + } + if (promptHit(keys().clear, key)) { const handled = requestExit() if (handled) { @@ -1028,23 +1082,28 @@ export function createPromptState(input: PromptInput): PromptState { return } - if (isExitCommand(next.text)) { + if (next.mode !== "shell" && isExitCommand(next.text)) { input.onExit() return } - const parsed = isNewCommand(next.text) ? undefined : parseSlashCommand(next.text, input.commands()) + const parsed = next.mode === "shell" || isNewCommand(next.text) ? undefined : parseSlashCommand(next.text, input.commands()) if (parsed?.type === "pending") { input.onStatus("loading commands") return } const submit = parsed?.type === "command" ? { ...next, command: parsed.command } : next + const shellMode = next.mode === "shell" resetDraft() queueMicrotask(async () => { if (await input.onSubmit(submit)) { push(next) + if (shellMode) { + setShellMode(false) + draft = emptyPrompt(false) + } return } @@ -1121,6 +1180,7 @@ export function createPromptState(input: PromptInput): PromptState { return { placeholder, bindings, + shell, visible, options, selected: menu.selected, diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index e1a028b7e9..bc0a3490b1 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -265,6 +265,7 @@ export function RunFooterView(props: RunFooterViewProps) { onRows: props.onRows, onStatus: props.onStatus, }) + const shell = createMemo(() => prompt() && composer.shell()) const menu = createMemo(() => prompt() && composer.visible()) createEffect(() => { @@ -487,18 +488,20 @@ export function RunFooterView(props: RunFooterViewProps) { paddingTop={1} > - {props.agent} - - - {props.state().model} + {shell() ? "Shell" : props.agent} + + + {props.state().model} + + @@ -629,19 +632,30 @@ export function RunFooterView(props: RunFooterViewProps) { flexShrink={0} justifyContent="flex-end" > - 0}> - - {queue()} queued - - - 0}> - - {usage()} - - - 0 && hints().command}> - - {command()} commands + + 0}> + + {queue()} queued + + + 0}> + + {usage()} + + + 0 && hints().command}> + + {command()} commands + + + + } + > + + esc exit shell mode diff --git a/packages/opencode/src/cli/cmd/run/prompt.shared.ts b/packages/opencode/src/cli/cmd/run/prompt.shared.ts index 0da787cb3c..2dda26bae1 100644 --- a/packages/opencode/src/cli/cmd/run/prompt.shared.ts +++ b/packages/opencode/src/cli/cmd/run/prompt.shared.ts @@ -65,11 +65,12 @@ export function promptCopy(prompt: RunPrompt): RunPrompt { return { text: prompt.text, parts: structuredClone(prompt.parts), + ...(prompt.mode ? { mode: prompt.mode } : {}), } } export function promptSame(a: RunPrompt, b: RunPrompt): boolean { - return a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts) + return a.mode === b.mode && a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts) } function promptKey(binding: ReturnType[number]): PromptInfo | undefined { diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue.ts b/packages/opencode/src/cli/cmd/run/runtime.queue.ts index d82b9e19e9..79be71cadf 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.queue.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.queue.ts @@ -102,7 +102,7 @@ export async function runPromptQueue(input: QueueInput): Promise { continue } - if (isNewCommand(prompt.text)) { + if (prompt.mode !== "shell" && isNewCommand(prompt.text)) { emit( { type: "queue", @@ -167,9 +167,11 @@ export async function runPromptQueue(input: QueueInput): Promise { break } - const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const - input.trace?.write("ui.commit", commit) - input.footer.append(commit) + if (prompt.mode !== "shell") { + const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const + input.trace?.write("ui.commit", commit) + input.footer.append(commit) + } input.onSend?.(prompt) if (state.closed) { @@ -234,7 +236,7 @@ export async function runPromptQueue(input: QueueInput): Promise { return } - if (isExitCommand(prompt.text)) { + if (prompt.mode !== "shell" && isExitCommand(prompt.text)) { input.footer.close() return } @@ -249,7 +251,7 @@ export async function runPromptQueue(input: QueueInput): Promise { queue: state.queue.length, }, ) - if (isNewCommand(prompt.text)) { + if (prompt.mode !== "shell" && isNewCommand(prompt.text)) { drain() return } diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index 1bb9aac52c..08d9416215 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -75,6 +75,23 @@ type StreamInput = { signal?: AbortSignal } +type ShellMessage = NonNullable>["data"]> +type SessionMessage = NonNullable>["data"]>[number] + +function isShellMessage(value: unknown): value is ShellMessage { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false + } + + const info = Reflect.get(value, "info") + const parts = Reflect.get(value, "parts") + if (!info || typeof info !== "object" || !Array.isArray(parts)) { + return false + } + + return Reflect.get(info, "role") === "assistant" && typeof Reflect.get(info, "sessionID") === "string" +} + type Wait = { tick: number armed: boolean @@ -513,6 +530,97 @@ function createLayer(input: StreamInput) { state.footerView = current } + const applyMessage = (event: Event) => { + const next = reduceSessionData({ + data: state.data, + event, + sessionID: input.sessionID, + thinking: input.thinking, + limits: input.limits(), + }) + state.data = next.data + syncFooter(next.commits, next.footer?.patch) + } + + const applyShellResponse = (message: ShellMessage | SessionMessage | undefined) => { + if (!message || message.info.role !== "assistant" || message.info.sessionID !== input.sessionID) { + return + } + + input.trace?.write("recv.shell", { + messageID: message.info.id, + parts: message.parts.length, + }) + applyMessage({ + type: "message.updated", + properties: { + sessionID: message.info.sessionID, + info: message.info, + }, + } as Event) + + for (const part of message.parts) { + if (part.type !== "tool") { + continue + } + + applyMessage({ + type: "message.part.updated", + properties: { + part, + }, + } as Event) + } + } + + const resolveShellMessage = Effect.fn("RunStreamTransport.resolveShellMessage")(function* (result: unknown) { + if (result && typeof result === "object") { + const data = Reflect.get(result, "data") + if (isShellMessage(data)) { + return data + } + + if (isShellMessage(result)) { + return result + } + } + + for (let attempt = 0; attempt < 5; attempt += 1) { + const list = yield* Effect.promise(() => + input.sdk.session.messages({ + sessionID: input.sessionID, + limit: 1, + }), + ).pipe(Effect.map((item) => item.data ?? []), Effect.orElseSucceed(() => [])) + const message = list.find(isShellMessage) + if (message) { + return message + } + + if (attempt < 4) { + yield* Effect.sleep("50 millis") + } + } + + return undefined + }) + + const resolveShellAgent = Effect.fn("RunStreamTransport.resolveShellAgent")(function* (agent: string | undefined) { + if (agent) { + return agent + } + + const list = yield* Effect.promise(() => + input.sdk.app.agents(input.directory ? { directory: input.directory } : undefined, { throwOnError: true }), + ).pipe(Effect.map((item) => item.data ?? []), Effect.orElseSucceed(() => [])) + const next = list.find((item) => item.mode !== "subagent" && item.hidden !== true)?.name + if (next) { + return next + } + + return yield* Effect.fail(new Error("no primary agent available for shell mode")) + }) + const recoverQuestion = Effect.fn("RunStreamTransport.recoverQuestion")(function* (partID: string) { if (recovering.has(partID)) { return @@ -1005,7 +1113,57 @@ function createLayer(input: StreamInput) { ], } const command = next.prompt.command - const send = command + const send = next.prompt.mode === "shell" + ? Effect.sync(() => { + input.trace?.write("send.shell", { + sessionID: input.sessionID, + command: next.prompt.text, + }) + }).pipe( + Effect.andThen( + resolveShellAgent(next.agent).pipe( + Effect.flatMap((agent) => + Effect.promise(() => + input.sdk.session.shell( + { + sessionID: input.sessionID, + agent, + model: next.model, + command: next.prompt.text, + }, + { signal: turn.signal, throwOnError: true }, + ), + ), + ), + ).pipe( + Effect.tap(() => + Effect.sync(() => { + input.trace?.write("send.shell.ok", { + sessionID: input.sessionID, + }) + item.armed = true + item.live = true + }), + ), + Effect.tap((result) => + Effect.gen(function* () { + const message = yield* resolveShellMessage(result) + if (!message) { + input.trace?.write("recv.shell.miss") + return + } + + applyShellResponse(message) + }), + ), + Effect.flatMap(() => Deferred.succeed(item.done, undefined).pipe(Effect.ignore)), + Effect.catch((error) => Deferred.fail(item.done, error).pipe(Effect.ignore)), + Effect.forkIn(scope, { startImmediately: true }), + Effect.asVoid, + ), + ), + ) + : command ? Effect.sync(() => { input.trace?.write("send.command", { sessionID: input.sessionID, command: command.name }) }).pipe( diff --git a/packages/opencode/src/cli/cmd/run/tool.ts b/packages/opencode/src/cli/cmd/run/tool.ts index 3dab7aa8df..c3891a2e2e 100644 --- a/packages/opencode/src/cli/cmd/run/tool.ts +++ b/packages/opencode/src/cli/cmd/run/tool.ts @@ -626,6 +626,10 @@ function scrollBashStart(p: ToolProps): string { const desc = p.input.description || "Shell" const wd = p.input.workdir ?? "" const dir = wd && wd !== "." ? toolPath(wd) : "" + if (cmd && desc === "Shell" && !dir) { + return `$ ${cmd}` + } + const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc if (!cmd) { diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index 3822223649..28b9fc371e 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -34,6 +34,7 @@ export type RunProvider = NonNullable { ]) }) + test("shell mode submits /exit as a shell command", async () => { + const ui = footer() + const seen: RunPrompt[] = [] + + const task = runPromptQueue({ + footer: ui.api, + run: async (input) => { + seen.push(input) + ui.api.close() + }, + }) + + ui.submit("/exit", "shell") + await task + + expect(seen).toEqual([{ text: "/exit", parts: [], mode: "shell" }]) + expect(ui.commits).toEqual([]) + }) + + test("shell mode submits /new instead of creating a session", async () => { + const ui = footer() + const seen: RunPrompt[] = [] + let created = 0 + + const task = runPromptQueue({ + footer: ui.api, + onNewSession: async () => { + created += 1 + }, + run: async (input) => { + seen.push(input) + ui.api.close() + }, + }) + + ui.submit("/new", "shell") + await task + + expect(created).toBe(0) + expect(seen).toEqual([{ text: "/new", parts: [], mode: "shell" }]) + expect(ui.commits).toEqual([]) + }) + + test("shell mode does not append a synthetic user row", async () => { + const ui = footer() + + const task = runPromptQueue({ + footer: ui.api, + run: async () => { + expect(ui.commits).toEqual([]) + ui.api.close() + }, + }) + + ui.submit("ls", "shell") + await task + }) + test("preserves whitespace for initial input", async () => { const ui = footer() const seen: string[] = []