Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline a7e7646282 fix(tui): hide background badge on interrupted shells 2026-08-16 22:17:13 +00:00
opencode-agent[bot] fd2699b4d7 fix(tui): clarify saved permission copy (#41144)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
2026-08-16 17:14:19 -05:00
10 changed files with 32 additions and 72 deletions
+2 -2
View File
@@ -722,7 +722,7 @@ describe("ShellTool", () => {
)
const settled = yield* executeTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(settled.metadata).toMatchObject({ truncated: false })
expect(settled.metadata).toMatchObject({ status: "running", truncated: false })
expect(shellID).toStartWith("sh_")
const shell = yield* Shell.Service
@@ -807,7 +807,7 @@ describe("ShellTool", () => {
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
const settled = yield* Fiber.join(waiting)
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(settled.metadata).toMatchObject({ truncated: false })
expect(settled.metadata).toMatchObject({ status: "running", truncated: false })
expect(settled.content?.[0]).toEqual({
type: "text",
text: "The command was moved to the background.",
@@ -126,15 +126,6 @@ export const settings: Setting[] = [
values: ["horizontal", "vertical"],
keywords: ["sidebar", "orientation", "left"],
},
{
title: "Shortcut numbers",
category: "Tabs",
path: ["tabs", "numbers"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["keys", "numeric", "labels"],
},
{
title: "Layout",
category: "Diffs",
+10 -33
View File
@@ -38,7 +38,6 @@ import { projectName } from "../util/project"
import { marqueeCycleWidth, marqueeOverflows, marqueeTextParts } from "../util/marquee"
import { useDialog } from "../ui/dialog"
import { DialogSessionRename } from "./dialog-session-rename"
import { Spinner } from "./spinner"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
@@ -659,23 +658,14 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={1} paddingRight={1}>
<Show
when={!config.tabs.numbers && runs()}
fallback={
<text
width={numberWidth()}
fg={numberColor()}
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
>
{config.tabs.numbers ? sessionTabShortcutLabel(index()) : ""}
</text>
}
<text
width={numberWidth()}
fg={numberColor()}
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
>
<box width={numberWidth()}>
<Spinner color={numberColor()} />
</box>
</Show>
{sessionTabShortcutLabel(index())}
</text>
<text
width={titleWidth()}
fg={foreground()}
@@ -1154,22 +1144,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
<text width={1} selectable={false}>
{" "}
</text>
<Show
when={tab !== NEW_SESSION_TAB && !config.tabs.numbers && runs()}
fallback={
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
{tab === NEW_SESSION_TAB
? "+"
: config.tabs.numbers
? sessionTabShortcutLabel(tabNumber() - 1)
: ""}
</text>
}
>
<box width={numberWidth()}>
<Spinner color={numberColor()} />
</box>
</Show>
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
{tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
</text>
<text
width={availableTitleWidth()}
fg={foreground()}
-5
View File
@@ -153,9 +153,6 @@ export const Info = Schema.Struct({
layout: Schema.optional(Schema.Literals(["horizontal", "vertical"])).annotate({
description: "Show tabs in a horizontal strip or vertical sidebar",
}),
numbers: Schema.optional(Schema.Boolean).annotate({
description: "Show numeric shortcuts beside session tabs",
}),
}),
).annotate({ description: "Tab strip settings" }),
mini: Schema.optional(
@@ -227,7 +224,6 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
enabled: boolean
scope: "global" | "cwd"
layout: "horizontal" | "vertical"
numbers: boolean
}
}
@@ -273,7 +269,6 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
enabled: input.tabs?.enabled ?? true,
scope: input.tabs?.scope ?? "cwd",
layout: input.tabs?.layout ?? "horizontal",
numbers: input.tabs?.numbers ?? false,
},
}
}
+3 -3
View File
@@ -2930,7 +2930,7 @@ function Shell(props: ToolProps) {
const permission = useToolPermission(() => props.part)
const color = createMemo(() => (permission() ? theme.text.feedback.warning.default : theme.text.default))
const shellID = createMemo(() => stringValue(props.metadata.shellID))
const background = createMemo(() => Boolean(shellID()) && props.part.state.status !== "running")
const background = createMemo(() => isBackgroundTool(props.metadata, props.part.state.status))
const backgroundRunning = createMemo(() => {
const id = shellID()
return Boolean(id && data.shell.get(id))
@@ -3190,7 +3190,7 @@ function Subagent(props: ToolProps) {
if (id) navigate({ type: "session", sessionID: id })
}}
status={
isBackgroundSubagent(props.metadata, props.part.state.status) ? (
isBackgroundTool(props.metadata, props.part.state.status) ? (
<StatusBadge>Background</StatusBadge>
) : undefined
}
@@ -3200,7 +3200,7 @@ function Subagent(props: ToolProps) {
)
}
export function isBackgroundSubagent(
export function isBackgroundTool(
metadata: Record<string, unknown>,
status: SessionMessageAssistantTool["state"]["status"],
) {
+3 -3
View File
@@ -154,14 +154,14 @@ function wildcardDirectory(value: string) {
export function permissionAlwaysLines(input: { action: string; save?: ReadonlyArray<string> }): string[] {
const save = input.save ?? []
if (save.length === 1 && save[0] === "*") {
return [`This will allow ${input.action} until OpenCode is restarted.`]
return [`This will always allow ${input.action} for this project.`]
}
return ["This will allow the following patterns until OpenCode is restarted.", ...save.map((item) => `- ${item}`)]
return ["This will always allow the following patterns for this project.", ...save.map((item) => `- ${item}`)]
}
export function permissionOptionLabel(option: "once" | "always" | "reject" | "confirm" | "cancel") {
if (option === "once") return "Allow once"
if (option === "always") return "Allow always"
if (option === "always") return "Always allow"
if (option === "reject") return "Reject"
if (option === "confirm") return "Confirm"
return "Cancel"
@@ -4,7 +4,7 @@ import { testRender, type JSX } from "@opentui/solid"
import {
InlineToolRow,
executeCallSummary,
isBackgroundSubagent,
isBackgroundTool,
parseApplyPatchFiles,
parseDiagnostics,
parseQuestionAnswers,
@@ -214,11 +214,12 @@ describe("TUI inline tool wrapping", () => {
).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }])
})
test("labels only detached or async subagents as background", () => {
expect(isBackgroundSubagent({ status: "running" }, "running")).toBeFalse()
expect(isBackgroundSubagent({ status: "running" }, "completed")).toBeTrue()
expect(isBackgroundSubagent({ status: "running" }, "error")).toBeFalse()
expect(isBackgroundSubagent({ status: "completed" }, "completed")).toBeFalse()
test("labels only detached or async tools as background", () => {
expect(isBackgroundTool({ status: "running" }, "running")).toBeFalse()
expect(isBackgroundTool({ status: "running" }, "completed")).toBeTrue()
expect(isBackgroundTool({ status: "running" }, "error")).toBeFalse()
expect(isBackgroundTool({ status: "completed" }, "completed")).toBeFalse()
expect(isBackgroundTool({}, "completed")).toBeFalse()
})
test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
@@ -22,12 +22,12 @@ test("releasing a transcript selection over tab controls does not activate them"
close() {},
move() {},
add: () => setAdded((value) => value + 1),
status: () => ({ ...EMPTY_SESSION_TAB_STATUS, busy: true }),
status: () => EMPTY_SESSION_TAB_STATUS,
} satisfies SessionTabsController
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ animations: false, tabs: { enabled: true } })}>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<box flexDirection="column">
<SessionTabs controller={controller} animations={false} />
@@ -43,8 +43,6 @@ test("releasing a transcript selection over tab controls does not activate them"
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Second"))
expect(app.captureCharFrame()).not.toContain("1 First")
expect(app.captureCharFrame()).toContain("⋯ First")
await app.mockMouse.pressDown(5, 1)
await app.mockMouse.release(40, 0)
expect(active()).toBe("first")
+3 -5
View File
@@ -20,12 +20,11 @@ test("validates mini replay settings", () => {
test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ tabs: { enabled: true, layout: "vertical", numbers: true } })).toEqual({
tabs: { enabled: true, layout: "vertical", numbers: true },
expect(decode({ tabs: { enabled: true, layout: "vertical" } })).toEqual({
tabs: { enabled: true, layout: "vertical" },
})
expect(() => decode({ tabs: { layout: true } })).toThrow()
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
expect(() => decode({ tabs: { numbers: "on" } })).toThrow()
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
expect(decode({ session: { new_location: "inherit" } })).toEqual({ session: { new_location: "inherit" } })
@@ -49,7 +48,7 @@ test("resolves nested config and keybind defaults", () => {
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
expect(config.diffs).toEqual({ view: "split" })
expect(config.debug).toEqual({ devtools: true })
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal", numbers: false })
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
expect(config.session.new_location).toBe("launch")
})
@@ -57,7 +56,6 @@ test("shows resolved tab defaults in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true)
expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd")
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
expect(settings.find((setting) => setting.path.join(".") === "tabs.numbers")?.default).toBe(false)
})
test("shows the new session location default in settings", () => {
@@ -183,11 +183,11 @@ describe("run permission shared", () => {
test("formats always-allow copy for wildcard and explicit patterns", () => {
expect(permissionAlwaysLines(req({ action: "bash", save: ["*"] }))).toEqual([
"This will allow bash until OpenCode is restarted.",
"This will always allow bash for this project.",
])
expect(permissionAlwaysLines(req({ save: ["src/**/*.ts", "src/**/*.tsx"] }))).toEqual([
"This will allow the following patterns until OpenCode is restarted.",
"This will always allow the following patterns for this project.",
"- src/**/*.ts",
"- src/**/*.tsx",
])