Compare commits

..

8 Commits

Author SHA1 Message Date
Aiden Cline ea74a84ea3 fix(tui): scope prompt drafts to sessions 2026-08-09 18:00:00 +00:00
Kit Langton 84fd347afa fix(codegen): write prettier-stable generated manifests (#41343) 2026-08-08 20:52:04 -04:00
opencode-agent[bot] e8f215bfbc chore: generate 2026-08-09 00:29:22 +00:00
opencode-agent[bot] 445af9ce70 docs: fix install command rendering (#41340)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-08-08 20:28:07 -04:00
Kit Langton bc51baa9a4 fix(tui): align session tab shortcut labels (#41308) 2026-08-08 20:19:51 -04:00
Kit Langton ff0a0b0786 fix(tui): isolate lifecycle and theme tests (#41310) 2026-08-08 20:18:24 -04:00
Kit Langton 4eff0ee2db fix(core): flush plugin reload generations (#41309) 2026-08-08 20:18:21 -04:00
Kit Langton cc0061f88b fix(core): update recorded prompt cache key (#41307) 2026-08-08 20:18:17 -04:00
15 changed files with 170 additions and 44 deletions
+10 -4
View File
@@ -233,7 +233,7 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const watcher = yield* Watcher.Service
const fs = yield* FSUtil.Service
const ready = yield* Deferred.make<void>()
const ready = { current: yield* Deferred.make<void>() }
let observed = 0
// Configured local plugin files can live outside config roots, where the
@@ -291,7 +291,13 @@ const layer = Layer.effect(
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
).pipe(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() => Effect.sync(() => ++observed)),
Stream.mapEffect(() =>
Effect.gen(function* () {
observed++
if (yield* Deferred.isDone(ready.current)) ready.current = yield* Deferred.make<void>()
return observed
}),
),
)
yield* Stream.concat(Stream.succeed(0), updates).pipe(
// Keep observing updates while activation runs, retaining only the latest generation request.
@@ -300,12 +306,12 @@ const layer = Layer.effect(
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* activate()
if (observed === target) yield* Deferred.succeed(ready, undefined)
if (observed === target) yield* Deferred.succeed(ready.current, undefined)
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: Deferred.await(ready) })
return Service.of({ flush: Effect.suspend(() => Deferred.await(ready.current)) })
}),
)
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -305,11 +305,13 @@ describe("LocationServiceMap", () => {
)
yield* Deferred.await(started)
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
Effect.provide(context),
Effect.timeout("1 second"),
Effect.forkChild({ startImmediately: true }),
)
expect(flushFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(flushFiber)
yield* Deferred.await(completed)
}),
),
+9 -1
View File
@@ -1316,7 +1316,15 @@ export function write(
}).pipe(Effect.flatMap((content) => fs.writeFileString(join(directory, file.path), content))),
{ concurrency: 8, discard: true },
)
yield* fs.writeFileString(manifest, JSON.stringify(output.files.map((file) => file.path).sort(), null, 2) + "\n")
// Format the manifest with the same prettier settings as the repo-wide
// format pass, so `check:generated` stays clean after the generate bot
// reformats the tree.
const manifestJson = JSON.stringify(output.files.map((file) => file.path).sort())
const manifestContent = yield* Effect.tryPromise({
try: () => format(manifestJson, { filepath: manifest, parser: "json", printWidth: 120 }),
catch: (error) => new GenerationError({ reason: `Failed to format ${manifest}: ${String(error)}` }),
})
yield* fs.writeFileString(manifest, manifestContent)
})
}
+1 -1
View File
@@ -16,7 +16,7 @@ describe("HttpApiCodegen.write", () => {
expect(writes).toEqual([
{ path: "/generated/session.ts", content: "export const session = {}\n" },
{ path: "/generated/.httpapi-codegen.json", content: '[\n "session.ts"\n]\n' },
{ path: "/generated/.httpapi-codegen.json", content: '["session.ts"]\n' },
])
}).pipe(
Effect.provideService(
+2 -1
View File
@@ -118,6 +118,7 @@ const sessionTabBindingCommands = [
"session.tab.select.7",
"session.tab.select.8",
"session.tab.select.9",
"session.tab.select.10",
] as const
const pinnedSessionBindingCommands = [
@@ -714,7 +715,7 @@ function App(props: { pair?: DialogPairCredentials }) {
enabled: sessionTabs.enabled,
run: () => sessionTabs.reopen(),
},
...Array.from({ length: 9 }, (_, i) => ({
...Array.from({ length: 10 }, (_, i) => ({
name: `session.tab.select.${i + 1}`,
title: `Switch to tab ${i + 1}`,
category: "Session",
+32 -14
View File
@@ -130,7 +130,7 @@ function formatEditorContext(selection: EditorSelection) {
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
}
let stashed: { prompt: PromptInfo; cursor: number } | undefined
const drafts = new Map<string | undefined, { prompt: PromptInfo; cursor: number }>()
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
const head = parseSlashHead(input, /\s/)
@@ -600,22 +600,40 @@ export function Prompt(props: PromptProps) {
},
}
onMount(() => {
const saved = stashed
stashed = undefined
if (store.prompt.text) return
if (saved && saved.prompt.text) {
input.setText(saved.prompt.text)
setStore("prompt", saved.prompt)
restoreExtmarksFromPrompt(saved.prompt)
input.cursorOffset = saved.cursor
function saveDraft(sessionID: string | undefined) {
if (!store.prompt.text) {
drafts.delete(sessionID)
return
}
})
drafts.set(sessionID, { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
}
function restoreDraft(sessionID: string | undefined) {
const saved = drafts.get(sessionID)
drafts.delete(sessionID)
ref.reset()
if (!saved?.prompt.text) return
ref.set(saved.prompt)
input.cursorOffset = saved.cursor
}
let draftSessionID = props.sessionID
onMount(() => restoreDraft(draftSessionID))
createEffect(
on(
() => props.sessionID,
(sessionID) => {
saveDraft(draftSessionID)
draftSessionID = sessionID
restoreDraft(sessionID)
},
{ defer: true },
),
)
onCleanup(() => {
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
saveDraft(draftSessionID)
setInputTarget(undefined)
props.ref?.(undefined)
})
+6 -5
View File
@@ -10,6 +10,7 @@ import {
moveSessionTab,
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
sessionTabShortcutLabel,
seedSessionTabMotion,
sessionTabOverflowWidth,
type SessionTab,
@@ -140,7 +141,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const value = session()
return value ? data.project.get(value.projectID) : undefined
})
const numberWidth = () => String(index() + 1).length + 1
const numberWidth = () => 2
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
const title = () => tab.title ?? "Untitled session"
const visibleTitle = createMemo(() => Locale.takeWidth(title(), titleWidth()))
@@ -311,7 +312,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
>
{index() + 1}
{sessionTabShortcutLabel(index())}
</text>
<text
width={titleWidth()}
@@ -555,8 +556,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
const title = () => tab.title ?? "Untitled session"
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
// The number cell keeps one trailing space, even for double-digit tabs.
const numberWidth = () => String(tabNumber()).length + 1
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
const numberWidth = () => 2
// Hovering reveals the close mark, so the title's right bound shifts left of it.
const availableTitleWidth = () =>
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
@@ -639,7 +640,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
{" "}
</text>
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
{tabNumber()}
{sessionTabShortcutLabel(tabNumber() - 1)}
</text>
<text
width={availableTitleWidth()}
+2
View File
@@ -126,6 +126,7 @@ export const Definitions = {
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to tab 7"),
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to tab 8"),
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to tab 9"),
session_tab_select_10: keybind("<leader>0,ctrl+0", "Switch to tab 10"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
@@ -329,6 +330,7 @@ export const CommandMap = {
session_tab_select_7: "session.tab.select.7",
session_tab_select_8: "session.tab.select.8",
session_tab_select_9: "session.tab.select.9",
session_tab_select_10: "session.tab.select.10",
stash_delete: "stash.delete",
model_provider_list: "model.dialog.provider",
model_favorite_toggle: "model.dialog.favorite",
@@ -7,6 +7,12 @@ export type SessionTabUnread = "activity" | "error"
export const NEW_SESSION_TAB_TITLE = "New session"
export function sessionTabShortcutLabel(index: number) {
if (index >= 0 && index < 9) return String(index + 1)
if (index === 9) return "0"
return "·"
}
export type SessionTabHistory = {
entries: readonly string[]
index: number
+74 -13
View File
@@ -1,4 +1,4 @@
import { expect, mock, test } from "bun:test"
import { expect, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -7,8 +7,6 @@ import { createEventStream, createFetch, directory, json } from "./fixture/tui-c
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const titles: string[] = []
let started!: () => void
const ready = new Promise<void>((resolve) => {
@@ -32,6 +30,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
@@ -46,14 +45,11 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore()
}
})
test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
let initialTitle!: () => void
const initialTitleSet = new Promise<void>((resolve) => {
initialTitle = resolve
@@ -110,6 +106,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
@@ -134,14 +131,11 @@ test("session lifecycle updates the terminal title and prints the epilogue after
process.stdout.write = originalWrite
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore()
}
})
test("session title generated while an untitled session is loading remains visible", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const titles: string[] = []
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
const generatedTitle = Promise.withResolvers<void>()
@@ -186,6 +180,7 @@ test("session title generated while an untitled session is loading remains visib
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
@@ -222,14 +217,11 @@ test("session title generated while an untitled session is loading remains visib
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore()
}
})
test("session startup prompt is submitted exactly once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventStream()
const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd } }
@@ -279,6 +271,7 @@ test("session startup prompt is submitted exactly once", async () => {
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy", prompt: "RESUME_READY" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
@@ -299,6 +292,74 @@ test("session startup prompt is submitted exactly once", async () => {
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore()
}
})
test("new session does not inherit the current session prompt draft", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const events = createEventStream()
const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd } }
const session = {
id: "dummy",
title: "Demo session",
projectID: "project",
location: { directory: cwd },
agent: "build",
model: { providerID: "provider", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const calls = createFetch((url) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }] })
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await Promise.race([
(async () => {
while (!setup.renderer.currentFocusedEditor) await Bun.sleep(10)
})(),
Bun.sleep(2_000).then(() => {
throw new Error("session prompt did not focus")
}),
])
await setup.mockInput.typeText("keep this draft")
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("keep this draft")
setup.mockInput.pressKey("x", { ctrl: true })
await Bun.sleep(10)
setup.mockInput.pressKey("n")
await Bun.sleep(20)
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("")
setup.renderer.destroy()
await task
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
@@ -86,6 +86,7 @@ test.each([
let themes: ReturnType<typeof useThemes> | undefined
let failure: ThemeError | undefined
let unsubscribe: (() => void) | undefined
const discovery = Promise.withResolvers<Record<string, unknown>>()
function Probe() {
const value = useThemes()
@@ -97,7 +98,7 @@ test.each([
const app = await testRender(
() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "invalid" } })}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({ invalid: source }) }}>
<ThemeProvider mode="dark" source={{ discover: () => discovery.promise }}>
<Probe />
</ThemeProvider>
</ConfigProvider>
@@ -105,6 +106,7 @@ test.each([
{ width: 20, height: 2 },
)
app.renderer.start()
discovery.resolve({ invalid: source })
try {
await wait(() => themes?.ready === true)
+1
View File
@@ -131,6 +131,7 @@ test("preserves pinned session bindings alongside tab bindings", () => {
expect(config.keybinds.get("session.pin.toggle")).toMatchObject([{ key: "ctrl+f" }])
expect(config.keybinds.get("session.quick_switch.1")).toMatchObject([{ key: "<leader>1" }])
expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "<leader>1,ctrl+1" }])
expect(config.keybinds.get("session.tab.select.10")).toMatchObject([{ key: "<leader>0,ctrl+0" }])
})
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
@@ -12,9 +12,27 @@ import {
seedSessionTabMotion,
sessionTabComplete,
sessionTabOverflowWidth,
sessionTabShortcutLabel,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"0",
"·",
"·",
])
})
test("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])
+1 -1
View File
@@ -22,7 +22,7 @@ You can also install it with the following package managers.
<Tabs>
<Tab title="npm">```bash npm install -g @opencode-ai/cli@next ```</Tab>
<Tab title="bun">```bash bun install -g --trust @opencode-ai/cli@next ```</Tab>
<Tab title="pnpm">```bash pnpm --allow-build=@opencode-ai/cli add -g @opencode-ai/cli@next ```</Tab>
<Tab title="pnpm">```bash pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@next ```</Tab>
<Tab title="Yarn">```bash yarn global add @opencode-ai/cli@next ```</Tab>
</Tabs>