فهرست منبع

run: add shell mode to prompt (#28315)

Press `!` on an empty prompt to enter shell mode and run a command
through session.shell instead of sending a message
Simon Klee 2 ماه پیش
والد
کامیت
539b118690

+ 69 - 9
packages/opencode/src/cli/cmd/run/footer.prompt.tsx

@@ -88,6 +88,7 @@ type PromptInput = {
 export type PromptState = {
   placeholder: Accessor<StyledText | string>
   bindings: Accessor<KeyBinding[]>
+  shell: Accessor<boolean>
   visible: Accessor<boolean>
   options: Accessor<PromptOption[]>
   selected: Accessor<number>
@@ -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<Auto[]>(() => {
     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,

+ 38 - 24
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}
                   >
                     <text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
-                      {props.agent}
-                    </text>
-                    <text
-                      id="run-direct-footer-model"
-                      fg={theme().text}
-                      wrapMode="none"
-                      truncate
-                      flexGrow={1}
-                      flexShrink={1}
-                    >
-                      {props.state().model}
+                      {shell() ? "Shell" : props.agent}
                     </text>
+                    <Show when={!shell()}>
+                      <text
+                        id="run-direct-footer-model"
+                        fg={theme().text}
+                        wrapMode="none"
+                        truncate
+                        flexGrow={1}
+                        flexShrink={1}
+                      >
+                        {props.state().model}
+                      </text>
+                    </Show>
                   </box>
                 </Show>
               </box>
@@ -629,19 +632,30 @@ export function RunFooterView(props: RunFooterViewProps) {
                       flexShrink={0}
                       justifyContent="flex-end"
                     >
-                      <Show when={queue() > 0}>
-                        <text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
-                          {queue()} queued
-                        </text>
-                      </Show>
-                      <Show when={usage().length > 0}>
-                        <text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
-                          {usage()}
-                        </text>
-                      </Show>
-                      <Show when={command().length > 0 && hints().command}>
-                        <text id="run-direct-footer-hint-command" fg={theme().text} wrapMode="none" truncate>
-                          {command()} <span style={{ fg: theme().muted }}>commands</span>
+                      <Show
+                        when={shell()}
+                        fallback={
+                          <>
+                            <Show when={queue() > 0}>
+                              <text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
+                                {queue()} queued
+                              </text>
+                            </Show>
+                            <Show when={usage().length > 0}>
+                              <text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
+                                {usage()}
+                              </text>
+                            </Show>
+                            <Show when={command().length > 0 && hints().command}>
+                              <text id="run-direct-footer-hint-command" fg={theme().text} wrapMode="none" truncate>
+                                {command()} <span style={{ fg: theme().muted }}>commands</span>
+                              </text>
+                            </Show>
+                          </>
+                        }
+                      >
+                        <text id="run-direct-footer-hint-shell" fg={theme().text} wrapMode="none" truncate>
+                          esc <span style={{ fg: theme().muted }}>exit shell mode</span>
                         </text>
                       </Show>
                     </box>

+ 2 - 1
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<typeof parseBindings>[number]): PromptInfo | undefined {

+ 8 - 6
packages/opencode/src/cli/cmd/run/runtime.queue.ts

@@ -102,7 +102,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
             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<void> {
               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<void> {
       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<void> {
         queue: state.queue.length,
       },
     )
-    if (isNewCommand(prompt.text)) {
+    if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
       drain()
       return
     }

+ 141 - 0
packages/opencode/src/cli/cmd/run/session-data.ts

@@ -61,13 +61,20 @@ type SessionCommit = StreamCommit
 // - text:   part ID → full accumulated text so far
 // - sent:   part ID → byte offset of last flushed text (for incremental output)
 // - end:    part IDs whose time.end has arrived (part is finished)
+// - shell:  shell call ID → chosen transcript source for direct shell calls
 // - echo:   message ID → bash outputs to strip from the next assistant chunk
+type ShellCall = {
+  source: "shell" | "tool"
+  command?: string
+}
+
 export type SessionData = {
   includeUserText: boolean
   announced: boolean
   ids: Set<string>
   tools: Set<string>
   call: Map<string, Dict>
+  shell: Map<string, ShellCall>
   permissions: PermissionRequest[]
   questions: QuestionRequest[]
   role: Map<string, MessageRole>
@@ -104,6 +111,7 @@ export function createSessionData(
     ids: new Set(),
     tools: new Set(),
     call: new Map(),
+    shell: new Map(),
     permissions: [],
     questions: [],
     role: new Map(),
@@ -621,6 +629,87 @@ function toolCommit(
   }
 }
 
+function shellPartID(callID: string): string {
+  return `shell:${callID}`
+}
+
+function claimShell(data: SessionData, callID: string, source: ShellCall["source"], command?: string): ShellCall {
+  const current = data.shell.get(callID)
+  if (current) {
+    if (command && !current.command) {
+      current.command = command
+    }
+
+    return current
+  }
+
+  const next = {
+    source,
+    ...(command ? { command } : {}),
+  } satisfies ShellCall
+  data.shell.set(callID, next)
+  return next
+}
+
+function bashCommand(part: ToolPart): string | undefined {
+  if (part.tool !== "bash") {
+    return undefined
+  }
+
+  const input = part.state.input
+  if (!input || typeof input !== "object" || Array.isArray(input)) {
+    return undefined
+  }
+
+  const command = Reflect.get(input, "command")
+  return typeof command === "string" ? command : undefined
+}
+
+function shellCommit(
+  input: {
+    callID: string
+    command: string
+  },
+  next: Pick<SessionCommit, "text" | "phase" | "toolState">,
+): SessionCommit {
+  return {
+    kind: "tool",
+    source: "tool",
+    partID: shellPartID(input.callID),
+    tool: "bash",
+    shell: input,
+    ...next,
+  }
+}
+
+function startShell(callID: string, command: string): SessionCommit {
+  return shellCommit(
+    {
+      callID,
+      command,
+    },
+    {
+      text: "running shell",
+      phase: "start",
+      toolState: "running",
+    },
+  )
+}
+
+function doneShell(callID: string, command: string, output: string): SessionCommit {
+  return shellCommit(
+    {
+      callID,
+      command,
+    },
+    {
+      text: output,
+      phase: "progress",
+      toolState: "completed",
+    },
+  )
+}
+
 function startTool(part: ToolPart): SessionCommit {
   return toolCommit(part, {
     text: toolStatus(part),
@@ -681,6 +770,53 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
   const data = input.data
   const event = input.event
 
+  if (event.type === "session.next.shell.started") {
+    if (event.properties.sessionID !== input.sessionID) {
+      return out(data, commits)
+    }
+
+    const shell = claimShell(data, event.properties.callID, "shell", event.properties.command)
+    if (shell.source !== "shell") {
+      return out(data, commits)
+    }
+
+    const partID = shellPartID(event.properties.callID)
+    if (data.ids.has(partID) || data.tools.has(partID)) {
+      return out(data, commits, patch({ status: "running shell" }))
+    }
+
+    data.tools.add(partID)
+    commits.push(startShell(event.properties.callID, shell.command ?? event.properties.command))
+    return out(data, commits, patch({ status: "running shell" }))
+  }
+
+  if (event.type === "session.next.shell.ended") {
+    if (event.properties.sessionID !== input.sessionID) {
+      return out(data, commits)
+    }
+
+    const shell = claimShell(data, event.properties.callID, "shell")
+    if (shell.source !== "shell") {
+      return out(data, commits)
+    }
+
+    const partID = shellPartID(event.properties.callID)
+    const seen = data.tools.has(partID)
+    const command = shell.command ?? ""
+    data.tools.delete(partID)
+    if (data.ids.has(partID)) {
+      return out(data, commits)
+    }
+
+    if (!seen && command) {
+      commits.push(startShell(event.properties.callID, command))
+    }
+
+    data.ids.add(partID)
+    commits.push(doneShell(event.properties.callID, command, event.properties.output))
+    return out(data, commits)
+  }
+
   if (event.type === "message.updated") {
     if (event.properties.sessionID !== input.sessionID) {
       return out(data, commits)
@@ -782,6 +918,11 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
 
     if (part.type === "tool") {
       const view = syncPermission(data, part) ?? syncQuestion(data, part)
+      if (part.tool === "bash" && part.callID) {
+        if (claimShell(data, part.callID, "tool", bashCommand(part)).source === "shell") {
+          return out(data, commits, view)
+        }
+      }
 
       if (part.state.status === "running") {
         if (data.ids.has(part.id)) {

+ 58 - 1
packages/opencode/src/cli/cmd/run/stream.transport.ts

@@ -132,6 +132,8 @@ function sid(event: Event): string | undefined {
   }
 
   if (
+    event.type === "session.next.shell.started" ||
+    event.type === "session.next.shell.ended" ||
     event.type === "permission.asked" ||
     event.type === "permission.replied" ||
     event.type === "question.asked" ||
@@ -513,6 +515,22 @@ function createLayer(input: StreamInput) {
           state.footerView = current
         }
 
+        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 +1023,46 @@ 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.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(

+ 32 - 3
packages/opencode/src/cli/cmd/run/tool.ts

@@ -35,7 +35,7 @@ import { webSearchProviderLabel, type WebSearchTool } from "@/tool/websearch"
 import type { WriteTool } from "@/tool/write"
 import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
 import * as Locale from "@/util/locale"
-import type { RunDiffStyle, RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
+import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
 
 export type ToolView = {
   output: boolean
@@ -626,6 +626,10 @@ function scrollBashStart(p: ToolProps<typeof BashTool>): 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) {
@@ -1248,7 +1252,7 @@ function frame(part: ToolPart): ToolFrame {
     raw: "",
     name: part.tool,
     input: dict(state.input),
-    meta: dict(state.metadata),
+    meta: "metadata" in part.state ? dict(part.state.metadata) : {},
     state,
     status: text(state.status),
     error: text(state.error),
@@ -1261,7 +1265,7 @@ export function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
     raw,
     name: commit.tool || commit.part?.tool || "tool",
     input: dict(state.input),
-    meta: dict(state.metadata),
+    meta: commit.part?.state && "metadata" in commit.part.state ? dict(commit.part.state.metadata) : {},
     state,
     status: commit.toolState ?? text(state.status),
     error: (commit.toolError ?? "").trim(),
@@ -1403,7 +1407,32 @@ function structuredBody(commit: StreamCommit, raw: string): RunEntryBody | undef
   }
 }
 
+function shellOutput(command: string, raw: string): string | undefined {
+  const body = stripAnsi(raw).replace(/^\n+/, "").replace(/\n+$/, "")
+  if (!body) {
+    return undefined
+  }
+
+  if (!command) {
+    return body
+  }
+
+  return `\n${body}`
+}
+
 export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody | undefined {
+  if (commit.shell) {
+    if (commit.phase === "start") {
+      return textBody(`$ ${commit.shell.command}`)
+    }
+
+    if (commit.phase === "progress") {
+      return textBody(shellOutput(commit.shell.command, raw) ?? "")
+    }
+
+    return undefined
+  }
+
   const ctx = toolFrame(commit, raw)
   const view = toolView(ctx.name)
 

+ 5 - 0
packages/opencode/src/cli/cmd/run/types.ts

@@ -34,6 +34,7 @@ export type RunProvider = NonNullable<Awaited<ReturnType<OpencodeClient["provide
 export type RunPrompt = {
   text: string
   parts: RunPromptPart[]
+  mode?: "shell"
   command?: {
     name: string
     arguments: string
@@ -302,6 +303,10 @@ export type StreamCommit = {
   interrupted?: boolean
   toolState?: StreamToolState
   toolError?: string
+  shell?: {
+    callID: string
+    command: string
+  }
 }
 
 // The public contract between the stream transport / prompt queue and

+ 67 - 0
packages/opencode/test/cli/run/entry.body.test.ts

@@ -359,6 +359,73 @@ describe("run entry body", () => {
     })
   })
 
+  test("renders command-only bash starts without the shell header", () => {
+    expect(
+      entryBody(
+        toolCommit({
+          tool: "bash",
+          phase: "start",
+          toolState: "running",
+          text: "running shell",
+          state: {
+            status: "running",
+            input: {
+              command: "ls",
+            },
+            time: { start: 1 },
+          },
+        }),
+      ),
+    ).toEqual({
+      type: "text",
+      content: "$ ls",
+    })
+  })
+
+  test("renders direct shell commits without a synthetic shell header", () => {
+    expect(
+      entryBody(
+        commit({
+          kind: "tool",
+          text: "running shell",
+          phase: "start",
+          source: "tool",
+          tool: "bash",
+          partID: "shell:call-1",
+          toolState: "running",
+          shell: {
+            callID: "call-1",
+            command: "pwd",
+          },
+        }),
+      ),
+    ).toEqual({
+      type: "text",
+      content: "$ pwd",
+    })
+
+    expect(
+      entryBody(
+        commit({
+          kind: "tool",
+          text: "/tmp/demo\n",
+          phase: "progress",
+          source: "tool",
+          tool: "bash",
+          partID: "shell:call-1",
+          toolState: "completed",
+          shell: {
+            callID: "call-1",
+            command: "pwd",
+          },
+        }),
+      ),
+    ).toEqual({
+      type: "text",
+      content: "\n/tmp/demo",
+    })
+  })
+
   test("falls back to patch summary when apply_patch has no visible diff items", () => {
     expect(
       entryBody(

+ 60 - 2
packages/opencode/test/cli/run/runtime.queue.test.ts

@@ -60,8 +60,8 @@ function footer() {
     api,
     events,
     commits,
-    submit(text: string) {
-      const next = { text, parts: [] as RunPrompt["parts"] }
+    submit(text: string, mode?: RunPrompt["mode"]) {
+      const next = mode ? { text, parts: [] as RunPrompt["parts"], mode } : { text, parts: [] as RunPrompt["parts"] }
       for (const fn of [...prompts]) {
         fn(next)
       }
@@ -137,6 +137,64 @@ describe("run runtime queue", () => {
     ])
   })
 
+  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[] = []

+ 184 - 0
packages/opencode/test/cli/run/session-data.test.ts

@@ -326,6 +326,190 @@ describe("run session data", () => {
     ])
   })
 
+  test("renders direct shell mode from first-class shell events", () => {
+    let data = createSessionData()
+    const started = reduce(data, {
+      type: "session.next.shell.started",
+      properties: {
+        sessionID: "session-1",
+        timestamp: 1,
+        callID: "call-1",
+        command: "pwd",
+      },
+    })
+
+    expect(started.commits).toEqual([
+      expect.objectContaining({
+        kind: "tool",
+        phase: "start",
+        partID: "shell:call-1",
+        tool: "bash",
+        shell: {
+          callID: "call-1",
+          command: "pwd",
+        },
+      }),
+    ])
+
+    data = started.data
+    const ended = reduce(data, {
+      type: "session.next.shell.ended",
+      properties: {
+        sessionID: "session-1",
+        timestamp: 2,
+        callID: "call-1",
+        output: "/tmp/demo\n",
+      },
+    })
+
+    expect(ended.commits).toEqual([
+      expect.objectContaining({
+        kind: "tool",
+        phase: "progress",
+        partID: "shell:call-1",
+        tool: "bash",
+        text: "/tmp/demo\n",
+        toolState: "completed",
+        shell: {
+          callID: "call-1",
+          command: "pwd",
+        },
+      }),
+    ])
+  })
+
+  test("suppresses legacy bash part updates once shell events claim the call", () => {
+    let data = reduce(createSessionData(), {
+      type: "session.next.shell.started",
+      properties: {
+        sessionID: "session-1",
+        timestamp: 1,
+        callID: "call-1",
+        command: "pwd",
+      },
+    }).data
+
+    expect(
+      reduce(
+        data,
+        tool({
+          id: "tool-1",
+          messageID: "msg-1",
+          callID: "call-1",
+          tool: "bash",
+          state: {
+            status: "running",
+            input: {
+              command: "pwd",
+            },
+            time: { start: 1 },
+          },
+        }),
+      ).commits,
+    ).toEqual([])
+
+    data = reduce(data, {
+      type: "session.next.shell.ended",
+      properties: {
+        sessionID: "session-1",
+        timestamp: 2,
+        callID: "call-1",
+        output: "/tmp/demo\n",
+      },
+    }).data
+
+    expect(
+      reduce(
+        data,
+        tool({
+          id: "tool-1",
+          messageID: "msg-1",
+          callID: "call-1",
+          tool: "bash",
+          state: {
+            status: "completed",
+            input: {
+              command: "pwd",
+            },
+            output: "/tmp/demo\n",
+            title: "",
+            metadata: {
+              output: "/tmp/demo\n",
+              description: "",
+            },
+            time: { start: 1, end: 2 },
+          },
+        }),
+      ).commits,
+    ).toEqual([])
+  })
+
+  test("suppresses shell events when the legacy bash part claimed the call first", () => {
+    let data = reduce(
+      createSessionData(),
+      tool({
+        id: "tool-1",
+        messageID: "msg-1",
+        callID: "call-1",
+        tool: "bash",
+        state: {
+          status: "running",
+          input: {
+            command: "pwd",
+          },
+          time: { start: 1 },
+        },
+      }),
+    ).data
+
+    expect(
+      reduce(data, {
+        type: "session.next.shell.started",
+        properties: {
+          sessionID: "session-1",
+          timestamp: 1,
+          callID: "call-1",
+          command: "pwd",
+        },
+      }).commits,
+    ).toEqual([])
+
+    data = reduce(
+      data,
+      tool({
+        id: "tool-1",
+        messageID: "msg-1",
+        callID: "call-1",
+        tool: "bash",
+        state: {
+          status: "completed",
+          input: {
+            command: "pwd",
+          },
+          output: "/tmp/demo\n",
+          title: "",
+          metadata: {
+            output: "/tmp/demo\n",
+            description: "",
+          },
+          time: { start: 1, end: 2 },
+        },
+      }),
+    ).data
+
+    expect(
+      reduce(data, {
+        type: "session.next.shell.ended",
+        properties: {
+          sessionID: "session-1",
+          timestamp: 2,
+          callID: "call-1",
+          output: "/tmp/demo\n",
+        },
+      }).commits,
+    ).toEqual([])
+  })
+
   test("synthesizes a glob start before an error when the running update is missed", () => {
     expect(
       reduce(

+ 1 - 1
packages/opencode/test/cli/run/subagent-data.test.ts

@@ -354,7 +354,7 @@ describe("run subagent data", () => {
     expect(visible(snapshot.details["child-1"]?.commits ?? [])).toEqual([
       "› Inspect footer tabs",
       "_Thinking:_ planning next steps",
-      "# Shell\n$ git status --short",
+      "$ git status --short",
       "hello world",
     ])
     expect(snapshot.permissions).toEqual([