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
|
||||
|
||||
Reference in New Issue
Block a user