mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-21 11:26:39 +00:00
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
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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[] = []
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user