Compare commits

..
39 changed files with 1049 additions and 1641 deletions
@@ -0,0 +1,133 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { SESSION_OPEN_FILE_TAB } from "./helpers"
import { createSessionSidePanelController, sessionSidePanelHandoffFiles } from "./session-side-panel-controller"
function createController(options?: { active?: string; all?: string[]; mode?: "changes" | "all" }) {
const calls: string[] = []
const [state, setState] = createStore({
active: options?.active,
all: options?.all ?? ["file://src/a.ts"],
preview: undefined as string | undefined,
mode: options?.mode ?? ("changes" as "changes" | "all"),
})
return createRoot((dispose) => ({
dispose,
calls,
state,
controller: createSessionSidePanelController({
currentTab: () => state.active,
allTabs: () => state.all,
openTab: (tab) => calls.push(`open:${tab}`),
preview: (tab) => calls.push(`preview:${tab}`),
setActive: (tab) => calls.push(`active:${tab}`),
normalizeFileTab: (tab) => `file://${tab.slice("file://".length).toLowerCase()}`,
pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
loadFile: (path) => calls.push(`load:${path}`),
reviewEnabled: () => true,
canReview: () => true,
fileBrowserEnabled: () => true,
reviewPanelOpened: () => false,
openReviewPanel: () => calls.push("panel"),
treeMode: () => state.mode,
setTreeMode: (mode) => setState("mode", mode),
fileReady: () => false,
sessionKey: () => "session",
selectedLines: () => null,
persistHandoff: () => undefined,
showDialog: () => undefined,
}),
}))
}
describe("session side panel controller", () => {
test("normalizes and centralizes file tab selection mutations", async () => {
const owned = createController()
owned.controller.tabs.activate("file://SRC/A.ts")
expect(owned.calls).toEqual(["load:src/a.ts", "panel", "active:file://src/a.ts"])
owned.calls.length = 0
owned.controller.tabs.preview("file://SRC/B.ts")
expect(owned.calls).toEqual(["preview:file://src/b.ts", "load:src/b.ts", "panel"])
await Promise.resolve()
expect(owned.calls).toEqual(["preview:file://src/b.ts", "load:src/b.ts", "panel", "active:file://src/b.ts"])
owned.calls.length = 0
owned.controller.tabs.open("file://SRC/C.ts")
expect(owned.calls).toEqual(["open:file://src/c.ts", "load:src/c.ts", "panel", "active:file://src/c.ts"])
owned.dispose()
})
test("derives browser selection and controls the tree mode", () => {
const owned = createController({ active: "file://src/a.ts", all: ["file://src/a.ts"] })
expect(owned.controller.browser.tab()).toBe("file://src/a.ts")
expect(owned.controller.browser.mounted()).toBe(true)
expect(owned.controller.browser.visible()).toBe(true)
owned.controller.tree.setMode("invalid")
expect(owned.state.mode).toBe("changes")
owned.controller.tree.showAll()
expect(owned.state.mode).toBe("all")
owned.controller.tree.showAll()
expect(owned.state.mode).toBe("all")
owned.calls.length = 0
owned.controller.browser.open()
expect(owned.calls[0]).toBe(`preview:${SESSION_OPEN_FILE_TAB}`)
owned.dispose()
})
test("opens the file dialog with the tree handoff callback", async () => {
let render: (() => unknown) | undefined
let dialogProps: { mode?: "files"; onOpenFile?: (path: string) => void } | undefined
const owned = createController()
const controller = createSessionSidePanelController({
currentTab: () => undefined,
allTabs: () => [],
openTab: () => undefined,
preview: () => undefined,
setActive: () => undefined,
normalizeFileTab: (tab) => tab,
pathFromTab: () => undefined,
loadFile: () => undefined,
reviewEnabled: () => true,
canReview: () => true,
fileBrowserEnabled: () => true,
reviewPanelOpened: () => true,
openReviewPanel: () => undefined,
treeMode: owned.controller.tree.mode,
setTreeMode: owned.controller.tree.setMode,
fileReady: () => false,
sessionKey: () => "session",
selectedLines: () => null,
persistHandoff: () => undefined,
showDialog: (value) => (render = value),
loadSelectFileDialog: async () => ({
DialogSelectFile: (props) => {
dialogProps = props
return null
},
}),
})
await controller.dialog.openFile()
render?.()
expect(dialogProps?.mode).toBe("files")
dialogProps?.onOpenFile?.("src/a.ts")
expect(owned.state.mode).toBe("all")
owned.dispose()
})
})
test("projects only file tabs into handoff persistence", () => {
expect(
sessionSidePanelHandoffFiles(
["review", "file://src/a.ts", "file://src/b.ts"],
(tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
(path) => (path.endsWith("a.ts") ? { start: 2, end: 4 } : { startLine: 2, endLine: 4 }),
),
).toEqual({ "src/a.ts": { start: 2, end: 4 }, "src/b.ts": null })
})
@@ -0,0 +1,150 @@
import { createComponent, createEffect, createMemo, type Accessor, type Component, type JSX } from "solid-js"
import type { SelectedLineRange } from "@/context/file"
import { SESSION_OPEN_FILE_TAB, createOpenSessionFileTab, createSessionTabs } from "@/pages/session/helpers"
type TreeMode = "changes" | "all"
type Input = {
currentTab: Accessor<string | undefined>
allTabs: Accessor<string[]>
openTab: (tab: string) => void
preview: (tab: string) => void
setActive: (tab: string) => void
normalizeFileTab: (tab: string) => string
pathFromTab: (tab: string) => string | undefined
loadFile: (path: string) => void
reviewEnabled: Accessor<boolean>
canReview: Accessor<boolean>
fileBrowserEnabled: Accessor<boolean>
reviewPanelOpened: Accessor<boolean>
openReviewPanel: () => void
treeMode: Accessor<TreeMode>
setTreeMode: (mode: TreeMode) => void
fileReady: Accessor<boolean>
sessionKey: Accessor<string>
selectedLines: (path: string) => unknown
persistHandoff: (key: string, files: Record<string, SelectedLineRange | null>) => void
showDialog: (render: () => JSX.Element) => void
loadSelectFileDialog?: () => Promise<{
DialogSelectFile: Component<{ mode?: "files"; onOpenFile?: (path: string) => void }>
}>
}
export function createSessionSidePanelController(input: Input) {
const normalizeTab = (tab: string) => (tab.startsWith("file://") ? input.normalizeFileTab(tab) : tab)
const openReviewPanel = () => {
if (!input.reviewPanelOpened()) input.openReviewPanel()
}
const tabs = createSessionTabs({
tabs: () => ({ active: input.currentTab, all: input.allTabs }),
pathFromTab: input.pathFromTab,
normalizeTab,
review: input.reviewEnabled,
hasReview: input.canReview,
fileBrowser: input.fileBrowserEnabled,
})
const prepareTab = (tab: string) => {
const path = input.pathFromTab(tab)
if (path) input.loadFile(path)
openReviewPanel()
return tab
}
const open = createOpenSessionFileTab({
normalizeTab,
openTab: input.openTab,
pathFromTab: input.pathFromTab,
loadFile: input.loadFile,
openReviewPanel,
setActive: input.setActive,
})
const preview = (value: string) => {
const next = normalizeTab(value)
input.preview(next)
const selected = prepareTab(next)
queueMicrotask(() => input.setActive(selected))
}
const activate = (value: string) => input.setActive(prepareTab(normalizeTab(value)))
const openFileBrowser = () => preview(SESSION_OPEN_FILE_TAB)
const browserTab = createMemo(() => {
if (!input.fileBrowserEnabled()) return undefined
const active = tabs.activeTab()
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
if (active && input.pathFromTab(active)) return active
return tabs.activeFileTab()
})
// Keep the shell mounted while any file tab exists. Kobalte briefly selects
// Review while replacing a preview trigger, which must not reset sidebar scroll.
const fileBrowserMounted = createMemo(
() =>
input.fileBrowserEnabled() && (tabs.openedTabs().length > 0 || tabs.openFileOpen() || browserTab() !== undefined),
)
const fileBrowserVisible = createMemo(() => {
const active = tabs.activeTab()
return active !== "review" && active !== "context" && active !== "empty"
})
const setTreeMode = (value: string) => {
if (value !== "changes" && value !== "all") return
input.setTreeMode(value)
}
const showAllFiles = () => {
if (input.treeMode() !== "changes") return
input.setTreeMode("all")
}
const openFileDialog = async () => {
const load = input.loadSelectFileDialog ?? (() => import("@/components/dialog-select-file"))
const { DialogSelectFile } = await load()
input.showDialog(() => createComponent(DialogSelectFile, { mode: "files", onOpenFile: showAllFiles }))
}
createEffect(() => {
if (!input.fileReady()) return
input.persistHandoff(
input.sessionKey(),
sessionSidePanelHandoffFiles(input.allTabs(), input.pathFromTab, input.selectedLines),
)
})
return {
tabs: {
...tabs,
normalize: normalizeTab,
open,
preview,
activate,
},
browser: {
tab: browserTab,
mounted: fileBrowserMounted,
visible: fileBrowserVisible,
open: openFileBrowser,
},
tree: {
mode: input.treeMode,
setMode: setTreeMode,
showAll: showAllFiles,
},
dialog: {
openFile: openFileDialog,
},
}
}
export function sessionSidePanelHandoffFiles(
tabs: readonly string[],
pathFromTab: (tab: string) => string | undefined,
selectedLines: (path: string) => unknown,
) {
return tabs.reduce<Record<string, SelectedLineRange | null>>((files, tab) => {
const path = pathFromTab(tab)
if (!path) return files
const selected = selectedLines(path)
files[path] = isSelectedLineRange(selected) ? selected : null
return files
}, {})
}
function isSelectedLineRange(value: unknown): value is SelectedLineRange {
return !!value && typeof value === "object" && "start" in value && "end" in value
}
export type SessionSidePanelController = ReturnType<typeof createSessionSidePanelController>
@@ -1,4 +1,4 @@
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { For, Match, Show, Switch, createMemo, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid"
@@ -38,23 +38,17 @@ const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session"
import { OpenInAppV2 } from "@/components/session/open-in-app-v2"
import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import {
SESSION_OPEN_FILE_TAB,
createOpenSessionFileTab,
createSessionTabs,
getTabReorderIndex,
shouldShowFileTree,
type Sizing,
} from "@/pages/session/helpers"
import { SESSION_OPEN_FILE_TAB, getTabReorderIndex, shouldShowFileTree, type Sizing } from "@/pages/session/helpers"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionSidePanelController } from "@/pages/session/session-side-panel-controller"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
@@ -153,91 +147,49 @@ export function SessionSidePanel(props: {
return file.tree.children("").length === 0
})
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
const openReviewPanel = () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
}
const openTab = createOpenSessionFileTab({
normalizeTab,
openTab: tabs().open,
const controller = createSessionSidePanelController({
currentTab: () => tabs().active(),
allTabs: () => tabs().all(),
openTab: (tab) => tabs().open(tab),
preview: (tab) => tabs().previewTab(tab),
setActive: (tab) => tabs().setActive(tab),
normalizeFileTab: file.tab,
pathFromTab: file.pathFromTab,
loadFile: file.load,
openReviewPanel,
setActive: tabs().setActive,
reviewEnabled: reviewTab,
canReview: props.canReview,
fileBrowserEnabled: () => !!props.fileBrowserState,
reviewPanelOpened: () => view().reviewPanel.opened(),
openReviewPanel: () => view().reviewPanel.open(),
treeMode: () => layout.fileTree.tab(),
setTreeMode: (mode) => layout.fileTree.setTab(mode),
fileReady: file.ready,
sessionKey,
selectedLines: file.selectedLines,
persistHandoff: (key, files) => setSessionHandoff(key, { files }),
showDialog: (render) => void dialog.show(render),
})
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview: props.canReview,
fileBrowser: () => !!props.fileBrowserState,
})
const contextOpen = tabState.contextOpen
const openFileOpen = tabState.openFileOpen
const panelTabs = tabState.panelTabs
const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTabValue = (value: string) => {
if (value !== "changes" && value !== "all") return
layout.fileTree.setTab(value)
}
const showAllFiles = () => {
if (fileTreeTab() !== "changes") return
layout.fileTree.setTab("all")
}
const contextOpen = controller.tabs.contextOpen
const panelTabs = controller.tabs.panelTabs
const openedTabs = controller.tabs.openedTabs
const activeTab = controller.tabs.activeTab
const activeFileTab = controller.tabs.activeFileTab
const openTab = controller.tabs.open
const previewTab = controller.tabs.preview
const activateTab = controller.tabs.activate
const browserTab = controller.browser.tab
const fileBrowserMounted = controller.browser.mounted
const fileBrowserVisible = controller.browser.visible
const fileTreeTab = controller.tree.mode
const setFileTreeTabValue = controller.tree.setMode
let fileFilter: HTMLInputElement | undefined
let tabList: HTMLDivElement | undefined
const temporaryTab = tabs().preview
const previewTab = (value: string) => {
const next = normalizeTab(value)
tabs().previewTab(next)
const path = file.pathFromTab(next)
if (path) void file.load(path)
openReviewPanel()
queueMicrotask(() => tabs().setActive(next))
}
const openFileBrowser = () => {
previewTab(SESSION_OPEN_FILE_TAB)
controller.browser.open()
queueMicrotask(() => fileFilter?.focus())
}
const activateTab = (value: string) => {
const next = normalizeTab(value)
const path = file.pathFromTab(next)
if (path) void file.load(path)
openReviewPanel()
tabs().setActive(next)
}
const browserTab = createMemo(() => {
if (!props.fileBrowserState) return undefined
const active = activeTab()
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
if (active && file.pathFromTab(active)) return active
return activeFileTab()
})
// Keep the file-browser shell mounted while any file tab exists. Kobalte briefly
// selects Review while the tab For replaces a preview trigger, which would
// otherwise dispose the sidebar and reset scroll.
const fileBrowserMounted = createMemo(() => {
if (!props.fileBrowserState) return false
return openedTabs().length > 0 || openFileOpen() || !!browserTab()
})
const fileBrowserVisible = createMemo(() => {
const active = activeTab()
return active !== "review" && active !== "context" && active !== "empty"
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
const [store, setStore] = createStore({
@@ -264,27 +216,6 @@ export function SessionSidePanel(props: {
setStore("activeDraggable", undefined)
}
createEffect(() => {
if (!file.ready()) return
setSessionHandoff(sessionKey(), {
files: tabs()
.all()
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
const path = file.pathFromTab(tab)
if (!path) return acc
const selected = file.selectedLines(path)
acc[path] =
selected && typeof selected === "object" && "start" in selected && "end" in selected
? (selected as SelectedLineRange)
: null
return acc
}, {}),
})
})
return (
<Show when={isDesktop() && !(settings.general.newLayoutDesigns() && !params.id)}>
<aside
@@ -451,9 +382,7 @@ export function SessionSidePanel(props: {
iconSize="large"
class="!rounded-md"
onClick={() => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
void controller.dialog.openFile()
}}
aria-label={language.t("command.file.open")}
/>
+13 -2
View File
@@ -5,6 +5,7 @@ import { rm } from "fs/promises"
import path from "path"
import { Script } from "@opencode-ai/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { modelsData } from "./generate"
@@ -22,7 +23,7 @@ await rm(outdir, { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const plugin = createSolidTransformPlugin()
const solidPlugin = createSolidTransformPlugin()
const allTargets: {
os: string
@@ -55,6 +56,16 @@ const targets = singleFlag
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
for (const item of targets) {
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
const parcelWatcherPlugin: BunPlugin = {
name: "parcel-watcher-binding",
setup(build) {
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
contents: `import binding from ${JSON.stringify(parcelWatcherPackage)}; export default () => binding`,
loader: "js",
}))
},
}
const target = [
binary,
item.os === "win32" ? "windows" : item.os,
@@ -69,7 +80,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [plugin],
plugins: [solidPlugin, parcelWatcherPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
+36 -1
View File
@@ -13,7 +13,7 @@ const directory = path.join(import.meta.dir, "..", "dist", ...(nodeBuild ? ["nod
const binary = path.join(directory, `opencode2${nodeBuild ? "-node" : ""}${process.platform === "win32" ? ".exe" : ""}`)
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-")))
const env = {
...process.env,
HOME: root,
@@ -29,6 +29,7 @@ const processes: Array<ReturnType<typeof Bun.spawn>> = []
const errors: Array<Promise<string>> = []
let failure: unknown
try {
await fs.mkdir(path.join(root, ".opencode"))
spawnService()
spawnService()
const registration = await waitForRegistration()
@@ -49,6 +50,11 @@ try {
{ signal: AbortSignal.timeout(5_000) },
)
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
await fs.mkdir(path.dirname(plugin), { recursive: true })
await fs.writeFile(plugin, pluginSource())
await waitForPlugin(info.url, headers)
const unauthorizedHealth = await fetch(new URL("/api/health", info.url), {
signal: AbortSignal.timeout(5_000),
@@ -88,6 +94,7 @@ try {
} finally {
processes.forEach((process) => process.kill())
await Promise.all(processes.map((process) => process.exited))
if (failure) errors.push(fs.readFile(path.join(root, "data", "opencode", "log", "opencode.log"), "utf8").catch(() => ""))
}
const output = await Promise.all(errors)
@@ -133,3 +140,31 @@ async function waitForReady(url: string, headers: HeadersInit) {
function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
}
function pluginSource() {
return 'export default { id: "smoke", setup: async () => {} }\n'
}
async function pluginIDs(url: string, headers: HeadersInit) {
const endpoint = new URL("/api/plugin", url)
endpoint.searchParams.set("location[directory]", root)
const response = await fetch(endpoint, { headers, signal: AbortSignal.timeout(5_000) })
const body: unknown = await response.json()
if (typeof body !== "object" || body === null || !("data" in body) || !Array.isArray(body.data)) {
throw new Error("Compiled service returned an invalid plugin list")
}
return body.data.flatMap((plugin) =>
typeof plugin === "object" && plugin !== null && "id" in plugin && typeof plugin.id === "string"
? [plugin.id]
: [],
)
}
async function waitForPlugin(url: string, headers: HeadersInit) {
const deadline = Date.now() + 10_000
while (Date.now() < deadline) {
if ((await pluginIDs(url, headers)).includes("smoke")) return
await Bun.sleep(25)
}
throw new Error("Compiled service did not discover the created plugin")
}
@@ -0,0 +1,13 @@
import { createRequire } from "node:module"
declare const OPENCODE_LIBC: string | undefined
const require = createRequire(import.meta.url)
export default function load() {
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
return require(
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
)
}
+2 -11
View File
@@ -9,23 +9,14 @@ import { Cause, Context, Effect, Layer, PubSub, RcMap, Schema, Stream } from "ef
import { lazy } from "../util/lazy"
import { watch as watchFileSystem } from "node:fs"
import path from "path"
import { createRequire } from "node:module"
declare const OPENCODE_LIBC: string | undefined
import loadBinding from "./watcher-binding"
const SUBSCRIBE_TIMEOUT_MS = 10_000
const require = createRequire(import.meta.url)
export const Event = { Updated: FileSystem.Event.Changed }
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
const binding = require(
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
return createWrapper(loadBinding()) as typeof import("@parcel/watcher")
} catch {
return
}
+4 -4
View File
@@ -230,11 +230,11 @@ export interface DialogSelectOptions<Value> {
}
export interface Dialog {
/** Shows a dialog and returns a function that closes it. */
show(render: () => JSX.Element, onClose?: () => void): () => void
/** Sets the presentation options for this plugin's active dialog. */
/** Shows a dialog. */
show(render: () => JSX.Element, onClose?: () => void): void
/** Sets the active dialog's presentation options. */
set(options: DialogOptions): void
/** Closes this plugin's active dialog. */
/** Closes the active dialog. */
clear(): void
alert(options: DialogAlertOptions): Promise<void>
confirm(options: DialogConfirmOptions): Promise<boolean | undefined>
-4
View File
@@ -11,7 +11,6 @@
},
"exports": {
".": "./src/index.tsx",
"./builtins": "./src/feature-plugins/builtins.ts",
"./config": "./src/config/index.tsx",
"./config/keybind": "./src/config/keybind.ts",
"./config/v1": "./src/config/v1/index.tsx",
@@ -37,9 +36,6 @@
"./context/keymap": "./src/context/keymap.tsx",
"./prompt/content": "./src/prompt/content.ts",
"./prompt/display": "./src/prompt/display.ts",
"./plugin/runtime": "./src/plugin/runtime.tsx",
"./plugin/slots": "./src/plugin/slots.tsx",
"./plugin/command-shim": "./src/plugin/command-shim.ts",
"./parsers-config": "./src/parsers-config.ts",
"./util/error": "./src/util/error.ts",
"./util/filetype": "./src/util/filetype.ts",
+42 -48
View File
@@ -81,7 +81,6 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime"
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
import { CommandPaletteDialog } from "./component/command-palette"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
@@ -275,8 +274,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
() => Effect.sync(() => process.off("SIGHUP", onSighup)),
)
renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void))
const pluginRuntime = createPluginRuntime()
yield* Effect.tryPromise(async () => {
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
@@ -354,49 +351,47 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
: undefined
}
>
<PluginRuntimeProvider value={pluginRuntime}>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode={mode}>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</PluginRuntimeProvider>
<ClientProvider api={api} service={service}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode={mode}>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
@@ -453,7 +448,6 @@ function App(props: { pair?: DialogPairCredentials }) {
const location = useLocation()
const exit = useExit()
const promptRef = usePromptRef()
const pluginRuntime = usePluginRuntime()
const plugins = usePlugin()
const clipboard = useClipboard()
@@ -1203,7 +1197,7 @@ function App(props: { pair?: DialogPairCredentials }) {
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Show when={sessionTabs.enabled() && sessionTabs.tabs().length > 1 && route.data.type !== "plugin"}>
<Show when={sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"}>
<SessionTabs />
</Show>
<Switch>
+3 -3
View File
@@ -94,9 +94,9 @@ export const settings: Setting[] = [
keywords: ["transcript", "messages"],
},
{
title: "Tabs",
category: "Session",
path: ["session", "tabs"],
title: "Enabled",
category: "Tabs",
path: ["tabs", "enabled"],
default: false,
values: [false, true],
labels: ["off", "on"],
+21 -5
View File
@@ -4,19 +4,33 @@ import { useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
import { useTheme, useThemes } from "../context/theme"
import { adaptiveSessionTabLayout, sessionTabComplete, SESSION_TAB_OVERFLOW_WIDTH } from "../context/session-tabs-model"
import {
adaptiveSessionTabLayout,
sessionTabComplete,
SESSION_TAB_OVERFLOW_WIDTH,
type SessionTabUnread,
} from "../context/session-tabs-model"
import { createAnimatable, spring } from "../ui/animation"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { TabPulse } from "./tab-pulse"
import { tint } from "../theme/color"
export function SessionTabs() {
const tabs = useSessionTabs()
type ContextController = ReturnType<typeof useSessionTabs>
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
unread: SessionTabUnread | undefined
}
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close"> & {
status(sessionID: string): SessionTabsStatus
}
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
const tabs = props.controller ?? useSessionTabs()
const dimensions = useTerminalDimensions()
const theme = useTheme()
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
@@ -48,7 +62,7 @@ export function SessionTabs() {
activities: layout().tabs.map((tab) => Number(statuses().get(tab.sessionID)!.complete)),
}))
const motion = createAnimatable(targets(), {
enabled: () => config.animations ?? true,
enabled: animations,
transition: spring({ visualDuration: 0.1 }),
})
const identity = createMemo(() =>
@@ -167,10 +181,12 @@ export function SessionTabs() {
onMouseUp={() => tabs.select(tab.sessionID)}
>
<TabPulse
enabled={config.animations ?? true}
enabled={animations()}
active={status().busy}
complete={status().complete}
glow={status().unread === "activity" && !status().busy && !selected() && !status().attention}
color={pulseColor()}
glowColor={accent()}
completionColor={accent()}
backgroundColor={pulseBackground()}
/>
+75 -16
View File
@@ -1,12 +1,13 @@
import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core"
import { extend } from "@opentui/solid"
import { tint } from "../theme/color"
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
enabled?: boolean
active?: boolean
complete?: boolean
glow?: boolean
color?: RGBA
glowColor?: RGBA
completionColor?: RGBA
backgroundColor?: RGBA
}
@@ -19,6 +20,9 @@ const RUN_TAIL = 18
const RUN_FADE_OUT = 500
const COMPLETION_DURATION = 900
const COMPLETION_ATTACK = 0.16
const GLOW_TAIL = 12
const GLOW_OPACITY = 0.16
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
const intensityAt = (index: number, front: number, head: number, tail: number) => {
const distance = front - index
return distance < 0 ? smootherstep(clamp(1 + distance / head)) : smootherstep(clamp(1 - distance / tail))
@@ -33,17 +37,44 @@ export const completionPulseOpacity = (progress: number) =>
progress < COMPLETION_ATTACK
? smootherstep(clamp(progress / COMPLETION_ATTACK))
: 1 - smootherstep(clamp((progress - COMPLETION_ATTACK) / (1 - COMPLETION_ATTACK)))
export const unreadGlowIntensity = (index: number, width: number) => {
const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2))
return smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
}
export function blendTabPulseColor(
output: RGBA,
background: RGBA,
glowColor: RGBA,
runningColor: RGBA,
completionColor: RGBA,
glow: number,
running: number,
completion: number,
) {
output.r = background.r + (glowColor.r - background.r) * glow
output.g = background.g + (glowColor.g - background.g) * glow
output.b = background.b + (glowColor.b - background.b) * glow
output.r += (runningColor.r - output.r) * running
output.g += (runningColor.g - output.g) * running
output.b += (runningColor.b - output.b) * running
output.r += (completionColor.r - output.r) * completion
output.g += (completionColor.g - output.g) * completion
output.b += (completionColor.b - output.b) * completion
}
class TabPulseRenderable extends Renderable {
private _enabled: boolean
private _active: boolean
private _complete: boolean
private _glow: boolean
private _color: RGBA
private _glowColor: RGBA
private _completionColor: RGBA
private _backgroundColor: RGBA
private clock = 0
private fadeClock: number | undefined
private completionClock: number | undefined
private completionPending = false
private renderColor = RGBA.fromInts(0, 0, 0)
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
const enabled = options.enabled ?? true
@@ -52,7 +83,9 @@ class TabPulseRenderable extends Renderable {
this._enabled = enabled
this._active = active
this._complete = options.complete ?? false
this._glow = options.glow ?? false
this._color = options.color ?? RGBA.defaultForeground()
this._glowColor = options.glowColor ?? this._color
this._completionColor = options.completionColor ?? this._color
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
}
@@ -103,12 +136,24 @@ class TabPulseRenderable extends Renderable {
this.requestRender()
}
set glow(value: boolean) {
if (value === this._glow) return
this._glow = value
this.requestRender()
}
set color(value: RGBA) {
if (value.equals(this._color)) return
this._color = value
this.requestRender()
}
set glowColor(value: RGBA) {
if (value.equals(this._glowColor)) return
this._glowColor = value
this.requestRender()
}
set completionColor(value: RGBA) {
if (value.equals(this._completionColor)) return
this._completionColor = value
@@ -144,15 +189,19 @@ class TabPulseRenderable extends Renderable {
}
protected override renderSelf(buffer: OptimizedBuffer): void {
if (!this.visible || this.isDestroyed || !this._enabled || this.width <= 0) return
const runningOpacity = this._active
? 1
: this.fadeClock === undefined
? 0
: 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT))
if (!this.visible || this.isDestroyed || this.width <= 0) return
const runningOpacity = !this._enabled
? 0
: this._active
? 1
: this.fadeClock === undefined
? 0
: 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT))
const completionOpacity =
this.completionClock === undefined ? 0 : completionPulseOpacity(this.completionClock / COMPLETION_DURATION)
if (runningOpacity === 0 && completionOpacity === 0) return
!this._enabled || this.completionClock === undefined
? 0
: completionPulseOpacity(this.completionClock / COMPLETION_DURATION)
if (!this._glow && runningOpacity === 0 && completionOpacity === 0) return
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
const start = -RUN_HEAD
const end = this.width - 1 + RUN_TAIL
@@ -163,14 +212,20 @@ class TabPulseRenderable extends Renderable {
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
)
const running = tint(this._backgroundColor, this._color, intensity * 0.14 * runningOpacity)
buffer.setCell(
this.screenX + index,
this.screenY,
" ",
RGBA.defaultForeground(),
tint(running, this._completionColor, completionOpacity * 0.18),
const glow = this._glow ? unreadGlowIntensity(index, this.width) * GLOW_OPACITY : 0
const running = intensity * 0.14 * runningOpacity
const completion = completionOpacity * 0.18
blendTabPulseColor(
this.renderColor,
this._backgroundColor,
this._glowColor,
this._color,
this._completionColor,
glow,
running,
completion,
)
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
}
}
}
@@ -187,7 +242,9 @@ export function TabPulse(props: {
enabled?: boolean
active: boolean
complete?: boolean
glow?: boolean
color: RGBA
glowColor?: RGBA
completionColor?: RGBA
backgroundColor: RGBA
}) {
@@ -199,7 +256,9 @@ export function TabPulse(props: {
enabled={props.enabled ?? true}
active={props.active}
complete={props.complete ?? false}
glow={props.glow ?? false}
color={props.color}
glowColor={props.glowColor ?? props.color}
completionColor={props.completionColor ?? props.color}
backgroundColor={props.backgroundColor}
/>
+6 -2
View File
@@ -120,11 +120,15 @@ export const Info = Schema.Struct({
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
}),
tabs: Schema.optional(Schema.Boolean).annotate({
}),
).annotate({ description: "Session transcript presentation settings" }),
tabs: Schema.optional(
Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Use a persistent session tab strip instead of pinned quick-switch sessions",
}),
}),
).annotate({ description: "Session transcript presentation settings" }),
).annotate({ description: "Session tab settings" }),
mini: Schema.optional(
Schema.Struct({
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
-28
View File
@@ -230,24 +230,9 @@ export const Definitions = {
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
terminal_title_toggle: keybind("none", "Toggle terminal title"),
plugin_manager: keybind("none", "Open plugin manager dialog"),
plugin_install: keybind("none", "Install plugin"),
which_key_toggle: keybind("ctrl+alt+k", "Toggle which-key panel"),
which_key_layout_toggle: keybind("ctrl+alt+shift+k", "Switch which-key layout"),
which_key_pending_toggle: keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"),
which_key_group_previous: keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"),
which_key_group_next: keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"),
which_key_scroll_up: keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"),
which_key_scroll_down: keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"),
which_key_page_up: keybind("ctrl+alt+pageup", "Page which-key up"),
which_key_page_down: keybind("ctrl+alt+pagedown", "Page which-key down"),
which_key_home: keybind("ctrl+alt+home", "Jump to first which-key binding"),
which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"),
} satisfies Record<string, Definition>
type KeybindName = keyof typeof Definitions
@@ -425,19 +410,6 @@ export const CommandMap = {
history_next: "prompt.history.next",
terminal_suspend: "terminal.suspend",
terminal_title_toggle: "terminal.title.toggle",
plugin_manager: "plugins.list",
plugin_install: "plugins.install",
which_key_toggle: "which-key.toggle",
which_key_layout_toggle: "which-key.layout.toggle",
which_key_pending_toggle: "which-key.pending.toggle",
which_key_group_previous: "which-key.group.previous",
which_key_group_next: "which-key.group.next",
which_key_scroll_up: "which-key.scroll.up",
which_key_scroll_down: "which-key.scroll.down",
which_key_page_up: "which-key.page.up",
which_key_page_down: "which-key.page.down",
which_key_home: "which-key.home",
which_key_end: "which-key.end",
} satisfies BindingCommandMap
const CommandDescriptions = Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [
+1 -1
View File
@@ -34,7 +34,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const event = useEvent()
const config = useConfig().data
const filePath = path.join(useTuiPaths().state, "session-tabs.json")
const enabled = () => config.session?.tabs ?? false
const enabled = () => config.tabs?.enabled ?? false
const state: {
pending: boolean
saving: boolean
@@ -1,49 +0,0 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/v1/tui"
import type { PluginRuntime } from "../plugin/runtime"
import PluginManager from "./system/plugins"
import WhichKey from "./system/which-key"
export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
id: string
tui: TuiPlugin
enabled?: boolean
}
export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
return [PluginManager, WhichKey]
}
export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) {
const slots = runtime.setupSlots(api)
const dispose: Array<() => void> = []
for (const plugin of createBuiltinPlugins()) {
if (plugin.enabled === false) continue
const scoped = Object.assign(Object.create(api), {
slots: {
register(input: Parameters<typeof slots.register>[0]) {
dispose.push(slots.register({ ...input, id: plugin.id }))
return plugin.id
},
},
}) as TuiPluginApi
const now = Date.now()
await plugin.tui(scoped, undefined, {
id: plugin.id,
source: "internal",
spec: plugin.id,
target: plugin.id,
first_time: now,
last_time: now,
time_changed: now,
load_count: 1,
fingerprint: plugin.id,
state: "first",
})
}
return () => {
for (const fn of dispose.reverse()) fn()
slots.dispose()
}
}
@@ -1,280 +1,90 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/v1/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { useTerminalDimensions } from "@opentui/solid"
import { fileURLToPath } from "url"
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, createSignal } from "solid-js"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { Show, createEffect, createMemo, createSignal } from "solid-js"
import { Keymap } from "../../context/keymap"
const id = "internal:plugin-manager"
const id = "opencode.plugins"
function state(api: TuiPluginApi, item: TuiPluginStatus) {
if (!item.enabled) {
return <span style={{ fg: api.theme.current.textMuted }}>disabled</span>
}
return (
<span style={{ fg: item.active ? api.theme.current.success : api.theme.current.error }}>
{item.active ? "active" : "inactive"}
</span>
)
}
function source(spec: string) {
if (!spec.startsWith("file://")) return
return fileURLToPath(spec)
}
function meta(item: TuiPluginStatus, width: number) {
if (item.source === "internal") {
if (width >= 120) return "Built-in plugin"
return "Built-in"
}
const next = source(item.spec)
if (next) return next
return item.spec
}
function Install(props: { api: TuiPluginApi }) {
const [global, setGlobal] = createSignal(false)
const [busy, setBusy] = createSignal(false)
Keymap.createLayer(() => ({
mode: "modal",
enabled: !busy(),
commands: [
{
bind: "tab",
title: "Toggle install scope",
group: "Plugins",
run: () => {
setGlobal((value) => !value)
},
},
],
}))
return (
<props.api.ui.DialogPrompt
title="Install plugin"
placeholder="npm package name"
busy={busy()}
busyText="Installing plugin..."
description={() => (
<box flexDirection="row" gap={1}>
<text fg={props.api.theme.current.textMuted}>scope:</text>
<text fg={busy() ? props.api.theme.current.textMuted : props.api.theme.current.text}>
{global() ? "global" : "local"}
</text>
<Show when={!busy()}>
<text fg={props.api.theme.current.textMuted}>(tab toggle)</text>
</Show>
</box>
)}
onConfirm={(raw) => {
if (busy()) return
const mod = raw.trim()
if (!mod) {
props.api.ui.toast({
variant: "error",
message: "Plugin package name is required",
})
return
}
setBusy(true)
void props.api.plugins
.install(mod, { global: global() })
.then((out) => {
if (!out.ok) {
props.api.ui.toast({
variant: "error",
message: out.message,
})
if (out.missing) {
props.api.ui.toast({
variant: "info",
message: "Check npm registry/auth settings and try again.",
})
}
show(props.api)
return
}
props.api.ui.toast({
variant: "success",
message: `Installed ${mod} (${global() ? "global" : "local"}: ${out.dir})`,
})
if (!out.tui) {
props.api.ui.toast({
variant: "info",
message: "Package has no TUI target to load in this app.",
})
show(props.api)
return
}
return props.api.plugins.add(mod).then((ok) => {
if (!ok) {
props.api.ui.toast({
variant: "warning",
message: "Installed plugin, but runtime load failed. See console/logs; restart TUI to retry.",
})
show(props.api)
return
}
props.api.ui.toast({
variant: "success",
message: `Loaded ${mod} in current session.`,
})
show(props.api)
})
})
.finally(() => {
setBusy(false)
})
}}
onCancel={() => {
show(props.api)
}}
/>
)
}
function row(api: TuiPluginApi, item: TuiPluginStatus, width: number): DialogSelectOption<string> {
return {
title: item.id,
value: item.id,
category: item.source === "internal" ? "Internal" : "External",
description: meta(item, width),
footer: state(api, item),
disabled: item.id === id,
}
}
function showInstall(api: TuiPluginApi) {
api.ui.dialog.replace(() => <Install api={api} />)
}
function View(props: { api: TuiPluginApi }) {
const size = useTerminalDimensions()
const [list, setList] = createSignal(props.api.plugins.list())
const [cur, setCur] = createSignal<string | undefined>()
const [lock, setLock] = createSignal(false)
createEffect(() => {
const width = size().width
if (width >= 128) {
props.api.ui.dialog.setSize("xlarge")
return
}
if (width >= 96) {
props.api.ui.dialog.setSize("large")
return
}
props.api.ui.dialog.setSize("medium")
})
const rows = createMemo(() =>
[...list()]
.sort((a, b) => {
const x = a.source === "internal" ? 1 : 0
const y = b.source === "internal" ? 1 : 0
if (x !== y) return x - y
return a.id.localeCompare(b.id)
})
.map((item) => row(props.api, item, size().width)),
function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePlugin> }) {
const [locked, setLocked] = createSignal(false)
const options = createMemo(() =>
props.plugins
.registered()
.filter((plugin) => plugin.id !== id)
.sort((a, b) => a.id.localeCompare(b.id))
.map(
(plugin): DialogSelectOption<string> => ({
title: plugin.id,
value: plugin.id,
category: plugin.source === "builtin" ? "Built-in" : "External",
footer: (
<span
style={{
fg: plugin.active
? props.context.theme.text.feedback.success.default
: props.context.theme.text.subdued,
}}
>
{plugin.active ? "active" : "inactive"}
</span>
),
}),
),
)
const flip = (x: string) => {
if (lock()) return
const item = list().find((entry) => entry.id === x)
if (!item) return
setLock(true)
const task = item.active ? props.api.plugins.deactivate(x) : props.api.plugins.activate(x)
void task
const toggle = (plugin: DialogSelectOption<string>) => {
if (locked()) return
const current = props.plugins.registered().find((item) => item.id === plugin.value)
if (!current) return
setLocked(true)
void (current.active ? props.plugins.deactivate(current.id) : props.plugins.activate(current.id))
.then((ok) => {
if (!ok) {
props.api.ui.toast({
variant: "error",
message: `Failed to update plugin ${item.id}`,
})
}
setList(props.api.plugins.list())
if (ok) return
props.context.ui.toast.show({ variant: "error", message: `Failed to update plugin ${current.id}` })
})
.finally(() => {
setLock(false)
.catch((error) => {
props.context.ui.toast.show({
variant: "error",
message: error instanceof Error ? error.message : String(error),
})
})
.finally(() => setLocked(false))
}
return (
<DialogSelect
title="Plugins"
options={rows()}
current={cur()}
onMove={(item) => setCur(item.value)}
actions={[
{
title: "toggle",
command: "plugins.toggle",
hidden: lock(),
onTrigger: (item) => {
setCur(item.value)
flip(item.value)
},
},
{
title: "install",
command: "dialog.plugins.install",
selection: "none",
hidden: lock(),
onTrigger: () => {
showInstall(props.api)
},
},
]}
onSelect={(item) => {
setCur(item.value)
flip(item.value)
}}
options={options()}
locked={locked()}
preserveSelection={true}
actions={[{ title: "toggle", command: "plugins.toggle", onTrigger: toggle }]}
onSelect={toggle}
/>
)
}
function show(api: TuiPluginApi) {
api.ui.dialog.replace(() => <View api={api} />)
}
const tui: TuiPlugin = async (api) => {
api.keymap.registerLayer({
function Commands(props: { context: Plugin.Context }) {
const plugins = usePlugin()
props.context.keymap.layer(() => ({
mode: "global",
commands: [
{
name: "plugins.list",
id: "plugins.list",
title: "Plugins",
category: "System",
namespace: "palette",
group: "System",
palette: true,
run() {
show(api)
},
},
{
name: "plugins.install",
title: "Install plugin",
category: "System",
namespace: "palette",
run() {
showInstall(api)
props.context.ui.dialog.show(() => <View context={props.context} plugins={plugins} />)
},
},
],
bindings: ["plugins.list", "plugins.install"].flatMap((command) => api.tuiConfig.keybinds.get(command)),
})
}))
return null
}
const plugin: BuiltinTuiPlugin = {
export default Plugin.define({
id,
tui,
}
export default plugin
setup(context) {
context.ui.slot("app", () => <Commands context={context} />)
},
})
@@ -1,5 +1,26 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { batch, createSignal } from "solid-js"
import { SessionTabs, type SessionTabsController } from "../../component/session-tabs"
type FixtureStatus = ReturnType<SessionTabsController["status"]>
const FIXTURE_TABS = [
{ sessionID: "fixture-1", title: "Implement session tabs" },
{ sessionID: "fixture-2", title: "Investigate rendering" },
{ sessionID: "fixture-3", title: "A deliberately long session title for truncation" },
{ sessionID: "fixture-4", title: "Fix provider state" },
{ sessionID: "fixture-5", title: "Review animation" },
{ sessionID: "fixture-6", title: "Untitled behavior" },
{ sessionID: "fixture-7", title: "Queue follow-up work" },
{ sessionID: "fixture-8", title: "Check narrow layout" },
{ sessionID: "fixture-9", title: "Profile terminal output" },
{ sessionID: "fixture-10", title: "Handle permission" },
{ sessionID: "fixture-11", title: "Run focused tests" },
{ sessionID: "fixture-12", title: "Prepare review" },
]
const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false }
function Commands(props: { context: Plugin.Context }) {
props.context.keymap.layer(() => ({
@@ -24,6 +45,50 @@ function Scrap(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme
const elevatedTheme = props.context.theme.contextual("elevated")
const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6))
const [active, setActive] = createSignal<string | undefined>("fixture-2")
const [animations, setAnimations] = createSignal(true)
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>({
"fixture-2": { ...EMPTY_STATUS, busy: true },
"fixture-3": { ...EMPTY_STATUS, unread: "activity" },
"fixture-4": { ...EMPTY_STATUS, unread: "error" },
"fixture-5": { ...EMPTY_STATUS, attention: true },
"fixture-6": { ...EMPTY_STATUS, busy: true, attention: true },
})
const controller = {
tabs,
current: active,
status(sessionID) {
return statuses()[sessionID] ?? EMPTY_STATUS
},
select(sessionID) {
setActive(sessionID)
},
close(sessionID?: string) {
const target = sessionID ?? active()
if (!target) return
const items = tabs()
const index = items.findIndex((tab) => tab.sessionID === target)
if (index === -1) return
const next = items.filter((tab) => tab.sessionID !== target)
batch(() => {
setTabs(next)
if (active() === target) setActive(next[index]?.sessionID ?? next[index - 1]?.sessionID)
})
},
} satisfies SessionTabsController
const cycle = (direction: 1 | -1) => {
const items = tabs()
if (items.length === 0) return
const index = items.findIndex((tab) => tab.sessionID === active())
controller.select(items[(index + direction + items.length) % items.length]!.sessionID)
}
const updateStatus = (update: (status: FixtureStatus) => FixtureStatus) => {
const sessionID = active()
if (!sessionID) return
setStatuses((current) => ({ ...current, [sessionID]: update(current[sessionID] ?? EMPTY_STATUS) }))
}
props.context.keymap.layer(() => ({
commands: [
@@ -35,12 +100,60 @@ function Scrap(props: { context: Plugin.Context }) {
props.context.ui.router.navigate({ type: "home" })
},
},
{ bind: "h", title: "Previous tab", group: "Scrap", run: () => cycle(-1) },
{ bind: "l", title: "Next tab", group: "Scrap", run: () => cycle(1) },
{
bind: "t",
title: "Add tab",
group: "Scrap",
run() {
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
if (next) setTabs((current) => [...current, next])
},
},
{ bind: "d", title: "Close tab", group: "Scrap", run: () => controller.close() },
{
bind: "b",
title: "Toggle busy",
group: "Scrap",
run: () =>
updateStatus((status) =>
status.busy ? { ...status, busy: false, unread: "activity" } : { ...status, busy: true, unread: undefined },
),
},
{
bind: "u",
title: "Cycle unread",
group: "Scrap",
run: () =>
updateStatus((status) => ({
...status,
unread: status.unread === undefined ? "activity" : status.unread === "activity" ? "error" : undefined,
})),
},
{
bind: "a",
title: "Toggle attention",
group: "Scrap",
run: () => updateStatus((status) => ({ ...status, attention: !status.attention })),
},
{
bind: "m",
title: "Toggle motion",
group: "Scrap",
run: () => setAnimations((enabled) => !enabled),
},
],
}))
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.default}>
<box flexGrow={1} />
<box
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
backgroundColor={theme.background.default}
>
<SessionTabs controller={controller} animations={animations()} />
<box
height={1}
flexShrink={0}
@@ -49,10 +162,13 @@ function Scrap(props: { context: Plugin.Context }) {
paddingRight={1}
flexDirection="row"
>
<text fg={elevatedTheme.text.subdued}>~/code/anomalyco/opencode</text>
<text fg={elevatedTheme.text.subdued}>tab playground</text>
<box flexGrow={1} />
<text fg={elevatedTheme.text.subdued}>esc home</text>
<text fg={elevatedTheme.text.subdued}>
h/l select | t add | d close | b busy | u unread | a attention | m motion | esc home
</text>
</box>
<box flexGrow={1} />
</box>
)
}
@@ -1,607 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { RGBA, TextAttributes, type KeyEvent, type Renderable } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { Keymap } from "../../context/keymap"
import type { ActiveKey } from "@opentui/keymap"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
import type { BuiltinTuiPlugin } from "../builtins"
const command = {
toggle: "which-key.toggle",
toggleLayout: "which-key.layout.toggle",
togglePending: "which-key.pending.toggle",
groupPrevious: "which-key.group.previous",
groupNext: "which-key.group.next",
scrollUp: "which-key.scroll.up",
scrollDown: "which-key.scroll.down",
pageUp: "which-key.page.up",
pageDown: "which-key.page.down",
home: "which-key.home",
end: "which-key.end",
} as const
const LAYER_PRIORITY = 900
const toggleCommands = [command.toggle, command.toggleLayout, command.togglePending] as const
const scrollCommands = [
command.scrollUp,
command.scrollDown,
command.pageUp,
command.pageDown,
command.home,
command.end,
] as const
const panelCommands = [command.groupPrevious, command.groupNext, ...scrollCommands] as const
const COLUMN_GAP = 4
const TAB_GAP = 3
const MIN_TAB_GAP = 1
const TAB_CONTENT_GAP = 1
const MIN_COLUMN_WIDTH = 28
const MAX_COLUMN_WIDTH = 44
const PANEL_HEIGHT_RATIO = 0.3
const MIN_PANEL_HEIGHT = 8
const MAX_PANEL_HEIGHT = 16
const PANEL_TOP_PADDING = 1
const FOOTER_HEIGHT = 1
const FOOTER_MARGIN = 1
const UNKNOWN = "Unknown"
type Layout = "dock" | "overlay"
type Color = RGBA | string
type Skin = {
panel: Color
text: Color
muted: Color
subtle: Color
key: Color
accent: Color
tab: Color
tabText: Color
}
type Entry = {
type: "entry"
key: string
label: string
group: string
continues: boolean
}
type Group = {
label: string
entries: Entry[]
}
type HeaderItem = { type: "tab"; group: Group } | { type: "scroll" }
type GroupHeader = {
type: "group"
label: string
}
type Item = Entry | GroupHeader
function text(value: unknown) {
if (typeof value !== "string") return undefined
const trimmed = value.trim()
return trimmed || undefined
}
function ink(api: TuiPluginApi, name: string, fallback: string): Color {
const value = Reflect.get(api.theme.current, name)
if (typeof value === "string") return value
if (value instanceof RGBA) return value
return fallback
}
function skin(api: TuiPluginApi): Skin {
return {
panel: ink(api, "backgroundMenu", "#1c1c1c"),
text: ink(api, "text", "#f0f0f0"),
muted: ink(api, "textMuted", "#a5a5a5"),
subtle: ink(api, "borderSubtle", "#6f6f6f"),
key: ink(api, "warning", "#ffd75f"),
accent: ink(api, "primary", "#5f87ff"),
tab: ink(api, "primary", "#5f87ff"),
tabText: ink(api, "selectedListItemText", "#ffffff"),
}
}
function activeKeyLabel(active: ActiveKey<Renderable, KeyEvent>) {
if (active.continues) return text(active.tokenName) ?? text(active.display) ?? UNKNOWN
return (
text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN
)
}
function activeKeyGroup(active: ActiveKey<Renderable, KeyEvent>) {
if (active.continues) return "System"
return text(active.commandAttrs?.category) ?? text(active.bindingAttrs?.group) ?? UNKNOWN
}
function activeKeyEntry(api: TuiPluginApi, active: ActiveKey<Renderable, KeyEvent>): Entry {
const key = api.keys.formatSequence([
{
stroke: active.stroke,
display: active.display,
tokenName: active.tokenName,
},
])
const label = activeKeyLabel(active)
return {
type: "entry",
key,
label: active.continues ? `+${label}` : label,
group: activeKeyGroup(active),
continues: active.continues,
}
}
function grouped(entries: Entry[]): Group[] {
const map = new Map<string, Entry[]>()
for (const entry of entries) map.set(entry.group, [...(map.get(entry.group) ?? []), entry])
return [...map]
.map(([label, entries]) => ({
label,
entries: entries.toSorted(
(a, b) =>
Number(b.continues) - Number(a.continues) || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
),
}))
.toSorted((a, b) => a.label.localeCompare(b.label))
}
function commandShortcut(_api: TuiPluginApi, name: string) {
const shortcuts = Keymap.useShortcuts()
return () => shortcuts.get(name) ?? ""
}
function layout(value: unknown): Layout {
if (value === "overlay") return "overlay"
return "dock"
}
function HomeHint(props: { api: TuiPluginApi }) {
const trigger = commandShortcut(props.api, command.toggle)
const look = createMemo(() => skin(props.api))
return (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0}>
<text fg={look().muted} wrapMode="none">
Show keyboard shortcuts with <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
</text>
</box>
)
}
function WhichKeyPanel(props: {
api: TuiPluginApi
layout: Layout
mode: () => Layout
pendingPreview: () => boolean
pinned: () => boolean
}) {
const dimensions = useTerminalDimensions()
const [offset, setOffset] = createSignal(0)
const [activeGroup, setActiveGroup] = createSignal<string | undefined>()
const pending = Keymap.usePendingSequence()
const active = Keymap.useActiveKeys()
const pendingActive = createMemo(() => pending().length > 0 && active().length > 0)
const pendingAutoVisible = createMemo(() => props.mode() === "overlay" && props.pendingPreview() && pendingActive())
const visible = createMemo(() => props.pinned() || pendingAutoVisible())
const pendingMode = createMemo(() => visible() && pendingActive())
const left = 0
const width = createMemo(() => Math.max(1, dimensions().width))
const panelHeight = createMemo(() =>
Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, Math.floor(dimensions().height * PANEL_HEIGHT_RATIO))),
)
const contentWidth = createMemo(() => Math.max(1, width() - 2))
const columns = createMemo(() =>
Math.max(1, Math.min(3, Math.floor((contentWidth() + COLUMN_GAP) / (MAX_COLUMN_WIDTH + COLUMN_GAP)) || 1)),
)
const entries = createMemo(() => active().map((item) => activeKeyEntry(props.api, item)))
const groups = createMemo(() => grouped(entries()))
const tabsVisible = createMemo(() => !pendingMode() && groups().length > 0)
const headerVisible = createMemo(() => tabsVisible() || pendingMode())
const footerVisible = createMemo(() => !pendingMode())
const rows = createMemo(() =>
Math.max(
1,
panelHeight() -
PANEL_TOP_PADDING -
(headerVisible() ? 1 : 0) -
(tabsVisible() ? TAB_CONTENT_GAP : 0) -
(footerVisible() ? FOOTER_MARGIN + FOOTER_HEIGHT : 0),
),
)
const pageSize = createMemo(() => rows() * columns())
const currentGroup = createMemo(() => {
const group = activeGroup()
return groups().find((item) => item.label === group) ?? groups()[0]
})
const activeEntries = createMemo(() => currentGroup()?.entries ?? [])
const items = createMemo<Item[]>(() => {
if (!pendingMode()) return activeEntries()
return groups().flatMap((group) => [{ type: "group", label: group.label } satisfies GroupHeader, ...group.entries])
})
const maxOffset = createMemo(() => Math.max(0, items().length - pageSize()))
const shown = createMemo(() => {
const columnsItems: Item[][] = []
let index = offset()
for (let column = 0; column < columns() && index < items().length; column++) {
const list: Item[] = []
while (list.length < rows() && index < items().length) {
list.push(items()[index]!)
index += 1
}
columnsItems.push(list)
}
return columnsItems
})
const rowIndexes = createMemo(() => Array.from({ length: rows() }, (_, index) => index))
const trigger = commandShortcut(props.api, command.toggle)
const modeTrigger = commandShortcut(props.api, command.toggleLayout)
const upActive = createMemo(() => offset() > 0)
const downActive = createMemo(() => offset() < maxOffset())
const scrollable = createMemo(() => maxOffset() > 0)
const headerItems = createMemo<HeaderItem[]>(() => [
...(tabsVisible() ? groups().map((group) => ({ type: "tab" as const, group })) : []),
...(scrollable() ? [{ type: "scroll" as const }] : []),
])
const tabGap = createMemo(() => {
const itemCount = headerItems().length
if (itemCount <= 1) return 0
const itemWidth = headerItems().reduce(
(sum, item) => sum + (item.type === "tab" ? item.group.label.length + 2 : 3),
0,
)
return Math.max(MIN_TAB_GAP, Math.min(TAB_GAP, Math.floor((contentWidth() - itemWidth) / (itemCount - 1))))
})
const nextMode = createMemo(() => (props.mode() === "dock" ? "overlay" : "dock"))
const look = createMemo(() => skin(props.api))
const columnWidth = createMemo(() =>
Math.max(1, Math.min(MAX_COLUMN_WIDTH, Math.floor((contentWidth() - (columns() - 1) * COLUMN_GAP) / columns()))),
)
const clamp = (value: number) => Math.max(0, Math.min(maxOffset(), value))
const scroll = (delta: number) => setOffset((value) => clamp(value + delta))
const moveGroup = (delta: number) => {
if (pendingMode()) return
const list = groups()
if (!list.length) return
const index = Math.max(
0,
list.findIndex((item) => item.label === currentGroup()?.label),
)
setActiveGroup(list[(index + delta + list.length) % list.length]!.label)
setOffset(0)
}
Keymap.createLayer(() => ({
priority: 1000,
enabled: visible(),
commands: [
{
id: command.groupPrevious,
bind: false,
title: "Previous key binding group",
description: "Show the previous which-key group",
group: "System",
run() {
moveGroup(-1)
},
},
{
id: command.groupNext,
bind: false,
title: "Next key binding group",
description: "Show the next which-key group",
group: "System",
run() {
moveGroup(1)
},
},
{
id: command.scrollUp,
bind: false,
title: "Scroll key bindings up",
description: "Scroll the which-key panel up",
group: "System",
run() {
scroll(-columns())
},
},
{
id: command.scrollDown,
bind: false,
title: "Scroll key bindings down",
description: "Scroll the which-key panel down",
group: "System",
run() {
scroll(columns())
},
},
{
id: command.pageUp,
bind: false,
title: "Page key bindings up",
description: "Page the which-key panel up",
group: "System",
run() {
scroll(-pageSize())
},
},
{
id: command.pageDown,
bind: false,
title: "Page key bindings down",
description: "Page the which-key panel down",
group: "System",
run() {
scroll(pageSize())
},
},
{
id: command.home,
bind: false,
title: "First key binding",
description: "Jump to the first which-key binding",
group: "System",
run() {
setOffset(0)
},
},
{
id: command.end,
bind: false,
title: "Last key binding",
description: "Jump to the last which-key binding",
group: "System",
run() {
setOffset(maxOffset())
},
},
],
bindings: pendingMode() ? scrollCommands : panelCommands,
}))
createEffect(() => {
if (pendingMode()) return
const group = currentGroup()
if (group?.label === activeGroup()) return
setActiveGroup(group?.label)
})
createEffect(() => {
if (pendingMode()) return
activeGroup()
setOffset(0)
})
createEffect(() => {
if (!visible()) setOffset(0)
})
createEffect(() => {
pending()
setOffset(0)
})
createEffect(() => {
setOffset((value) => clamp(value))
})
return (
<Show when={visible()}>
<box
position={props.layout === "overlay" ? "absolute" : "relative"}
zIndex={3500}
left={left}
bottom={props.layout === "overlay" ? 0 : undefined}
width={dimensions().width}
height={panelHeight()}
backgroundColor={look().panel}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
flexShrink={0}
flexDirection="column"
>
<Show when={headerVisible()}>
<box width="100%" flexDirection="row" justifyContent="center" gap={tabGap()} flexShrink={0}>
<For each={headerItems()}>
{(item) => (
<Show
when={item.type === "tab" ? item.group : undefined}
fallback={
<box flexShrink={0}>
<text wrapMode="none">
<span style={{ fg: upActive() ? look().text : look().muted }}></span>
<span style={{ fg: look().muted }}> </span>
<span style={{ fg: downActive() ? look().text : look().muted }}></span>
</text>
</box>
}
>
{(group) => {
const selected = createMemo(() => currentGroup()?.label === group().label)
return (
<box
paddingLeft={1}
paddingRight={1}
flexShrink={0}
backgroundColor={selected() ? look().tab : undefined}
onMouseDown={() => {
setActiveGroup(group().label)
setOffset(0)
}}
>
<text
fg={selected() ? look().tabText : look().muted}
attributes={selected() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
{group().label}
</text>
</box>
)
}}
</Show>
)}
</For>
</box>
</Show>
<Show when={tabsVisible()}>
<box height={TAB_CONTENT_GAP} flexShrink={0} />
</Show>
<box height={rows()} flexShrink={0} flexDirection="column">
<Show when={shown().length > 0} fallback={<text fg={look().muted}>No reachable bindings</text>}>
<For each={rowIndexes()}>
{(row) => (
<box width="100%" flexDirection="row" justifyContent="center" gap={COLUMN_GAP}>
<For each={shown()}>
{(column) => {
const item = createMemo(() => column[row])
const entry = createMemo(() => {
const value = item()
if (value?.type !== "entry") return undefined
return value
})
return (
<box width={columnWidth()} flexDirection="row" gap={1} justifyContent="space-between">
<Show when={item()}>
{(value) => (
<Show
when={entry()}
fallback={
<text fg={look().accent} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
{value().label}
</text>
}
>
{(binding) => (
<>
<box flexGrow={1} minWidth={0}>
<text
fg={binding().continues ? look().accent : look().muted}
wrapMode="none"
truncate
>
{binding().label}
</text>
</box>
<box flexShrink={0}>
<text fg={look().text} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
{binding().key}
</text>
</box>
</>
)}
</Show>
)}
</Show>
</box>
)
}}
</For>
</box>
)}
</For>
</Show>
</box>
<Show when={footerVisible()}>
<box height={FOOTER_MARGIN} flexShrink={0} />
<box width="100%" flexDirection="row" justifyContent="space-between" flexShrink={0}>
<box>
<text fg={look().text} wrapMode="none">
toggle <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
</text>
</box>
<box>
<text fg={look().text} wrapMode="none">
{nextMode()} <span style={{ fg: look().subtle }}>{modeTrigger() || command.toggleLayout}</span>
</text>
</box>
</box>
</Show>
</box>
</Show>
)
}
const tui: TuiPlugin = async (api) => {
const [pinned, setPinned] = createSignal(false)
const [mode, setMode] = createSignal(layout("dock"))
const [pendingPreview, setPendingPreview] = createSignal(false)
api.keymap.registerLayer({
priority: LAYER_PRIORITY,
commands: [
{
name: command.toggle,
title: "Show key bindings",
desc: "Toggle which-key overlay",
category: "System",
run() {
setPinned((value) => !value)
},
},
{
name: command.toggleLayout,
title: "Toggle key bindings layout",
desc: "Switch which-key between dock and overlay mode",
category: "System",
run() {
setMode((value) => {
const next = value === "dock" ? "overlay" : "dock"
return next
})
},
},
{
name: command.togglePending,
title: "Toggle pending key preview",
desc: "Automatically show which-key for pending key sequences in overlay mode",
category: "System",
run() {
setPendingPreview((value) => {
return !value
})
},
},
],
bindings: toggleCommands.flatMap((command) => api.tuiConfig.keybinds.get(command)),
})
api.slots.register({
order: 200,
slots: {
home_bottom() {
return <HomeHint api={api} />
},
app() {
return (
<Show when={mode() === "overlay"}>
<WhichKeyPanel api={api} layout="overlay" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
</Show>
)
},
app_bottom() {
return (
<Show when={mode() === "dock"}>
<WhichKeyPanel api={api} layout="dock" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
</Show>
)
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id: "which-key",
enabled: false,
tui,
}
export default plugin
+23 -12
View File
@@ -767,19 +767,23 @@ export function RunSubagentSelectBody(props: {
onRows?: (rows: number) => void
mono?: boolean
}) {
const [active, setActive] = createSignal(true)
const entries = createMemo<SubagentEntry[]>(() =>
props.tabs().map((item) => {
const title = item.description || item.title || item.label
return {
category: "",
display: title,
description: title === item.label ? undefined : item.label,
footer: subagentStatusLabel(item.status),
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
sessionID: item.sessionID,
current: props.current() === item.sessionID,
}
}),
props
.tabs()
.filter((item) => (active() ? item.status === "running" : item.status !== "running"))
.map((item) => {
const title = item.description || item.title || item.label
return {
category: "",
display: title,
description: title === item.label ? undefined : item.label,
footer: subagentStatusLabel(item.status),
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
sessionID: item.sessionID,
current: props.current() === item.sessionID,
}
}),
)
const controller = createSearchablePanelController({
entries,
@@ -788,6 +792,12 @@ export function RunSubagentSelectBody(props: {
onSelect: (item) => props.onSelect(item.sessionID),
isCurrent: (item) => item.current,
closeOnFirstUp: true,
onKey(event) {
if (event.name.toLowerCase() !== "tab") return false
event.preventDefault()
setActive((value) => !value)
return true
},
onRows: props.onRows,
})
@@ -801,6 +811,7 @@ export function RunSubagentSelectBody(props: {
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
hint={`tab show ${active() ? "inactive" : "active"}`}
mono={props.mono}
>
<RunFooterMenu
-40
View File
@@ -1,40 +0,0 @@
import type { TuiRouteDefinition } from "@opencode-ai/plugin/v1/tui"
import { createSignal } from "solid-js"
type RouteEntry = {
key: symbol
render: TuiRouteDefinition["render"]
}
export type RouteMap = Map<string, RouteEntry[]>
export function createPluginRoutes() {
const routes: RouteMap = new Map()
const [revision, setRevision] = createSignal(0)
return {
register(list: TuiRouteDefinition[]) {
const key = Symbol()
list.forEach((item) => routes.set(item.name, [...(routes.get(item.name) ?? []), { key, render: item.render }]))
setRevision((value) => value + 1)
return () => {
list.forEach((item) => {
const next = routes.get(item.name)?.filter((entry) => entry.key !== key) ?? []
if (next.length) {
routes.set(item.name, next)
return
}
routes.delete(item.name)
})
setRevision((value) => value + 1)
}
},
get(name: string) {
revision()
return routes.get(name)?.at(-1)?.render
},
}
}
export type PluginRoutes = ReturnType<typeof createPluginRoutes>
+2
View File
@@ -5,6 +5,7 @@ import SidebarLsp from "../feature-plugins/sidebar/lsp"
import SidebarMcp from "../feature-plugins/sidebar/mcp"
import DiffViewer from "../feature-plugins/system/diff-viewer"
import Notifications from "../feature-plugins/system/notifications"
import Plugins from "../feature-plugins/system/plugins"
import Scrap from "../feature-plugins/system/scrap"
export const builtins = [
@@ -14,6 +15,7 @@ export const builtins = [
SidebarLsp,
SidebarFooter,
Notifications,
Plugins,
Scrap,
DiffViewer,
]
-108
View File
@@ -1,108 +0,0 @@
// Legacy `api.command` bridge for v1 plugins; remove in v2.
import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
import { TuiKeybind } from "../config/keybind"
import type { DialogContext } from "../ui/dialog"
const COMMAND_PALETTE_SHOW = "command.palette.show"
const warned = new Set<string>()
type Warn = (api: string, replacement: string) => void
type LegacyDialog = TuiPluginApi["ui"]["dialog"]
type CommandShimDialog = DialogContext | LegacyDialog
type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"]
function warnCommandShim(api: string, replacement: string) {
// Warn v1 plugins about deprecated `api.command`; remove this shim path in v2.
console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement })
}
function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog {
if (!("stack" in dialog)) return dialog
return {
replace(render, onClose) {
dialog.replace(render, onClose)
},
clear() {
dialog.clear()
},
setSize(size) {
dialog.setSize(size)
},
get size() {
return dialog.size
},
get depth() {
return dialog.stack.length
},
get open() {
return dialog.stack.length > 0
},
}
}
function warnOnce(api: string, replacement: string, warn: Warn) {
if (warned.has(api)) return
warned.add(api)
warn(api, replacement)
}
function toCommand(item: TuiCommand, dialog: LegacyDialog) {
return {
namespace: "palette",
name: item.value,
title: item.title,
desc: item.description,
category: item.category,
suggested: item.suggested,
hidden: item.hidden,
enabled: item.enabled,
slash: item.slash,
run() {
return item.onSelect?.(dialog)
},
}
}
function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) {
return commands.flatMap((item) =>
item.keybind
? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
? keybinds
.get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
.map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title }))
: [
{
key: item.keybind,
cmd: item.value,
desc: item.title,
},
]
: [],
)
}
export function createCommandShim(
keymap: TuiPluginApi["keymap"],
dialog: CommandShimDialog,
keybinds: LegacyKeybinds,
): TuiPluginApi["command"] {
const shimDialog = createCommandShimDialog(dialog)
return {
register(cb) {
warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim)
const commands = cb()
return keymap.registerLayer({
commands: commands.map((item) => toCommand(item, shimDialog)),
bindings: toBindings(commands, keybinds),
})
},
trigger(value) {
warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim)
keymap.dispatchCommand(value)
},
show() {
warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim)
keymap.dispatchCommand(COMMAND_PALETTE_SHOW)
},
}
}
+20 -28
View File
@@ -48,9 +48,16 @@ type State =
| { readonly target: string; readonly status: "unsupported" }
| { readonly target: string; readonly status: "failed"; readonly error: string }
type RegisteredPlugin = {
readonly id: string
readonly source: "builtin" | "external"
readonly active: boolean
}
type Value = {
readonly ready: () => boolean
readonly list: () => ReadonlyArray<State>
readonly registered: () => ReadonlyArray<RegisteredPlugin>
readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
readonly activate: (id: string) => Promise<boolean>
@@ -59,8 +66,8 @@ type Value = {
type Dispose = () => Promise<void>
type Registration = {
target: string
plugin: Plugin.Definition
source: RegisteredPlugin["source"]
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
@@ -107,28 +114,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
})
const owned: Dispose[] = []
let context: Context
let activeDialog: symbol | undefined
const dialogApi: Dialog = {
show(render, onClose) {
const token = Symbol()
let closed = false
activeDialog = token
dialog.replace(
() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>,
() => {
if (closed) return
closed = true
if (activeDialog === token) activeDialog = undefined
onClose?.()
},
)
return () => {
if (closed || activeDialog !== token) return
dialog.clear()
}
dialog.replace(() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>, onClose)
},
set(options) {
if (!activeDialog) return
dialog.setSize(options.size ?? "medium")
dialog.setCentered(options.centered ?? false)
},
@@ -224,7 +214,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
toast.show({ ...options, variant: options.variant ?? "info" })
},
}
owned.push(async () => dialogApi.clear())
context = {
options: item.options ?? {},
get location() {
@@ -365,8 +354,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
for (const plugin of builtins) {
setStore("registrations", plugin.id, {
target: plugin.id,
plugin,
source: "builtin",
active: false,
routes: {},
slots: {},
@@ -410,23 +399,24 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
continue
}
const item = { target, plugin, options }
setStore("registrations", item.plugin.id, {
...item,
setStore("registrations", plugin.id, {
plugin,
source: "external",
options,
active: false,
routes: {},
slots: {},
cleanups: [],
})
const error = await activate(item.plugin.id).then(
const error = await activate(plugin.id).then(
() => undefined,
(error) => (error instanceof Error ? error.message : String(error)),
)
setStore("states", (items) => [
...items.filter((state) => state.target !== item.target && (!("id" in state) || state.id !== item.plugin.id)),
...items.filter((state) => state.target !== target && (!("id" in state) || state.id !== plugin.id)),
error
? { target: item.target, status: "failed", error }
: { target: item.target, id: item.plugin.id, status: "active" },
? { target, status: "failed", error }
: { target, id: plugin.id, status: "active" },
])
}
}
@@ -460,6 +450,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
value={{
ready: () => store.ready,
list: () => store.states,
registered: () =>
Object.entries(store.registrations).map(([id, plugin]) => ({ id, source: plugin.source, active: plugin.active })),
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slot: (name) =>
Object.values(store.registrations).flatMap((registration) =>
-79
View File
@@ -1,79 +0,0 @@
import type {
TuiPluginApi,
TuiPluginInstallOptions,
TuiPluginInstallResult,
TuiPluginStatus,
} from "@opencode-ai/plugin/v1/tui"
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
import { createPluginRoutes } from "./api"
import { createSlots, type HostSlots } from "./slots"
export function createPluginRuntime() {
const [commands, setCommands] = createSignal<PluginRuntimeCommands>(emptyCommands)
const [status, setStatus] = createSignal<ReadonlyArray<TuiPluginStatus>>([])
const slots = createSlots()
return {
Slot: slots.Slot,
routes: createPluginRoutes(),
commands,
status,
update(input: { commands?: PluginRuntimeCommands; status?: ReadonlyArray<TuiPluginStatus> }) {
if (input.commands) setCommands(input.commands)
if (input.status) setStatus(input.status)
},
clear() {
setCommands(emptyCommands)
setStatus([])
slots.clear()
},
setupSlots(api: TuiPluginApi): HostSlots {
return slots.setup(api)
},
}
}
export type PluginRuntimeCommands = {
activate: (id: string) => Promise<boolean>
deactivate: (id: string) => Promise<boolean>
add: (spec: string) => Promise<boolean>
install: (spec: string, options?: TuiPluginInstallOptions) => Promise<TuiPluginInstallResult>
}
const emptyCommands: PluginRuntimeCommands = {
async activate() {
return false
},
async deactivate() {
return false
},
async add() {
return false
},
async install() {
return { ok: false, message: "Plugin runtime is not available." }
},
}
export type PluginRuntime = ReturnType<typeof createPluginRuntime>
export type TuiPluginHost = {
start(input: {
api: TuiPluginApi
runtime: PluginRuntime
dispose?: () => void
}): Promise<void>
dispose(): Promise<void>
}
const Context = createContext<PluginRuntime>()
export function PluginRuntimeProvider(props: ParentProps<{ value: PluginRuntime }>): JSX.Element {
return <Context.Provider value={props.value}>{props.children}</Context.Provider>
}
export function usePluginRuntime() {
const runtime = useContext(Context)
if (!runtime) throw new Error("usePluginRuntime must be used within PluginRuntimeProvider")
return runtime
}
-65
View File
@@ -1,65 +0,0 @@
import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@opencode-ai/plugin/v1/tui"
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
import { createSignal } from "solid-js"
import { isRecord } from "../util/record"
type RuntimeSlotMap = TuiSlotMap<Record<string, object>>
type SlotView = <Name extends string>(props: TuiSlotProps<Name>) => JSX.Element | null
export type HostSlotPlugin<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
export type HostPluginApi = TuiPluginApi
export type HostSlots = {
register: {
(plugin: HostSlotPlugin): () => void
<Slots extends Record<string, object>>(plugin: HostSlotPlugin<Slots>): () => void
}
dispose: () => void
}
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string, object>> {
if (!isRecord(value)) return false
if (typeof value.id !== "string") return false
return isRecord(value.slots)
}
export function createSlots() {
const empty: SlotView = (props) => props.children ?? null
const [view, setView] = createSignal<SlotView>(empty)
const Slot: SlotView = (props) => view()(props)
return {
Slot,
setup(api: HostPluginApi): HostSlots {
const registry = createSolidSlotRegistry<RuntimeSlotMap, TuiSlotContext>(
api.renderer,
{ theme: api.theme },
{
onPluginError(event) {
console.error("[tui.slot] plugin error", {
plugin: event.pluginId,
slot: event.slot,
phase: event.phase,
source: event.source,
message: event.error.message,
})
},
},
)
const slot = createSlot<RuntimeSlotMap, TuiSlotContext>(registry)
setView(() => (props: TuiSlotProps<string>) => slot(props))
return {
register(plugin: HostSlotPlugin) {
if (!isHostSlotPlugin(plugin)) return () => {}
return registry.register(plugin)
},
dispose() {
setView(() => empty)
},
}
},
clear() {
setView(() => empty)
},
}
}
+2 -13
View File
@@ -5,7 +5,6 @@ import { useArgs } from "../context/args"
import { useRouteData } from "../context/route"
import { usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local"
import { usePluginRuntime } from "../plugin/runtime"
import { useEditorContext } from "../context/editor"
import { useData } from "../context/data"
import { useLocation } from "../context/location"
@@ -19,7 +18,6 @@ const placeholder = {
}
export function Home() {
const pluginRuntime = usePluginRuntime()
const route = useRouteData("home")
const promptRef = usePromptRef()
const [ref, setRef] = createSignal<PromptRef | undefined>()
@@ -70,20 +68,11 @@ export function Home() {
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
<box flexShrink={0}>
<pluginRuntime.Slot name="home_logo" mode="replace">
<Logo />
</pluginRuntime.Slot>
<Logo />
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
<Prompt
ref={bind}
right={<pluginRuntime.Slot name="home_prompt_right" />}
placeholders={placeholder}
disabled={forms().length > 0}
/>
</pluginRuntime.Slot>
<Prompt ref={bind} placeholders={placeholder} disabled={forms().length > 0} />
</box>
<box flexGrow={1} minHeight={0} />
</box>
@@ -27,6 +27,7 @@ export function SubagentsTab(props: { sessionID: string }) {
const shortcuts = Keymap.useShortcuts()
const session = createMemo(() => data.session.get(props.sessionID))
const [store, setStore] = createStore({ selected: 0, active: true })
const entries = createMemo<SubagentEntry[]>(() => {
const current = session()
@@ -72,10 +73,9 @@ export function SubagentsTab(props: { sessionID: string }) {
}
}
return result
return result.filter((entry) => (store.active ? entry.status === "running" : entry.status !== "running"))
})
const [store, setStore] = createStore({ selected: 0 })
let selectedSessionID = ""
let wasActive = false
let scroll: ScrollBoxRenderable | undefined
@@ -90,7 +90,7 @@ export function SubagentsTab(props: { sessionID: string }) {
if (!active) {
if (wasActive) {
selectedSessionID = ""
setStore("selected", 0)
setStore({ selected: 0, active: true })
}
wasActive = false
return
@@ -140,8 +140,15 @@ export function SubagentsTab(props: { sessionID: string }) {
label: "Subagents",
hints: () => {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return []
return [{ label: "interrupt", shortcut: shortcuts.get("composer.subagent.interrupt") ?? "" }]
return [
...(entry?.status === "running"
? [{ label: "interrupt", shortcut: shortcuts.get("composer.subagent.interrupt") ?? "" }]
: []),
{
label: `show ${store.active ? "inactive" : "active"}`,
shortcut: shortcuts.get("composer.subagent.toggle-activity") ?? "",
},
]
},
onClose: () => {
const parentID = session()?.parentID
@@ -189,6 +196,16 @@ export function SubagentsTab(props: { sessionID: string }) {
if (entry) navigate({ type: "session", sessionID: entry.sessionID })
},
},
{
id: "composer.subagent.toggle-activity",
title: "Toggle active subagents",
group: "Composer",
bind: "ctrl+a",
run() {
setStore({ selected: 0, active: !store.active })
scroll?.scrollTo(0)
},
},
{
id: "composer.subagent.interrupt",
title: "Interrupt subagent",
@@ -206,7 +223,10 @@ export function SubagentsTab(props: { sessionID: string }) {
return (
<Show when={composer.active("subagents")}>
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
<Show when={entries().length > 0} fallback={<text fg={theme.text.subdued}> No subagents</text>}>
<Show
when={entries().length > 0}
fallback={<text fg={theme.text.subdued}> No {store.active ? "active" : "inactive"} subagents</text>}
>
<For each={entries()}>
{(entry, index) => {
const active = createMemo(() => index() === selected())
+175 -23
View File
@@ -8,6 +8,7 @@ import {
Match,
on,
onCleanup,
onMount,
Show,
Switch,
useContext,
@@ -19,10 +20,10 @@ import { useRoute, useRouteData } from "../../context/route"
import { createStore } from "solid-js/store"
import { useData } from "../../context/data"
import { SplitBorder } from "../../ui/border"
import { useTuiTerminalEnvironment } from "../../context/runtime"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
ModelInfo,
@@ -48,6 +49,7 @@ import {
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useClient } from "../../context/client"
import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { DialogMessage } from "./dialog-message"
@@ -74,7 +76,6 @@ import { useClipboard } from "../../context/clipboard"
import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { usePluginRuntime } from "../../plugin/runtime"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
@@ -122,12 +123,12 @@ export function Session() {
await mkdir(path.dirname(file), { recursive: true })
await writeFile(file, content)
}
const pluginRuntime = usePluginRuntime()
const route = useRouteData("session")
const { navigate } = useRoute()
const data = useData()
const local = useLocal()
const args = useArgs()
const paths = useTuiPaths()
const configState = useConfig()
const config = configState.data
const theme = useTheme()
@@ -168,6 +169,16 @@ export function Session() {
})
const disabled = createMemo(() => promptedPermissions().length > 0 || forms().length > 0)
const pending = createMemo(() => {
const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id
return messages().findLast((x) => x.type === "assistant" && !x.time.completed && (!completed || x.id > completed))
?.id
})
const lastAssistant = createMemo(() => {
return messages().findLast((x) => x.type === "assistant")
})
const dimensions = useTerminalDimensions()
const sidebar = createMemo(() => config.session?.sidebar ?? "auto")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
@@ -637,7 +648,7 @@ export function Session() {
const messages = data.session.message.list(route.sessionID)
if (!messages || !messages.length) return
// Find the most recent visible user message in the V2 transcript.
// Find the most recent user message with non-ignored, non-synthetic text parts
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (!message || message.type !== "user" || !message.text.trim()) continue
@@ -973,26 +984,15 @@ export function Session() {
</Show>
</Match>
<Match when={!disabled()}>
<pluginRuntime.Slot
name="session_prompt"
mode="replace"
session_id={route.sessionID}
<Prompt
visible={true}
disabled={false}
on_submit={toBottom}
ref={bind}
>
<Prompt
visible={true}
ref={bind}
disabled={false}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
right={<pluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
/>
</pluginRuntime.Slot>
disabled={false}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
/>
</Match>
</Switch>
</box>
@@ -1866,6 +1866,122 @@ function UserMessage(props: { message: SessionMessageUser }) {
)
}
function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) {
const ctx = use()
const local = useLocal()
const theme = useThemes().contextual("elevated")
const model = createMemo(
() =>
ctx
.models()
.find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
)
const final = createMemo(() => {
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
})
const duration = createMemo(() => {
if (!final()) return 0
if (!props.message.time.completed) return 0
return props.message.time.completed - props.message.time.created
})
const exploration = createMemo(() => {
const grouped = new Map<string, { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean }>()
if (!ctx.groupExploration()) return grouped
const runs = props.message.content
.map((part) =>
part.type === "tool" &&
["read", "glob", "grep"].includes(toolDisplay(part.name)) &&
part.state.status !== "streaming"
? part
: undefined,
)
.reduce<SessionMessageAssistantTool[][]>(
(runs, part) => {
if (part) runs[runs.length - 1].push(part)
if (!part && runs[runs.length - 1].length) runs.push([])
return runs
},
[[]],
)
.filter((run) => run.length > 0)
for (const run of runs) {
const summary = {
parts: run,
active: false,
}
run.forEach((part, index) => grouped.set(part.id, { ...summary, first: index === 0 }))
}
return grouped
})
return (
<>
<For each={props.message.content}>
{(content, index) => (
<Switch>
<Match when={content.type === "text"}>
<TextPart
part={content as SessionMessageAssistantText}
last={index() === props.message.content.length - 1}
/>
</Match>
<Match when={content.type === "reasoning"}>
<ReasoningPart
part={content as SessionMessageAssistantReasoning}
message={props.message}
last={index() === props.message.content.length - 1}
/>
</Match>
<Match when={content.type === "tool"}>
<Show when={exploration().get((content as SessionMessageAssistantTool).id)?.first !== false}>
<Show
when={exploration().get((content as SessionMessageAssistantTool).id)}
fallback={<ToolPart part={content as SessionMessageAssistantTool} />}
>
{(summary) => <ExplorationSummary {...summary()} />}
</Show>
</Show>
</Match>
</Switch>
)}
</For>
<Show when={props.message.error}>
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={theme.background.default}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.text.feedback.error.default}
>
<text fg={theme.text.subdued}>{errorMessage(props.message.error)}</text>
</box>
</Show>
<AssistantRetry retry={props.message.retry} />
<Switch>
<Match when={props.last || final() || props.message.error}>
<box paddingLeft={3}>
<text>
<span style={{ fg: props.message.error ? theme.text.subdued : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
</span>
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
<Show when={duration()}>
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
</Show>
</text>
</box>
</Match>
</Switch>
</>
)
}
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const theme = useTheme()
return (
@@ -1881,6 +1997,40 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
)
}
function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) {
const theme = useTheme()
const pathFormatter = usePathFormatter()
const label = (part: SessionMessageAssistantTool) => {
const input = typeof part.state.input === "string" ? {} : part.state.input
const tool = toolDisplay(part.name)
if (tool === "read") return `Read ${pathFormatter.format(stringValue(input.path))}`
if (tool === "glob") return `Glob "${stringValue(input.pattern)}"`
return `Grep "${stringValue(input.pattern)}"`
}
return (
<box flexDirection="column">
<InlineToolRow
icon="✱"
color={theme.text.subdued}
complete={!props.active}
pending="Exploring"
spinner={props.active}
>
{props.active ? "Exploring" : "Explored"}
</InlineToolRow>
<For each={props.parts}>
{(part, index) => (
<box paddingLeft={5}>
<text fg={part.state.status === "error" ? theme.text.feedback.error.default : theme.text.subdued}>
{index() === props.parts.length - 1 ? "└" : "├"} {label(part)}
</text>
</box>
)}
</For>
</box>
)
}
const INLINE_TOOL_ICON_WIDTH = 2
function ReasoningPart(props: {
@@ -2031,6 +2181,8 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
)
}
// Pending messages moved to individual tool pending functions
function ToolPart(props: { part: SessionMessageAssistantTool }) {
const display = createMemo(() => toolDisplay(props.part.name))
+8 -17
View File
@@ -2,13 +2,11 @@ import { useData } from "../../context/data"
import { createMemo, Show } from "solid-js"
import { useThemes } from "../../context/theme"
import { useConfig } from "../../config"
import { usePluginRuntime } from "../../plugin/runtime"
import { PluginSlot } from "../../plugin/context"
import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const pluginRuntime = usePluginRuntime()
const data = useData()
const theme = useThemes().contextual("elevated")
const config = useConfig().data
@@ -38,21 +36,14 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
}}
>
<box flexShrink={0} gap={1} paddingRight={1}>
<pluginRuntime.Slot
name="sidebar_title"
mode="single_winner"
session_id={props.sessionID}
title={session()!.title}
>
<box paddingRight={1}>
<text fg={theme.text.default}>
<b>{session()!.title}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
</Show>
</box>
</pluginRuntime.Slot>
<box paddingRight={1}>
<text fg={theme.text.default}>
<b>{session()!.title}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
</Show>
</box>
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} mode="all" />
</box>
</scrollbox>
+38 -1
View File
@@ -1,5 +1,7 @@
import { expect, test } from "bun:test"
import { completionPulseOpacity } from "../../src/component/tab-pulse"
import { RGBA } from "@opentui/core"
import { blendTabPulseColor, completionPulseOpacity, unreadGlowIntensity } from "../../src/component/tab-pulse"
import { tint } from "../../src/theme/color"
test("completion pulse rises quickly and fades over the remaining duration", () => {
expect(completionPulseOpacity(0)).toBe(0)
@@ -8,3 +10,38 @@ test("completion pulse rises quickly and fades over the remaining duration", ()
expect(completionPulseOpacity(0.58)).toBeCloseTo(0.5)
expect(completionPulseOpacity(1)).toBe(0)
})
test("unread glow peaks behind the tab number and fades to the normal background", () => {
const intensities = Array.from({ length: 22 }, (_, index) => unreadGlowIntensity(index, 22))
expect(intensities[0]).toBe(1)
expect(intensities[1]).toBe(1)
expect(intensities[2]).toBeLessThan(1)
expect(intensities.slice(1)).toEqual(intensities.slice(1).sort((a, b) => b - a))
expect(intensities[13]).toBe(0)
expect(intensities.at(-1)).toBe(0)
})
test("unread glow reaches the normal background on compact tabs", () => {
expect(unreadGlowIntensity(0, 8)).toBe(1)
expect(unreadGlowIntensity(7, 8)).toBe(0)
})
test("reuses a color while preserving the original glow and pulse blend stages", () => {
const output = RGBA.fromInts(0, 0, 0)
const background = RGBA.fromHex("#1a1b26")
const glowColor = RGBA.fromHex("#82aaff")
const runningColor = RGBA.fromHex("#c8d3f5")
const completionColor = RGBA.fromHex("#ff9e64")
for (const glow of [0, 0.08, 0.16]) {
for (const running of [0, 0.01, 0.07, 0.14]) {
for (const completion of [0, 0.03, 0.09, 0.18]) {
blendTabPulseColor(output, background, glowColor, runningColor, completionColor, glow, running, completion)
expect(output.buffer).toEqual(
tint(tint(tint(background, glowColor, glow), runningColor, running), completionColor, completion).buffer,
)
}
}
}
})
+2 -2
View File
@@ -17,8 +17,8 @@ test("validates mini replay settings", () => {
test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ session: { tabs: true } })).toEqual({ session: { tabs: true } })
expect(() => decode({ session: { tabs: "on" } })).toThrow()
expect(decode({ tabs: { enabled: true } })).toEqual({ tabs: { enabled: true } })
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
})
test("resolves nested config and keybind defaults", () => {
+14 -4
View File
@@ -820,7 +820,7 @@ test("direct command panel keeps completed subagents available", async () => {
}
})
test("direct subagent panel renders active subagents", async () => {
test("direct subagent panel toggles between active and inactive subagents", async () => {
const [tabs] = createSignal([
subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
subagent({ sessionID: "s-2", label: "General", description: "Write migration plan", status: "completed" }),
@@ -856,12 +856,22 @@ test("direct subagent panel renders active subagents", async () => {
expect(frame).toContain("Select subagent")
expect(frame).toContain("Inspect auth flow")
expect(frame).toContain("Write migration plan")
expect(frame).toContain("done")
expect(frame).not.toContain("Write migration plan")
expect(frame).not.toContain("done")
expect(frame).toContain("tab show inactive")
expect(frame).not.toContain("┌")
expect(frame).not.toContain("┃")
expectPaletteList(list, 0)
expect(rows).toBe(8)
expect(rows).toBe(7)
app.mockInput.pressKey("TAB")
await app.renderOnce()
const inactive = app.captureCharFrame()
expect(inactive).not.toContain("Inspect auth flow")
expect(inactive).toContain("Write migration plan")
expect(inactive).toContain("done")
expect(inactive).toContain("tab show active")
} finally {
app.renderer.destroy()
}
-50
View File
@@ -1,50 +0,0 @@
import { expect, test } from "bun:test"
import { createPluginRuntime } from "../../src/plugin/runtime"
test("routes use the latest registration and restore previous registrations", () => {
const runtime = createPluginRuntime()
const first = () => "first"
const second = () => "second"
runtime.routes.register([{ name: "demo", render: first }])
const dispose = runtime.routes.register([{ name: "demo", render: second }])
expect(runtime.routes.get("demo")).toBe(second)
dispose()
expect(runtime.routes.get("demo")).toBe(first)
})
test("facade publishes and clears presentation state", async () => {
const runtime = createPluginRuntime()
runtime.update({
commands: {
async activate() {
return true
},
async deactivate() {
return true
},
async add() {
return true
},
async install() {
return { ok: true, dir: "/tmp", tui: true }
},
},
status: [
{
id: "demo",
source: "internal",
spec: "demo",
target: "demo",
enabled: true,
active: true,
},
],
})
expect(await runtime.commands().activate("demo")).toBe(true)
expect(runtime.status()).toHaveLength(1)
runtime.clear()
expect(await runtime.commands().activate("demo")).toBe(false)
expect(runtime.status()).toEqual([])
})
-38
View File
@@ -1,38 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { createSlot, createSolidSlotRegistry, testRender, useRenderer } from "@opentui/solid"
import { onMount } from "solid-js"
type Slots = {
prompt: {}
}
test("replace slot mounts plugin content once", async () => {
let mounts = 0
const Probe = () => {
onMount(() => {
mounts += 1
})
return <box />
}
const App = () => {
const registry = createSolidSlotRegistry<Slots>(useRenderer(), {})
const Slot = createSlot(registry)
registry.register({ id: "plugin", slots: { prompt: () => <Probe /> } })
return (
<Slot name="prompt" mode="replace">
<box />
</Slot>
)
}
const app = await testRender(() => <App />)
try {
expect(mounts).toBe(1)
} finally {
app.renderer.destroy()
}
})
+7 -1
View File
@@ -7,6 +7,11 @@ import { Tabs, TabItem } from "@astrojs/starlight/components"
import config from "../../../config.mjs"
export const console = config.console
:::note
OpenCode 1 installs and runs as `opencode`. OpenCode 2 installs separately as `opencode2`, so you can keep both versions
installed and run them side by side. See the [OpenCode 2 docs](https://opencode.ai/v2/docs/) to install V2.
:::
[**OpenCode**](/) is an open source AI coding agent. It's available as a terminal-based interface, desktop app, or IDE extension.
![OpenCode TUI with the opencode theme](../../assets/lander/screenshot.png)
@@ -31,7 +36,8 @@ To use OpenCode in your terminal, you'll need:
## Install
The easiest way to install OpenCode is through the install script.
The easiest way to install OpenCode 1 is through the install script. These installation methods provide the `opencode`
binary and do not replace an `opencode2` installation.
```bash
curl -fsSL https://opencode.ai/install | bash
+7 -4
View File
@@ -8,6 +8,11 @@ description: "Get started with OpenCode."
wipe your data, things may break, and APIs, configuration, and plugin APIs may change.
</Callout>
<Callout type="note">
OpenCode 2 installs and runs as `opencode2`. It does not replace OpenCode 1's `opencode` binary, so you can keep both
versions installed and run them side by side.
</Callout>
## Install
<Callout type="note">The curl install script is not available in beta.</Callout>
@@ -37,10 +42,8 @@ You can also install it with the following package managers.
</Tab>
</Tabs>
The package uses a trusted postinstall script to select the native binary for your platform. The Bun and pnpm commands
above explicitly allow that script to run.
<Callout type="note">During beta, the binary is called `opencode2`.</Callout>
The package uses a trusted postinstall script to select the native `opencode2` binary for your platform. The Bun and pnpm
commands above explicitly allow that script to run.
### Homebrew
+5 -3
View File
@@ -3,6 +3,11 @@ title: "Migrate from V1"
description: "Move from OpenCode V1 to the OpenCode 2.0 beta."
---
<Callout type="note">
OpenCode 1 and OpenCode 2 can be installed side by side. V1 runs as `opencode`, while V2 installs and runs separately as
`opencode2`.
</Callout>
## Breaking changes
V2 has three intentional breaking changes:
@@ -27,9 +32,6 @@ an expected migration requirement.
continue to change.
</Callout>
During the beta, OpenCode V1 and V2 use different executable names. You can keep using `opencode` for V1 while trying V2
with `opencode2`.
## Install the beta
Install the beta from the `next` distribution tag: