Compare commits

...

4 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
Kit Langton 2cf20e660e fix(tui): restore composer shell kill shortcut (#42366) 2026-08-13 17:40:47 +00:00
7 changed files with 117 additions and 34 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) },
@@ -50,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 -19
View File
@@ -2872,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(() => {
@@ -2910,15 +2902,7 @@ 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()}>
@@ -23,7 +23,11 @@ const sessions = {
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")]
async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Partial<TuiKeybind.Keybinds>) {
async function renderComposer(
defaultTab: "subagents" | "shell",
keybinds: Partial<TuiKeybind.Keybinds>,
focusedTextarea = false,
) {
const events = createEventStream()
const interrupted: string[] = []
const removed: string[] = []
@@ -69,7 +73,21 @@ async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Parti
.then(() => wait(() => data.session.status("child-a") === "running"))
.then(() => ready.resolve(), ready.reject)
})
return <Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
return (
<>
{focusedTextarea && <textarea focused={true} initialValue="draft" />}
<Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
</>
)
}
function AppExit() {
Keymap.createLayer(() => ({
mode: "global",
commands: [{ id: "app.exit", title: "Exit", group: "System", run: () => {} }],
}))
Keymap.createLayer(() => ({ bindings: ["app.exit"] }))
return null
}
const app = await testRender(
@@ -88,6 +106,7 @@ async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Parti
</LocationProvider>
</DataProvider>
</ClientProvider>
<AppExit />
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
@@ -154,6 +173,20 @@ test("disabled shell bindings have no component fallbacks", async () => {
}
})
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("u", { ctrl: true })
await wait(() => composer.removed.length === 1)
expect(composer.removed).toEqual(["sh-a"])
} finally {
composer.app.renderer.destroy()
}
})
function session(id: string, title: string, parentID?: string) {
return {
id,