Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions packages/opencode/src/tool/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,27 @@ function commands(node: Node) {
return node.descendantsOfType("command").filter((child): child is Node => Boolean(child))
}

function formatCommand(root: Node, command: string): string {
const starts: number[] = []
const walk = (node: Node) => {
if (node.text === "&&") starts.push(node.startIndex)
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i)
if (child) walk(child)
}
}
walk(root)
if (starts.length === 0) return command
const pieces: string[] = []
let prev = 0
for (const start of starts) {
pieces.push(command.slice(prev, start).replace(/\s+$/, ""))
prev = start
}
pieces.push(command.slice(prev))
return pieces.join(" \\\n")
}

function unquote(text: string) {
if (text.length < 2) return text
const first = text[0]
Expand Down Expand Up @@ -260,7 +281,11 @@ const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boole
return tree
})

const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan, input: { command: string }) {
const ask = Effect.fn("ShellTool.ask")(function* (
ctx: Tool.Context,
scan: Scan,
input: { command: string; commandDisplay: string },
) {
if (scan.dirs.size > 0) {
const directories = Array.from(scan.dirs)
const globs = directories.map((dir) => {
Expand All @@ -273,6 +298,7 @@ const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan,
always: globs,
metadata: {
command: input.command,
commandDisplay: input.commandDisplay,
directories,
patterns: globs,
},
Expand All @@ -286,6 +312,7 @@ const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan,
always: Array.from(scan.always),
metadata: {
command: input.command,
commandDisplay: input.commandDisplay,
},
})
})
Expand Down Expand Up @@ -429,6 +456,7 @@ export const ShellTool = Tool.define(
input: {
shell: string
command: string
commandDisplay: string
cwd: string
env: NodeJS.ProcessEnv
timeout: number
Expand Down Expand Up @@ -475,6 +503,7 @@ export const ShellTool = Tool.define(
yield* ctx.metadata({
metadata: {
output: "",
commandDisplay: input.commandDisplay,
},
})

Expand Down Expand Up @@ -515,6 +544,7 @@ export const ShellTool = Tool.define(
ctx.metadata({
metadata: {
output: last,
commandDisplay: input.commandDisplay,
},
}),
),
Expand All @@ -525,6 +555,7 @@ export const ShellTool = Tool.define(
return ctx.metadata({
metadata: {
output: last,
commandDisplay: input.commandDisplay,
},
})
}),
Expand Down Expand Up @@ -588,6 +619,7 @@ export const ShellTool = Tool.define(
output: last || preview(output),
exit: code,
truncated: cut,
commandDisplay: input.commandDisplay,
...(cut && file ? { outputPath: file } : {}),
},
output,
Expand Down Expand Up @@ -617,21 +649,24 @@ export const ShellTool = Tool.define(
}
const timeout = params.timeout ?? defaultTimeoutMs
const ps = Shell.ps(shell)
yield* Effect.scoped(
const commandDisplay = yield* Effect.scoped(
Effect.gen(function* () {
const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) =>
Effect.sync(() => tree.delete()),
)
const scan = yield* collect(tree.rootNode, cwd, ps, shell, instanceCtx)
if (!containsPath(cwd, instanceCtx)) scan.dirs.add(cwd)
yield* ask(ctx, scan, params)
const display = formatCommand(tree.rootNode, params.command)
yield* ask(ctx, scan, { command: params.command, commandDisplay: display })
return display
}),
)

return yield* run(
{
shell,
command: params.command,
commandDisplay,
cwd,
env: yield* shellEnv(ctx, cwd),
timeout,
Expand Down
62 changes: 62 additions & 0 deletions packages/opencode/test/tool/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,68 @@ describe("tool.shell permissions", () => {
}),
)

each("exposes commandDisplay split across lines for chained commands", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "echo foo && echo bar && echo baz",
},
capture(requests),
)
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
expect(bashReq!.metadata.commandDisplay).toBe("echo foo \\\n&& echo bar \\\n&& echo baz")
}),
)
}),
)

each("leaves single command commandDisplay unchanged", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "echo hello",
},
capture(requests),
)
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
expect(bashReq!.metadata.commandDisplay).toBe("echo hello")
}),
)
}),
)

if (process.platform !== "win32") {
it.live("does not split && inside quotes in commandDisplay", () =>
runIn(
projectRoot,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: 'echo "a && b" && echo c',
},
capture(requests),
)
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
expect(bashReq!.metadata.commandDisplay).toBe('echo "a && b" \\\n&& echo c')
}),
),
)
}

for (const item of ps) {
it.live(`parses PowerShell conditionals for permission prompts [${item.label}]`, () =>
withShell(
Expand Down
9 changes: 7 additions & 2 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2089,8 +2089,13 @@ function Shell(props: ToolProps) {
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<Show when={isRunning()} fallback={<text fg={theme.text}>$ {stringValue(props.input.command)}</text>}>
<Spinner color={theme.text}>{stringValue(props.input.command)}</Spinner>
<Show
when={isRunning()}
fallback={<text fg={theme.text}>$ {stringValue(props.metadata.commandDisplay) ?? stringValue(props.input.command)}</text>}
>
<Spinner color={theme.text}>
{stringValue(props.metadata.commandDisplay) ?? stringValue(props.input.command)}
</Spinner>
</Show>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
Expand Down
3 changes: 2 additions & 1 deletion packages/tui/src/routes/session/permission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,8 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
}

if (permission === "bash") {
const command = typeof data.command === "string" ? data.command : ""
const raw = props.request.metadata?.commandDisplay
const command = (typeof raw === "string" ? raw : "") || (typeof data.command === "string" ? data.command : "")
return {
icon: "#",
title: "Shell command",
Expand Down
Loading