refactor(core): replace bash tool with shell tool

This commit is contained in:
Dax Raad 2026-06-29 00:43:04 -04:00
commit 5ae93092aa
37 changed files with 2260 additions and 901 deletions

View file

@ -164,6 +164,9 @@ export function Prompt(props: PromptProps) {
.filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running")
.length,
)
const runningShells = createMemo(
() => data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID).length,
)
const history = usePromptHistory()
const stash = usePromptStash()
const keymap = useOpencodeKeymap()
@ -284,6 +287,20 @@ export function Prompt(props: PromptProps) {
}
})
// Far-right footer cluster: live work counts lead, then context/cost usage, all dot-joined.
// When empty, the cluster falls back to the hotkey hints.
const statusItems = createMemo(() => {
const agents = activeSubagents()
const shells = runningShells()
const stats = usage()
return [
agents ? `${agents} subagent${agents === 1 ? "" : "s"}` : undefined,
shells ? `${shells} shell${shells === 1 ? "" : "s"}` : undefined,
stats?.context,
stats?.cost,
].filter(Boolean)
})
const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
@ -1548,13 +1565,6 @@ export function Prompt(props: PromptProps) {
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<Show when={activeSubagents()}>
{(count) => (
<Spinner color={theme.textMuted}>
{count()} active subagent{count() === 1 ? "" : "s"}
</Spinner>
)}
</Show>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
@ -1624,22 +1634,20 @@ export function Prompt(props: PromptProps) {
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
<Match when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
)}
<Match when={statusItems().length > 0}>
<text fg={theme.textMuted} wrapMode="none">
{statusItems().join(" · ")}
</text>
</Match>
<Match when={true}>
<text fg={theme.text}>
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
</text>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
</Switch>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>

View file

@ -15,6 +15,7 @@ import type {
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionV2Info,
Shell,
SkillV2Info,
V2Event,
} from "@opencode-ai/sdk/v2"
@ -47,6 +48,9 @@ type Data = {
permission: Record<string, PermissionSavedInfo[]>
}
location: Record<string, LocationData>
// Currently running shell commands, keyed by shell id. Entries are removed once the command
// exits or is deleted, so this only ever holds in-flight shells.
shell: Record<string, Shell>
}
function locationKey(location: LocationRef) {
@ -72,6 +76,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
permission: {},
},
location: {},
shell: {},
})
const sdk = useSDK()
@ -467,6 +472,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
),
)
break
case "shell.created":
setStore("shell", event.data.info.id, event.data.info)
break
case "shell.exited":
case "shell.deleted":
setStore(
"shell",
produce((draft) => {
delete draft[event.data.id]
}),
)
break
case "reference.updated":
void result.location.reference.refresh()
break
@ -567,6 +584,23 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
},
},
shell: {
list() {
return Object.values(store.shell)
},
get(id: string) {
return store.shell[id]
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.shell.list({ location: locationQuery(ref) }, { throwOnError: true })
setStore(
"shell",
produce((draft) => {
for (const info of result.data.data) draft[info.id] = info
}),
)
},
},
location: {
default() {
return defaultLocation()
@ -682,6 +716,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.reference.refresh(),
result.location.command.refresh(),
result.location.skill.refresh(),
result.shell.refresh(),
])
for (const failure of settled.filter((item) => item.status === "rejected"))
console.error("Failed to refresh default location data", failure.reason)

View file

@ -221,8 +221,8 @@ const TIPS: Tip[] = [
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input",
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight})",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agents/{/highlight} for specialized AI personas",
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools",
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions',
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}shell{/highlight}, and {highlight}webfetch{/highlight} tools",
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular shell permissions',
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands',
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing',
'Set {highlight}"formatter": true{/highlight} in config to enable built-in formatters like prettier, gofmt, and ruff',
@ -256,7 +256,7 @@ const TIPS: Tip[] = [
"Use {highlight}instructions{/highlight} in config to load additional rules files",
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)",
"Configure {highlight}steps{/highlight} to limit agentic iterations per request",
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools',
'Set {highlight}"tools": {"shell": false}{/highlight} to disable specific tools',
'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server',
"Override global tool settings per agent configuration",
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions',

View file

@ -1622,7 +1622,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
return (
<Show when={!shouldHide()}>
<Switch>
<Match when={display() === "bash"}>
<Match when={display() === "shell"}>
<Shell {...toolprops} />
</Match>
<Match when={display() === "glob"}>
@ -1646,7 +1646,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
<Match when={display() === "edit"}>
<Edit {...toolprops} />
</Match>
<Match when={display() === "task"}>
<Match when={display() === "subagent"}>
<Task {...toolprops} />
</Match>
<Match when={display() === "apply_patch"}>
@ -2111,8 +2111,8 @@ function Task(props: ToolProps) {
}}
>
{formatSubagentTitle(
Locale.titlecase(stringValue(props.input.subagent_type) ?? "General"),
description() ?? "Task",
Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General"),
description() ?? "Subagent",
props.metadata.background === true,
)}
</InlineTool>
@ -2124,7 +2124,7 @@ export function formatSubagentToolcalls(count: number) {
}
export function formatSubagentTitle(agent: string, description: string, background: boolean) {
return `${agent} Task${background ? " (background)" : ""}${description}`
return `${agent} Subagent${background ? " (background)" : ""}${description}`
}
export function formatSubagentRetry(attempt: number, message: string) {
@ -2402,7 +2402,7 @@ function numberValue(value: unknown) {
}
const toolDisplays = new Set([
"bash",
"shell",
"glob",
"read",
"grep",
@ -2410,7 +2410,7 @@ const toolDisplays = new Set([
"websearch",
"write",
"edit",
"task",
"subagent",
"apply_patch",
"todowrite",
"question",
@ -2418,7 +2418,10 @@ const toolDisplays = new Set([
])
export function toolDisplay(tool: string) {
return toolDisplays.has(tool) ? tool : "generic"
// Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render
// them with the renamed views.
const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool
return toolDisplays.has(normalized) ? normalized : "generic"
}
function recordValue(value: unknown): Record<string, unknown> | undefined {

View file

@ -287,7 +287,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
}
}
if (permission === "bash") {
if (permission === "shell") {
const command = typeof data.command === "string" ? data.command : ""
return {
body: (
@ -300,12 +300,17 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
}
}
if (permission === "task") {
const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown"
if (permission === "subagent" || permission === "task") {
const agent =
typeof data.agent === "string"
? data.agent
: typeof data.subagent_type === "string"
? data.subagent_type
: "Unknown"
const desc = typeof data.description === "string" ? data.description : ""
return {
icon: "#",
title: `${Locale.titlecase(type)} Task`,
title: `${Locale.titlecase(agent)} Subagent`,
body: (
<Show when={desc}>
<box paddingLeft={1}>