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

@@ -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)) {