Compare commits

..

3 Commits

Author SHA1 Message Date
opencode-agent[bot] ed708f9dc2 fix(tui): keep shell commands full width (#42339)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-08-13 14:45:09 -04:00
Kit Langton f2408060b7 fix(core): render granular instruction updates (#42383) 2026-08-13 18:26:44 +00:00
Kit Langton 9fff6e2b4f fix(tui): prioritize composer keybinds (#42384) 2026-08-13 18:12:39 +00:00
7 changed files with 88 additions and 56 deletions
+26 -2
View File
@@ -2,6 +2,7 @@ export * as InstructionDiscovery from "./instruction-discovery.js"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { createPatch } from "diff"
import { Bus } from "./bus.js"
import { Instructions } from "./instructions/index.js"
import { AbsolutePath } from "./schema.js"
@@ -81,8 +82,7 @@ export const layer = (options?: Options) =>
read: Effect.succeed(value),
render: {
initial: render,
changed: (_previous, current) =>
`These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
changed: renderUpdate,
removed: () => "Previously loaded instructions no longer apply.",
},
})
@@ -120,3 +120,27 @@ export const node = configured()
function render(files: ReadonlyArray<File>) {
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
}
function renderUpdate(previous: ReadonlyArray<File>, current: ReadonlyArray<File>) {
const changes = Instructions.diffByKey(
previous,
current,
(file) => file.path,
(before, after) => before.content !== after.content,
)
return [
...changes.removed.map((file) => `The instructions from ${file.path} no longer apply.`),
...changes.added.map((file) => `New instructions apply from:\n${render([file])}`),
...changes.changed.map(({ previous: before, current: after }) => {
const patch = createPatch(after.path, before.content, after.content, "", "", { context: 3 })
const diff = [
`The instructions from ${after.path} changed. Here's the diff:`,
"```diff",
patch.slice(patch.indexOf("@@")).trimEnd(),
"```",
].join("\n")
const replacement = `The instructions changed:\n${render([after])}`
return diff.length < replacement.length ? diff : replacement
}),
].join("\n\n")
}
@@ -111,6 +111,50 @@ describe("InstructionDiscovery", () => {
).toBe(false)
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
)
it.effect("renders granular instruction updates", () =>
Effect.gen(function* () {
const discovery = yield* InstructionDiscovery.Service
yield* discovery.transform((draft) => {
draft.add(file("/global/AGENTS.md", "global"))
draft.add(
file("/repo/AGENTS.md", ["old", ...Array.from({ length: 20 }, (_, index) => `keep ${index}`)].join("\n")),
)
})
const initial = yield* readInitial(yield* discovery.load())
yield* discovery.transform((draft) => {
draft.update("/repo/AGENTS.md", (current) => {
current.content = ["new", ...Array.from({ length: 20 }, (_, index) => `keep ${index}`)].join("\n")
})
})
const modified = (yield* readUpdate(yield* discovery.load(), initial)).text
expect(modified).toContain("The instructions from /repo/AGENTS.md changed. Here's the diff:")
expect(modified).toContain("-old\n+new")
expect(modified).not.toContain("global")
const rewritten = state({
"core/instructions": [{ path: "/repo/AGENTS.md", content: "old one\nold two\nold three\nold four" }],
})
yield* discovery.transform((draft) => {
draft.remove("/global/AGENTS.md")
draft.update("/repo/AGENTS.md", (current) => {
current.content = "new"
})
})
expect((yield* readUpdate(yield* discovery.load(), rewritten)).text).toBe(
"The instructions changed:\nInstructions from: /repo/AGENTS.md\nnew",
)
yield* discovery.transform((draft) => {
draft.add(file("/repo/packages/AGENTS.md", "package"))
})
const structural = (yield* readUpdate(yield* discovery.load(), initial)).text
expect(structural).toContain("The instructions from /global/AGENTS.md no longer apply.")
expect(structural).toContain("New instructions apply from:\nInstructions from: /repo/packages/AGENTS.md\npackage")
expect(structural).not.toContain("Instructions from: /global/AGENTS.md\nglobal")
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
)
})
describe("ConfigInstructionPlugin.Plugin", () => {
@@ -168,20 +212,15 @@ describe("ConfigInstructionPlugin.Plugin", () => {
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
yield* emitAndWait({ type: "update", path: packageFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toContain(
`Instructions from: ${packageFile}\nchanged`,
)
const changed = (yield* readUpdate(yield* discovery.load(), initialized)).text
expect(changed).toContain(`The instructions changed:\nInstructions from: ${packageFile}\nchanged`)
expect(changed).not.toContain(`Instructions from: ${globalFile}\nglobal`)
yield* Effect.promise(() => fs.rm(packageFile))
yield* emitAndWait({ type: "delete", path: packageFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
[
"These instructions replace all previously loaded ambient instructions.",
`Instructions from: ${globalFile}\nglobal`,
`Instructions from: ${projectFile}\nproject`,
`Instructions from: ${sharedFile}\nshared`,
].join("\n\n"),
)
const removed = (yield* readUpdate(yield* discovery.load(), initialized)).text
expect(removed).toContain(`The instructions from ${packageFile} no longer apply.`)
expect(removed).not.toContain(`Instructions from: ${globalFile}\nglobal`)
yield* Effect.promise(() => fs.rm(globalFile))
yield* emitAndWait({ type: "delete", path: globalFile })
@@ -94,6 +94,7 @@ export function Composer(props: ComposerProps) {
Keymap.createLayer(() => ({
mode: "composer",
enabled: () => props.open,
priority: 1,
commands: [
{ bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
{ bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
@@ -23,19 +23,6 @@ export function ShellTab(props: { sessionID: string }) {
const selectedEntry = createMemo(() => entries()[store.selected])
const keymap = Keymap.use()
createEffect(() => {
if (!composer.active("shell")) return
const cleanup = keymap.intercept("key", ({ event, consume }) => {
if (event.name !== "d" || !event.ctrl) return
if (!shortcuts.list("composer.shell.kill").includes("ctrl+d")) return
if (!selectedEntry()) return
consume()
keymap.dispatch("composer.shell.kill")
})
onCleanup(cleanup)
})
createEffect(() => {
if (store.selected >= entries().length) setStore("selected", Math.max(0, entries().length - 1))
})
@@ -63,6 +50,7 @@ export function ShellTab(props: { sessionID: string }) {
Keymap.createLayer(() => ({
mode: "composer",
enabled: () => composer.active("shell"),
priority: 1,
commands: [
{
id: "composer.shell.up",
@@ -164,6 +164,7 @@ export function SubagentsTab(props: { sessionID: string }) {
Keymap.createLayer(() => ({
mode: "composer",
enabled: () => composer.active("subagents"),
priority: 1,
commands: [
{
id: "composer.subagent.up",
+3 -26
View File
@@ -2799,10 +2799,6 @@ function Shell(props: ToolProps) {
const isRunning = createMemo(() => props.part.state.status === "running" || backgroundRunning())
const command = createMemo(() => stringValue(props.input.command))
const workdir = createMemo(() => pathFormatter.format(stringValue(props.input.workdir)))
const failedExit = createMemo(() => {
const exit = finiteNumber(props.metadata.exit)
return exit !== undefined && exit !== 0 ? exit : undefined
})
const [expanded, setExpanded] = createSignal(false)
const [backgroundOutput, setBackgroundOutput] = createSignal("")
const [outputTruncated, setOutputTruncated] = createSignal(false)
@@ -2876,16 +2872,8 @@ function Shell(props: ToolProps) {
})
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const prompt = createMemo(() => (workdir() && workdir() !== "." ? `${workdir()}$` : "$"))
const input = createMemo(() => {
const cmd = command()
if (!cmd) return ""
// While running, the workdir prompt shares the spinner's text column; when
// settled, the prompt renders as its own column so wrapped command lines
// keep a stable hanging indent instead of jumping to the card inset.
if (isRunning() && prompt() !== "$") return `${prompt()} ${cmd}`
return cmd
})
const prefix = createMemo(() => (workdir() && workdir() !== "." ? `cd ${workdir()} && ` : ""))
const input = createMemo(() => (command() ? `${isRunning() ? "" : "$ "}${prefix()}${command()}` : ""))
const content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n"))
const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars()))
const limited = createMemo(() => {
@@ -2914,24 +2902,13 @@ function Shell(props: ToolProps) {
)
}
>
<Show
when={isRunning()}
fallback={
<box flexDirection="row" gap={1}>
<text fg={theme.text.default}>{prompt()}</text>
<text fg={theme.text.default}>{limitedInput()}</text>
</box>
}
>
<Show when={isRunning()} fallback={<text fg={theme.text.default}>{limitedInput()}</text>}>
<Spinner color={color()}>{limitedInput()}</Spinner>
</Show>
<Show when={limitedOutput()}>
<text fg={theme.text.subdued}>{limitedOutput()}</text>
</Show>
</Show>
<Show when={failedExit()}>
{(exit) => <text fg={theme.text.feedback.error.default}>× exited with code {exit()}</text>}
</Show>
<Show when={background()}>
<StatusBadge>Background</StatusBadge>
</Show>
@@ -95,7 +95,6 @@ async function renderComposer(
<TestTuiContexts directory={directory}>
<ConfigProvider config={createTuiResolvedConfig({ keybinds })}>
<Keymap.Provider>
<AppExit />
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<LocationProvider>
@@ -107,6 +106,7 @@ async function renderComposer(
</LocationProvider>
</DataProvider>
</ClientProvider>
<AppExit />
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
@@ -173,11 +173,13 @@ test("disabled shell bindings have no component fallbacks", async () => {
}
})
test("shell kill binding overrides app exit", async () => {
const composer = await renderComposer("shell", {}, true)
test("configured composer bindings work with a focused textarea", async () => {
const composer = await renderComposer("subagents", { "composer.shell.kill": "ctrl+u" }, true)
try {
composer.app.mockInput.pressArrow("right")
await composer.app.renderOnce()
expect(composer.app.captureCharFrame()).toContain("bun test")
composer.app.mockInput.pressKey("d", { ctrl: true })
composer.app.mockInput.pressKey("u", { ctrl: true })
await wait(() => composer.removed.length === 1)
expect(composer.removed).toEqual(["sh-a"])
} finally {