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:
Simon Klee
2026-05-20 09:09:12 +02:00
committed by GitHub
parent 11f7e5a1b0
commit 539b118690
12 changed files with 665 additions and 47 deletions

View File

@@ -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(

View File

@@ -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[] = []

View File

@@ -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(

View File

@@ -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([