Compare commits

...
9 changed files with 639 additions and 33 deletions
+35 -6
View File
@@ -149,6 +149,7 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
const char = input[index]
if (!wordStarted) wordStart = index
if (!quote && !wordStarted) {
if (char === " " || char === "\t") continue
const structure = structures.at(-1)
const token = /^[A-Za-z_][A-Za-z0-9_]*(?=[ \t\n;()<>]|$)/.exec(input.slice(index))?.[0]
if (structure?.kind === "case" && structure.phase === "header" && token === "in") {
@@ -166,13 +167,24 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
segment = index + 1
continue
}
if (structure?.kind === "for" && structure.phase === "header" && char === "(" && input[index + 1] !== "(") {
const values = bashExpansion(input, index, depth, "array")
if (!values) return { kind: "opaque", reason: "compound-command" }
finishCommand()
const failure = addSubstitutions(values)
if (failure) return failure
commands.push(...nestedCommands.splice(0))
// Zsh permits a sublist or brace group directly after the value list, without do/done.
structure.phase = "do"
if (!/^(?:[ \t\n;]|\\\n|#[^\n]*(?:\n|$))*do(?=[ \t\n;]|$)/.test(input.slice(values.end + 1))) structures.pop()
index = values.end
segment = index + 1
continue
}
if (!words.length && !hasRedirect && !compoundEnd) {
const definition =
/^(?:function[ \t]+[A-Za-z_][A-Za-z0-9_]*(?:[ \t]*\([ \t]*\))?|[A-Za-z_][A-Za-z0-9_]*[ \t]*\([ \t]*\))[ \t\n]*(?=[{(])/.exec(
input.slice(index),
)
const definition = bashFunctionHead(input, index)
if (definition && !header()) {
index += definition[0].length - 1
index += definition.length - 1
segment = index + 1
continue
}
@@ -536,6 +548,14 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
type BashExpansion = { source: string; end: number; substitutions?: string[] }
function bashFunctionHead(input: string, start: number) {
// Share recognition with delimiter scanning so case patterns in function bodies do not close the outer group.
// Names need not be variable identifiers. Zsh permits anonymous functions, including in an if condition.
return /^(?!if(?:[ \t]|\\\n)*\()(?:function[ \t]+(?:\\\n[ \t]*)*[A-Za-z_][A-Za-z0-9_.:-]*(?:(?:[ \t]|\\\n)*\([ \t]*\))?|(?:[A-Za-z_][A-Za-z0-9_.:-]*(?:[ \t]|\\\n)*)?\([ \t]*\))(?:[ \t\n]|\\\n|#[^\n]*(?:\n|$))*(?=[{(]|\[\[(?=[ \t\n])|(?:if|while|until|for|select|case)[ \t\n])/.exec(
input.slice(start),
)?.[0]
}
function bashDelimited(input: string, start: number, depth: number): BashExpansion | undefined {
if (depth >= MAX_SUBSTITUTION_DEPTH) return
const close = input[start] === "{" ? "}" : ")"
@@ -544,6 +564,7 @@ function bashDelimited(input: string, start: number, depth: number): BashExpansi
let commandStart = true
for (let index = start + 1; index < input.length; index++) {
const char = input[index]
if (char === " " || char === "\t") continue
if (char === "\\") {
if (input[index + 1] !== "\n") commandStart = false
index++
@@ -566,6 +587,11 @@ function bashDelimited(input: string, start: number, depth: number): BashExpansi
continue
}
const boundary = index === start + 1 || /[ \t\n;|&(){}]/.test(input[index - 1])
const definition = commandStart && boundary ? bashFunctionHead(input, index) : undefined
if (definition) {
index += definition.length - 1
continue
}
if (char === "#" && boundary) {
const newline = input.indexOf("\n", index)
if (newline < 0) return
@@ -725,7 +751,10 @@ function bashExpansion(
index = nested.end
continue
}
if (kind === "array" && "<>=".includes(char) && input[index + 1] === "(") {
if (
((kind === "array" && "<>=".includes(char)) || (kind === "test" && "<>".includes(char))) &&
input[index + 1] === "("
) {
const nested = bashDelimited(input, index + 1, depth + 1)
if (!nested) return
substitutions.push(nested.source)
@@ -0,0 +1,132 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { ShellParse } from "../../src/shell/parse.js"
import { ShellScan } from "../../src/shell/scan.js"
const contexts = [
(source: string) => source,
(source: string) => `( ${source} )`,
(source: string) => `{ ${source}; }`,
(source: string) => `if true; then ${source}; fi`,
(source: string) => `outer() { ${source}; }; outer`,
]
const bodies = [
"for value in one two; do scan_probe; done",
"while true; do scan_probe; break; done",
"until false; do scan_probe; break; done",
"case value in value) scan_probe;; *) scan_other;; esac",
]
describe("compound function acceptance", () => {
for (const shell of ["bash", "zsh"]) {
for (const head of ["probe()", "function probe", "function probe()", "probe-name()"])
for (const body of bodies)
for (const context of contexts) {
const name = head.includes("probe-name") ? "probe-name" : "probe"
const source = context(`${head} ${body}; ${name}`)
test(`${shell}: ${source}`, async () => {
// Braces preserve the function's behavior, but avoid Tree-sitter's recovery artifacts.
const legacy = await Effect.runPromise(
ShellParse.scan(context(`${head} { ${body}; }; ${name}`), shell, "/workspace"),
)
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
})
}
}
test.each(bodies)("keeps compound function bodies inside command substitutions: %s", (body) => {
const source = `printf '%s' "$( probe() ${body}; probe )"`
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands[0]?.resource).toBe(source)
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
expect(result.commands.at(-1)?.words).toEqual(["probe"])
})
})
const values = [
"one two",
"'two words' one",
"'cd' '/outside'",
"'do' 'done'",
"'(literal)' '$(scan_ignored)'",
"one\\\ntwo",
"$(printf one)",
'"$(printf one)"',
"<(printf one)",
"",
]
const loops = values.flatMap((value) =>
[
`for value (${value}) scan_probe "$value"`,
`for value (${value}) { scan_probe "$value"; }`,
...(value
? [
`for value (${value}) do scan_probe "$value"; done`,
`for value (${value}); do scan_probe "$value"; done`,
`for value (${value})\ndo scan_probe "$value"; done`,
`for value (${value}) # ignored\ndo scan_probe "$value"; done`,
`for value (${value}) \\\ndo scan_probe "$value"; done`,
]
: []),
].map((source) => ({ source, equivalent: `for value in ${value}; do scan_probe "$value"; done` })),
)
describe("Zsh parenthesized loop acceptance", () => {
for (const fixture of loops)
for (const context of contexts) {
const source = context(fixture.source)
test(source, async () => {
const legacy = await Effect.runPromise(ShellParse.scan(context(fixture.equivalent), "zsh", "/workspace"))
expect(await Effect.runPromise(ShellParse.scanPortable(source, "zsh", "/workspace"))).toEqual(legacy)
})
}
test.each([
"for x (one two) for y (a b) scan_probe",
"for x (one two) scan_probe && scan_other",
"for x (one two) scan_probe | scan_other",
"printf '%s' \"$(for x (one two) scan_probe)\"",
"for x (one two) { for y (a b); do scan_probe; done; }",
"for x (one two) [[ $(scan_probe) == ok ]]",
"for x (one two) (( 1 + $(scan_probe) ))",
])("retains commands in nested shorthand loops: %s", (source) => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
if (source.includes("scan_other"))
expect(result.commands.map((command) => command.words[0])).toContain("scan_other")
})
})
describe("real-shell compound syntax", () => {
for (const shell of ["bash", "zsh"]) {
const executable = Bun.which(shell)
test
.skipIf(!executable)
.each([
...bodies.map((body) => `probe() ${body}; probe`),
...bodies.map((body) => `printf '%s' "$(probe() ${body}; probe)"`),
...(shell === "zsh" ? loops.map((fixture) => fixture.source) : []),
])(`${shell}: %s`, (source) => {
if (!executable) throw new Error(`${shell} is unavailable`)
const execution = Bun.spawnSync(
[
executable,
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
"-c",
`scan_probe() { printf 'executed\\n' >&2; }; ${source}; wait`,
],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" }, timeout: 2_000 },
)
expect(execution.exitCode).toBe(0)
expect(execution.stderr.toString()).toEqual(
source.includes("value ()") ? "" : expect.stringContaining("executed\n"),
)
expect(ShellScan.scan(source).kind).toBe("scanned")
})
}
})
@@ -0,0 +1,158 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { ShellParse } from "../../src/shell/parse.js"
import { ShellScan } from "../../src/shell/scan.js"
const conditions = ["[[ -n <(scan_probe) ]]", "[[ -n >(scan_probe) ]]"]
const contexts = [
(source: string) => source,
(source: string) => `( ${source} )`,
(source: string) => `{ ${source}; }`,
(source: string) => `if ${source}; then printf visible; fi`,
(source: string) => `check() { ${source}; }; check`,
(source: string) => `printf '%s' "$( ${source}; printf visible)"`,
]
const functions = ["probe", "probe-name", "probe.name", "probe:name"].flatMap((name) =>
[`${name}()`, `function ${name}`, `function ${name}()`].flatMap((head) =>
[
"{ scan_probe; }",
"(scan_probe)",
"if true; then scan_probe; fi",
"[[ $(scan_probe) == ok ]]",
"(( 1 + $(scan_probe) ))",
].map((body) => `${head} ${body}; ${name}`),
),
)
describe("legacy-accepted shell syntax regressions", () => {
test.each(["() { scan_probe; }", "probe() { scan_probe; }; probe"])(
"preserves deeply indented function definitions: %s",
async (source) => {
const command = `( ${" ".repeat(32_000)}${source} )`
const legacy = await Effect.runPromise(ShellParse.scan(command, "zsh", "/workspace"))
expect(await Effect.runPromise(ShellParse.scanPortable(command, "zsh", "/workspace"))).toEqual(legacy)
},
)
test.each(conditions.flatMap((source) => contexts.map((context) => context(source))))(
"retains conditional process substitutions and permission resources: %s",
async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, "bash", "/workspace"))
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
expect(await Effect.runPromise(ShellParse.scanPortable(source, "bash", "/workspace"))).toEqual(legacy)
},
)
for (const shell of ["bash", "zsh"]) {
test.each(
["probe()", "probe \\\n()", "function \\\nprobe()", "function probe \\\n()"].flatMap((head) =>
[" \\\n", " \\\n # ignored ) }\n", "# ignored \\\n"].flatMap((gap) =>
contexts.map((context) => context(`${head}${gap}{ scan_probe; }; probe`)),
),
),
)(`${shell} preserves line continuations at function boundaries: %s`, async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
})
test.each(
["probe()", "function probe", "function probe()"].flatMap((head) =>
[" # ignored ) }\n", "\n# ignored ) }\n\n", " # first\n# second\n"].flatMap((gap) =>
contexts.map((context) => context(`${head}${gap}{ scan_probe; }; probe`)),
),
),
)(`${shell} preserves comments between a function head and its body: %s`, async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
})
test.each(functions)(
`${shell} preserves function resources, saved prefixes, and directories: %s`,
async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
},
)
const executable = Bun.which(shell)
test
.skipIf(!executable)
.each([
"probe-name() { scan_probe; }; probe-name",
"function probe.name { scan_probe; }; probe.name",
"probe:name() if true; then scan_probe; fi; probe:name",
"probe()# ignored ) }\n{ scan_probe; }; probe",
"probe \\\n() \\\n{ scan_probe; }; probe",
"function \\\nprobe() # ignored \\\n{ scan_probe; }; probe",
...(shell === "bash" ? conditions : ["() { scan_probe; }"]),
])(`${shell} really executes the extracted command: %s`, (source) => {
if (!executable) throw new Error(`${shell} is unavailable`)
const execution = Bun.spawnSync(
[
executable,
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
"-c",
`scan_probe() { printf 'executed\\n' >&2; }; ${source}; wait`,
],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
)
expect(execution.exitCode).toBe(0)
expect(execution.stderr.toString()).toBe("executed\n")
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
})
}
test.each([
"() { scan_probe; }",
"( () { scan_probe; } )",
"{ () { scan_probe; }; }",
"while() { scan_probe; break; }",
"until() { scan_probe; break; }",
])("preserves Zsh anonymous functions and parenthesized loop permissions: %s", async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, "zsh", "/workspace"))
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
expect(await Effect.runPromise(ShellParse.scanPortable(source, "zsh", "/workspace"))).toEqual(legacy)
})
// Tree-sitter recovers these valid Zsh forms with synthetic commands or truncated outer resources.
// Pin both results rather than treating recovery artifacts as executable shell syntax.
test.each([
{
source: "if () { scan_probe; }; then printf visible; fi",
legacy: ["scan_probe", "then printf visible", "fi"],
portable: ["scan_probe", "printf visible"],
},
{
source: "check() { () { scan_probe; }; }; check",
legacy: ["scan_probe", "}", "check"],
portable: ["scan_probe", "check"],
},
{
source: "printf '%s' \"$( () { scan_probe; }; printf visible)\"",
legacy: ["printf '%s'", "scan_probe", "printf visible"],
portable: ["printf '%s' \"$( () { scan_probe; }; printf visible)\"", "scan_probe", "printf visible"],
},
])("accepts anonymous-function compositions despite legacy recovery artifacts: $source", async (fixture) => {
const legacy = await Effect.runPromise(ShellParse.scan(fixture.source, "zsh", "/workspace"))
const portable = await Effect.runPromise(ShellParse.scanPortable(fixture.source, "zsh", "/workspace"))
expect(legacy.commands.map((command) => command.resource)).toEqual([...fixture.legacy])
expect(portable.commands.map((command) => command.resource)).toEqual([...fixture.portable])
})
test.each([
"[[ -n '<(scan_ignored)' ]]",
'[[ -n "<(scan_ignored)" ]]',
"[[ -n '>(scan_ignored)' ]]",
'[[ -n ">(scan_ignored)" ]]',
"[[ -n $'<(scan_ignored)' ]]",
])("does not turn quoted process-substitution text into commands: %s", (source) => {
expect(ShellScan.scan(source)).toEqual({ kind: "scanned", commands: [] })
})
})
@@ -3,7 +3,30 @@ import { ShellScan } from "../../src/shell/scan.js"
const pwsh = process.env.SHELL_SCAN_PWSH ?? Bun.which("pwsh")
// These ordinary forms must stay accepted, not disappear behind the oracle's opaque-result filter.
const supported = [
"Invoke-ProbeA; Invoke-ProbeB",
"$result = Invoke-ProbeA; Invoke-ProbeB",
"if (Invoke-ProbeA) { Invoke-ProbeB } else { Invoke-ProbeC }",
"foreach ($item in (Invoke-ProbeA)) { Invoke-ProbeB }",
"function Get-Probe { param($x); Invoke-ProbeB }; Invoke-ProbeA",
"$x = @{ first = Invoke-ProbeA; second = @(Invoke-ProbeB; Invoke-ProbeC) }",
'Invoke-ProbeA "$(Invoke-ProbeB "$(Invoke-ProbeC)")"',
"Invoke-ProbeA | ForEach-Object { Invoke-ProbeB }",
"Invoke-ProbeA @'\nliteral ; }\n'@; Invoke-ProbeB",
'Invoke-ProbeA @"\n$(Invoke-ProbeB)\n"@; Invoke-ProbeC',
"Invoke-ProbeA `\n argument; Invoke-ProbeB",
"Invoke-ProbeA 2>&1; Invoke-ProbeB",
"& 'Invoke-ProbeA' argument; Invoke-ProbeB",
"Invoke-ProbeA --% literal; ignored\nInvoke-ProbeB",
]
test.each(supported)("accepts supported PowerShell syntax without an opaque escape hatch: %s", (source) => {
expect(ShellScan.scanPowerShell(source).kind).toBe("scanned")
})
const fixtures = [
...supported,
...[
"$result = Invoke-ProbeA; Invoke-ProbeB",
"$result = (Invoke-ProbeA); Invoke-ProbeB",
@@ -343,6 +366,10 @@ test.skipIf(!pwsh)(
let executed = 0
for (const result of results) {
const scan = ShellScan.scanPowerShell(result.source)
if (supported.includes(result.source)) {
expect(result.errors, result.source).toEqual([])
expect(scan.kind, result.source).toBe("scanned")
}
if (scan.kind === "opaque" || result.errors.length > 0) continue
scanned++
executed += result.executed.length
+144
View File
@@ -526,6 +526,150 @@ describe("ShellTool scanner permissions", () => {
}
})
describe("ShellTool conditional process substitution", () => {
const test = isWindows || !Bun.which("bash") ? permissionIt.live.skip : permissionIt.live
for (const portable of [false, true]) {
test(`${portable ? "native" : "legacy"}: a nested deny prevents the substitution from running`, () =>
withScanner(
portable,
(registry, directory) =>
Effect.gen(function* () {
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(toolIdentity.agent, (agent) => {
agent.permissions = [
{ action: "shell", resource: "*", effect: "allow" },
{ action: "shell", resource: "printf *", effect: "deny" },
]
}),
)
const marker = path.join(directory.active, "marker")
const result = yield* runPermissionCommand(
registry,
'[[ -n <(printf reached > marker) ]]; wait "$!"',
marker,
[],
)
expect(result.exit).toMatchObject({
_tag: "Success",
value: { status: "error", error: { message: expect.stringContaining("Permission denied: shell") } },
})
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
}),
"bash",
))
for (const reply of ["reject", "once", "always"] as const) {
test(`${portable ? "native" : "legacy"}: conditional substitutions respect ${reply}`, () =>
withScanner(
portable,
(registry, directory) =>
Effect.gen(function* () {
const saved = yield* PermissionSaved.Service
const location = yield* Location.Service
yield* saved.add({ projectID: location.project.id, action: "shell", resources: ["wait *"] })
const marker = path.join(directory.active, "marker")
const command = '[[ -n <(printf reached > marker) ]]; wait "$!"'
const result = yield* runPermissionCommand(registry, command, marker, [reply])
expect(result.requests).toMatchObject([
{ action: "shell", resources: ["printf reached > marker", 'wait "$!"'], save: ["printf *", "wait *"] },
])
if (reply === "reject") {
expect(Exit.isFailure(result.exit)).toBe(true)
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
return
}
expect(result.exit).toMatchObject({
_tag: "Success",
value: { status: "completed", metadata: { exit: 0 } },
})
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("reached")
yield* Effect.promise(() => fs.unlink(marker))
const repeat = yield* runPermissionCommand(
registry,
command,
marker,
reply === "always" ? [] : ["reject"],
)
expect(repeat.requests).toHaveLength(reply === "always" ? 0 : 1)
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(reply === "always")
}),
"bash",
))
}
}
})
describe("ShellTool compound syntax approval compatibility", () => {
for (const fixture of [
{
shell: "zsh",
command: 'for value (a b) printf %s "$value"',
equivalent: 'for value in a b; do printf %s "$value"; done',
output: "ab",
saved: ["printf *"],
},
{
shell: "zsh",
command: 'for value (a b) { printf %s "$value"; }',
equivalent: 'for value in a b; do printf %s "$value"; done',
output: "ab",
saved: ["printf *"],
},
{
shell: "zsh",
command: 'for value ($(printf a)) do printf %s "$value"; done',
equivalent: 'for value in $(printf a); do printf %s "$value"; done',
output: "a",
saved: ["printf *"],
},
{
shell: "bash",
command: 'probe() for value in a b; do printf %s "$value"; done; probe',
equivalent: 'probe() { for value in a b; do printf %s "$value"; done; }; probe',
output: "ab",
saved: ["printf *", "probe *"],
},
{
shell: "bash",
command: 'printf %s "$(probe() case value in value) printf hello;; esac; probe)"',
equivalent: 'printf %s "$(probe() { case value in value) printf hello;; esac; }; probe)"',
output: "hello",
saved: ["printf *", "probe *"],
},
]) {
const test = isWindows || !Bun.which(fixture.shell) ? permissionIt.live.skip : permissionIt.live
for (const portable of [false, true]) {
test(`${fixture.shell} ${portable ? "native" : "legacy equivalent"}: ${fixture.command}`, () =>
withScanner(
portable,
(registry, directory) =>
Effect.gen(function* () {
const saved = yield* PermissionSaved.Service
const location = yield* Location.Service
yield* saved.add({ projectID: location.project.id, action: "shell", resources: fixture.saved })
const result = yield* runPermissionCommand(
registry,
portable ? fixture.command : fixture.equivalent,
path.join(directory.active, "marker"),
[],
)
expect(result.requests).toEqual([])
expect(result.exit).toMatchObject({
_tag: "Success",
value: {
status: "completed",
metadata: { exit: 0 },
content: [{ type: "text", text: fixture.output }, { type: "text" }],
},
})
}),
fixture.shell,
))
}
}
})
describe("ShellTool ordinary shell syntax", () => {
for (const shell of ["bash", "zsh"]) {
const test = isWindows || !Bun.which(shell) ? permissionIt.live.skip : permissionIt.live
@@ -105,7 +105,7 @@ export function ShellTab(props: { sessionID: string }) {
backgroundColor={
active() ? theme.background.action.primary.focused : theme.background.action.primary.default
}
onMouseOver={() => setStore("selected", index())}
onMouseMove={() => setStore("selected", index())}
>
<text
fg={active() ? theme.text.action.primary.focused : theme.text.action.primary.default}
@@ -215,7 +215,7 @@ export function SubagentsTab(props: { sessionID: string }) {
? theme.background.action.primary.selected
: theme.background.action.primary.default
}
onMouseOver={() => setStore("selected", index())}
onMouseMove={() => setStore("selected", index())}
onMouseUp={() => {
setStore("selected", index())
navigate({ type: "session", sessionID: entry.sessionID })
@@ -84,7 +84,7 @@ export function TerminalsTab(props: { sessionID: string; visibleTerminalID?: str
? theme.background.action.primary.selected
: theme.background.action.primary.default
}
onMouseOver={() => setSelected(index())}
onMouseMove={() => setSelected(index())}
onMouseUp={() => {
setSelected(index())
select()
@@ -1,7 +1,8 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { expect, test } from "bun:test"
import { onMount } from "solid-js"
import { createSignal, onMount } from "solid-js"
import { ConfigProvider } from "../../../src/config"
import type { TuiKeybind } from "../../../src/config/keybind"
import { ClientProvider } from "../../../src/context/client"
@@ -9,8 +10,13 @@ import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider } from "../../../src/context/location"
import { RouteProvider, useRoute } from "../../../src/context/route"
import { TuiAppProvider } from "../../../src/context/runtime"
import { SessionTerminalsProvider, useSessionTerminals } from "../../../src/context/session-terminals"
import { StorageProvider, useStorage } from "../../../src/context/storage"
import { ThemeProvider } from "../../../src/context/theme"
import { Composer } from "../../../src/routes/session/composer"
import { ToastProvider } from "../../../src/ui/toast"
import { tmpdir } from "../../fixture/fixture"
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -22,9 +28,22 @@ const sessions = {
}
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")]
const terminals = ["First terminal", "Second terminal"].map((title, index) => ({
id: `pty-${index}`,
title,
command: "/bin/sh",
args: [],
cwd: directory,
status: "running",
pid: index + 1,
sessionID: "parent",
foregroundProcess: null,
size: { cols: 100, rows: 20 },
output: { head: 0, tail: 0 },
}))
async function renderComposer(
defaultTab: "subagents" | "shell",
defaultTab: "subagents" | "shell" | "terminals",
keybinds: Partial<TuiKeybind.Keybinds>,
focusedTextarea = false,
) {
@@ -32,10 +51,14 @@ async function renderComposer(
const interrupted: string[] = []
const removed: string[] = []
const ready = Promise.withResolvers<void>()
const [open, setOpen] = createSignal(true)
const temporary = await tmpdir()
let closed = 0
let dispatch!: ReturnType<typeof Keymap.use>["dispatch"]
let route!: ReturnType<typeof useRoute>
let storage!: ReturnType<typeof useStorage>
const calls = createFetch((url, request) => {
if (url.pathname === "/api/experimental/session/parent/terminal") return json({ data: terminals })
if (url.pathname === "/api/session/active")
return json({ data: { "child-a": { type: "running" }, "child-b": { type: "running" } } })
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
@@ -61,6 +84,8 @@ async function renderComposer(
function Content() {
const data = useData()
const terminals = useSessionTerminals()
storage = useStorage()
route = useRoute()
dispatch = Keymap.use().dispatch
onMount(() => {
@@ -69,6 +94,7 @@ async function renderComposer(
data.session.sync("child-a"),
data.session.sync("child-b"),
data.shell.sync(),
terminals.refresh("parent"),
])
.then(() => wait(() => data.session.status("child-a") === "running"))
.then(() => ready.resolve(), ready.reject)
@@ -76,7 +102,13 @@ async function renderComposer(
return (
<>
{focusedTextarea && <textarea focused={true} initialValue="draft" />}
<Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
<Composer
sessionID="parent"
open={open()}
defaultTab={defaultTab}
visibleTerminalID="pty-0"
onClose={() => closed++}
/>
</>
)
}
@@ -92,23 +124,31 @@ async function renderComposer(
const app = await testRender(
() => (
<TestTuiContexts directory={directory}>
<ConfigProvider config={createTuiResolvedConfig({ keybinds, session: { terminal: false } })}>
<Keymap.Provider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={process.cwd()}>
<LocationProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
<Content />
</ThemeProvider>
</RouteProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
<AppExit />
</Keymap.Provider>
</ConfigProvider>
<TestTuiContexts directory={directory} paths={{ state: temporary.path }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ConfigProvider config={createTuiResolvedConfig({ keybinds, session: { terminal: true } })}>
<Keymap.Provider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={process.cwd()}>
<LocationProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
<ToastProvider>
<SessionTerminalsProvider>
<Content />
</SessionTerminalsProvider>
</ToastProvider>
</ThemeProvider>
</RouteProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
<AppExit />
</Keymap.Provider>
</ConfigProvider>
</StorageProvider>
</TuiAppProvider>
</TestTuiContexts>
),
{ width: 100, height: 20, kittyKeyboard: true },
@@ -122,9 +162,85 @@ async function renderComposer(
route: () => route.data,
dispatch: (command: string) => dispatch(command),
closed: () => closed,
setOpen,
selected: () =>
app
.captureSpans()
.lines.flatMap((line) => line.spans)
.filter((span) => span.attributes & TextAttributes.BOLD)
.map((span) => span.text.trim()),
async dispose() {
app.renderer.destroy()
await storage.flush()
await temporary[Symbol.asyncDispose]()
},
}
}
const tabs = [
{ tab: "subagents", first: "Build: First", second: "Build: Second" },
{ tab: "shell", first: "bun test", second: "bun dev" },
{ tab: "terminals", first: "First terminal", second: "Second terminal" },
] as const
test.each([...tabs])("opening $tab under a stationary pointer preserves selection", async ({ tab, first, second }) => {
const composer = await renderComposer(tab, {})
try {
const row = composer.app
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes(second))
expect(row).toBeGreaterThan(0)
expect(composer.selected()).toContain(first)
composer.setOpen(false)
await composer.app.renderOnce()
await composer.app.mockMouse.moveTo(10, row)
composer.setOpen(true)
await composer.app.renderOnce()
await composer.app.renderOnce()
expect(composer.selected()).toContain(first)
expect(composer.selected()).not.toContain(second)
} finally {
await composer.dispose()
}
})
test.each([...tabs])("moving within a $tab row selects it after opening", async ({ tab, first, second }) => {
const composer = await renderComposer(tab, {})
try {
const row = composer.app
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes(second))
expect(row).toBeGreaterThan(0)
composer.setOpen(false)
await composer.app.renderOnce()
await composer.app.mockMouse.moveTo(10, row)
composer.setOpen(true)
await composer.app.renderOnce()
await composer.app.renderOnce()
await composer.app.mockMouse.moveTo(11, row)
await composer.app.renderOnce()
expect(composer.selected()).toContain(second)
expect(composer.selected()).not.toContain(first)
composer.app.mockInput.pressArrow("up")
await composer.app.renderOnce()
await composer.app.renderOnce()
expect(composer.selected()).toContain(first)
await composer.app.mockMouse.moveTo(12, row)
await composer.app.renderOnce()
expect(composer.selected()).toContain(second)
} finally {
await composer.dispose()
}
})
test("disabled subagent bindings have no component fallbacks", async () => {
const composer = await renderComposer("subagents", {
"composer.subagent.up": "none",
@@ -146,7 +262,7 @@ test("disabled subagent bindings have no component fallbacks", async () => {
composer.dispatch("composer.subagent.select")
expect(composer.route()).toMatchObject({ type: "session", sessionID: "child-a" })
} finally {
composer.app.renderer.destroy()
await composer.dispose()
}
})
@@ -169,7 +285,7 @@ test("disabled shell bindings have no component fallbacks", async () => {
await wait(() => composer.removed.length === 1)
expect(composer.removed).toEqual(["sh-a"])
} finally {
composer.app.renderer.destroy()
await composer.dispose()
}
})
@@ -183,7 +299,7 @@ test("configured composer bindings work with a focused textarea", async () => {
await wait(() => composer.removed.length === 1)
expect(composer.removed).toEqual(["sh-a"])
} finally {
composer.app.renderer.destroy()
await composer.dispose()
}
})
@@ -194,7 +310,7 @@ test("ctrl+c closes the active composer", async () => {
composer.app.mockInput.pressKey("c", { ctrl: true })
await composer.app.waitFor(() => composer.closed() === 1)
} finally {
composer.app.renderer.destroy()
await composer.dispose()
}
})