feat(tui): add managed config interface

This commit is contained in:
Dax Raad 2026-07-12 16:54:08 -04:00
commit 3568dd1b99
52 changed files with 848 additions and 266 deletions

View file

@ -75,7 +75,7 @@ import * as Model from "./util/model"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config/v1"
import { TuiConfig, TuiConfigProvider, useTuiConfig } from "./config"
import { createTuiApiAdapters } from "./plugin/adapters"
import { createTuiApi } from "./plugin/api"
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
@ -144,7 +144,6 @@ const appBindingCommands = [
"app.toggle.file_context",
"app.toggle.diffwrap",
"app.toggle.paste_summary",
"app.toggle.session_directory_filter",
] as const
export type TuiInput = {
@ -154,7 +153,7 @@ export type TuiInput = {
reload?: () => Promise<void>
}
args: Args
config: TuiConfig.Resolved
config: TuiConfig.Interface
onSnapshot?: () => Promise<string[]>
pluginHost: TuiPluginHost
terminalHandoff?: () => Promise<
@ -203,6 +202,8 @@ function isVersionGreater(left: string, right: string) {
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const log = input.log ?? (() => {})
const global = yield* Global.Service
const configInfo = yield* Effect.tryPromise(() => input.config.get())
const config = TuiConfig.resolve(configInfo, { terminalSuspend: process.platform !== "win32" })
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
const api = OpenCode.make(options)
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
@ -235,7 +236,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse,
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && config.mouse,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
},
@ -263,7 +264,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
win32DisableProcessedInput()
const keymap = createDefaultOpenTuiKeymap(renderer)
yield* Effect.acquireRelease(
Effect.sync(() => registerOpencodeKeymap(keymap, renderer, input.config)),
Effect.sync(() => registerOpencodeKeymap(keymap, renderer, config)),
(unregister) => Effect.sync(unregister),
)
yield* Effect.addFinalizer(() =>
@ -337,19 +338,23 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<ClipboardProvider>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<TuiConfigProvider
config={config}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider
client={createOpencodeClient({ ...options, directory })}
@ -397,10 +402,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
</PermissionProvider>
</SDKProvider>
</PluginRuntimeProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</TuiConfigProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ClipboardProvider>
@ -1004,17 +1009,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
dialog.clear()
},
},
{
name: "app.toggle.session_directory_filter",
title: kv.get("session_directory_filter_enabled", true)
? "Disable session directory filtering"
: "Enable session directory filtering",
category: "System",
run: async () => {
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
dialog.clear()
},
},
{
name: "permission.mode",
title:

View file

@ -10,7 +10,7 @@ import type {
TuiAttentionSoundPack,
TuiAttentionSoundPackInfo,
} from "@opencode-ai/plugin/tui"
import { AttentionSoundName, type TuiConfig } from "./config/v1"
import { AttentionSoundName, type TuiConfig } from "./config"
import { Schema } from "effect"
import stripAnsi from "strip-ansi"
import * as TuiAudio from "./audio"

View file

@ -8,7 +8,7 @@ import {
useKeymapSelector,
useOpencodeKeymap,
} from "../keymap"
import { useTuiConfig } from "../config/v1"
import { useTuiConfig } from "../config"
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]

View file

@ -9,7 +9,7 @@ import type { McpServer } from "@opencode-ai/client"
import { useClipboard } from "../context/clipboard"
import { useToast } from "../ui/toast"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useTuiConfig } from "../config/v1"
import { useTuiConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
import { useBindings } from "../keymap"

View file

@ -1,7 +1,7 @@
import { InputRenderable, TextAttributes } from "@opentui/core"
import { Slug } from "@opencode-ai/core/util/slug"
import { createSignal, onMount } from "solid-js"
import { useTuiConfig } from "../config/v1"
import { useTuiConfig } from "../config"
import { useTheme } from "../context/theme"
import { useBindings, useCommandShortcut } from "../keymap"
import { useDialog, type DialogContext } from "../ui/dialog"

View file

@ -5,7 +5,7 @@ import { createMemo, For } from "solid-js"
import { createStore } from "solid-js/store"
import { FilePath } from "../ui/file-path"
import { useTheme } from "../context/theme"
import { useTuiConfig } from "../config/v1"
import { useTuiConfig } from "../config"
import { useDialog, type DialogContext } from "../ui/dialog"
import { getScrollAcceleration } from "../util/scroll"

View file

@ -11,7 +11,7 @@ import { useSDK } from "../../context/sdk"
import { useData } from "../../context/data"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiPaths } from "../../context/runtime"
import { useTuiConfig } from "../../config/v1"
import { useTuiConfig } from "../../config"
import { useLocation } from "../../context/location"
import { useTheme, selectedForeground } from "../../context/theme"
import { SplitBorder } from "../../ui/border"

View file

@ -48,7 +48,7 @@ import { createFadeIn } from "../../util/signal"
import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
import { useTuiConfig } from "../../config/v1"
import { useTuiConfig } from "../../config"
import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
@ -1321,7 +1321,7 @@ export function Prompt(props: PromptProps) {
}),
}
})
const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3)))
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48)))
return (

View file

@ -0,0 +1,216 @@
export * as TuiConfig from "."
import { createBindingLookup } from "@opentui/keymap/extras"
import { Schema } from "effect"
import { createContext, type JSX, useContext } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { TuiKeybind } from "./keybind"
export interface Interface {
readonly get: () => Promise<Info>
readonly update: (update: (draft: any) => void) => Promise<Info>
}
export const AttentionSoundName = Schema.Literals([
"default",
"question",
"permission",
"error",
"done",
"subagent_done",
])
export type AttentionSoundName = Schema.Schema.Type<typeof AttentionSoundName>
export type AttentionSoundPaths = Partial<Record<AttentionSoundName, string>>
export const Plugin = Schema.Union([
Schema.String,
Schema.Struct({
package: Schema.String.annotate({ description: "Plugin package name or path" }),
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)).annotate({
description: "Options passed to the plugin",
}),
}),
])
export const Info = Schema.Struct({
theme: Schema.optional(
Schema.Struct({
name: Schema.optional(Schema.String).annotate({ description: "Theme name" }),
mode: Schema.optional(Schema.Literals(["system", "dark", "light"])).annotate({
description: "Color mode; 'system' follows the terminal",
}),
}),
).annotate({ description: "Color theme settings" }),
keybinds: Schema.optional(TuiKeybind.KeybindOverrides).annotate({ description: "Custom key bindings" }),
plugins: Schema.optional(Schema.Array(Plugin)).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),
leader: Schema.optional(
Schema.Struct({
timeout: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))).annotate({
description: "Time in milliseconds to wait for a key after the leader key",
}),
}),
).annotate({ description: "Leader key behavior" }),
scroll: Schema.optional(
Schema.Struct({
speed: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))).annotate({
description: "Distance scrolled per input tick",
}),
acceleration: Schema.optional(Schema.Boolean).annotate({
description: "Accelerate scrolling from repeated input",
}),
}),
).annotate({ description: "Scrolling behavior" }),
attention: Schema.optional(
Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable attention alerts" }),
notifications: Schema.optional(Schema.Boolean).annotate({ description: "Show system notifications" }),
sound: Schema.optional(Schema.Boolean).annotate({ description: "Play attention sounds" }),
volume: Schema.optional(
Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1)),
).annotate({ description: "Attention sound volume from 0 to 1" }),
sound_pack: Schema.optional(Schema.String).annotate({ description: "Active attention sound pack ID" }),
sounds: Schema.optional(
Schema.Record(
AttentionSoundName,
Schema.optionalKey(Schema.String),
),
).annotate({ description: "Sound file overrides by attention event" }),
}),
).annotate({ description: "System notification and sound settings" }),
diffs: Schema.optional(
Schema.Struct({
wrap: Schema.optional(Schema.Literals(["word", "none"])).annotate({
description: "Line wrapping behavior in diff output",
}),
tree: Schema.optional(Schema.Boolean).annotate({ description: "Show the diff file tree" }),
single: Schema.optional(Schema.Boolean).annotate({ description: "Show only the selected file patch" }),
view: Schema.optional(Schema.Literals(["auto", "split", "unified"])).annotate({
description: "Diff layout; 'auto' selects a layout from the available width",
}),
}),
).annotate({ description: "Diff presentation settings" }),
terminal: Schema.optional(
Schema.Struct({
title: Schema.optional(Schema.Boolean).annotate({ description: "Update the terminal window title" }),
}),
).annotate({ description: "Terminal integration settings" }),
prompt: Schema.optional(
Schema.Struct({
editor: Schema.optional(Schema.Boolean).annotate({
description: "Include the active editor file or selection as prompt context",
}),
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
description: "Display large pastes as compact placeholders or full text",
}),
}),
).annotate({ description: "Prompt input behavior" }),
session: Schema.optional(
Schema.Struct({
sidebar: Schema.optional(Schema.Literals(["auto", "hide"])).annotate({
description: "Session sidebar visibility; 'auto' shows it when space permits",
}),
scrollbar: Schema.optional(Schema.Boolean).annotate({ description: "Show the session transcript scrollbar" }),
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide model reasoning by default",
}),
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
description: "Group related transcript items automatically or render each item separately",
}),
}),
).annotate({ description: "Session transcript presentation settings" }),
hints: Schema.optional(
Schema.Struct({
tips: Schema.optional(Schema.Boolean).annotate({ description: "Show usage tips on the home screen" }),
onboarding: Schema.optional(Schema.Boolean).annotate({ description: "Show getting-started guidance" }),
}),
).annotate({ description: "In-product guidance settings" }),
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
})
export type Info = Schema.Schema.Type<typeof Info>
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"> & {
attention: {
enabled: boolean
notifications: boolean
sound: boolean
volume: number
sound_pack: string
sounds: AttentionSoundPaths
}
keybinds: TuiKeybind.BindingLookupView
leader: { timeout: number }
mouse: boolean
}
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds }
if (!options.terminalSuspend) {
keybinds.terminal_suspend = "none"
if (keybinds.input_undo === undefined) {
const inputUndo = TuiKeybind.defaultValue("input_undo")
keybinds.input_undo = ["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]
.filter((value, index, values) => values.indexOf(value) === index)
.join(",")
}
}
return {
...input,
attention: {
enabled: input.attention?.enabled ?? false,
notifications: input.attention?.notifications ?? true,
sound: input.attention?.sound ?? true,
volume: input.attention?.volume ?? 0.4,
sound_pack: input.attention?.sound_pack ?? "opencode.default",
sounds: input.attention?.sounds ?? {},
},
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(TuiKeybind.parse(keybinds)), {
commandMap: TuiKeybind.CommandMap,
bindingDefaults: TuiKeybind.bindingDefaults(),
}),
leader: { timeout: input.leader?.timeout ?? 2000 },
mouse: input.mouse ?? true,
}
}
const ConfigContext = createContext<{ config: Resolved; service?: Interface }>()
export function TuiConfigProvider(props: {
config: Resolved
service?: Interface
options?: { terminalSuspend: boolean }
children: JSX.Element
}) {
const [config, setConfig] = createStore(props.config)
const host = props.service
const service = host
? {
get: host.get,
update: async (update: (draft: any) => void) => {
const info = await host.update(update)
setConfig(reconcile(resolve(info, props.options ?? { terminalSuspend: true })))
return info
},
}
: undefined
return <ConfigContext.Provider value={{ config, service }}>{props.children}</ConfigContext.Provider>
}
export function useTuiConfig() {
const value = useContext(ConfigContext)
if (!value) throw new Error("TuiConfigProvider is missing")
return value.config
}
export function useTuiConfigOptional() {
return useContext(ConfigContext)?.config
}
export function useTuiConfigService() {
const value = useContext(ConfigContext)
if (!value?.service) throw new Error("TuiConfig service is missing")
return value.service
}

View file

@ -0,0 +1,2 @@
export * from "./v1/keybind"
export * as TuiKeybind from "./v1/keybind"

View file

@ -1,4 +1,5 @@
export * as TuiConfig from "."
export * as TuiConfigV1 from "."
import { createBindingLookup } from "@opentui/keymap/extras"
import { Schema } from "effect"

View file

@ -53,7 +53,6 @@ export const Definitions = {
app_toggle_file_context: keybind("none", "Toggle file context"),
app_toggle_diffwrap: keybind("none", "Toggle diff wrapping"),
app_toggle_paste_summary: keybind("none", "Toggle paste summary"),
app_toggle_session_directory_filter: keybind("none", "Toggle session directory filtering"),
command_list: keybind("ctrl+p", "List available commands"),
help_show: keybind("none", "Open help dialog"),
docs_open: keybind("none", "Open documentation"),
@ -257,7 +256,6 @@ export const CommandMap = {
app_toggle_file_context: "app.toggle.file_context",
app_toggle_diffwrap: "app.toggle.diffwrap",
app_toggle_paste_summary: "app.toggle.paste_summary",
app_toggle_session_directory_filter: "app.toggle.session_directory_filter",
command_list: "command.palette.show",
help_show: "help.show",
docs_open: "docs.open",

View file

@ -1,97 +0,0 @@
export * as TuiConfigV2 from "."
import { Schema } from "effect"
import { TuiKeybind } from "./keybind"
export const Plugin = Schema.Union([
Schema.String,
Schema.Struct({
package: Schema.String,
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
}),
])
export const Info = Schema.Struct({
theme: Schema.optional(
Schema.Struct({
name: Schema.optional(Schema.String),
mode: Schema.optional(Schema.Literals(["system", "dark", "light"])),
}),
),
keybinds: Schema.optional(TuiKeybind.KeybindOverrides),
plugins: Schema.optional(Schema.Array(Plugin)),
leader: Schema.optional(
Schema.Struct({
timeout: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
}),
),
scroll: Schema.optional(
Schema.Struct({
speed: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))),
acceleration: Schema.optional(Schema.Boolean),
}),
),
attention: Schema.optional(
Schema.Struct({
enabled: Schema.optional(Schema.Boolean),
notifications: Schema.optional(Schema.Boolean),
sound: Schema.optional(Schema.Boolean),
volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))),
sound_pack: Schema.optional(Schema.String),
sounds: Schema.optional(
Schema.Record(
Schema.Literals(["default", "question", "permission", "error", "done", "subagent_done"]),
Schema.optionalKey(Schema.String),
),
),
}),
),
diffs: Schema.optional(
Schema.Struct({
wrap: Schema.optional(Schema.Literals(["word", "none"])),
tree: Schema.optional(Schema.Boolean),
single: Schema.optional(Schema.Boolean),
view: Schema.optional(Schema.Literals(["auto", "split", "unified"])),
}),
),
terminal: Schema.optional(
Schema.Struct({
title: Schema.optional(Schema.Boolean),
}),
),
composer: Schema.optional(
Schema.Struct({
file_context: Schema.optional(Schema.Boolean),
paste_summary: Schema.optional(Schema.Boolean),
}),
),
session: Schema.optional(
Schema.Struct({
sidebar: Schema.optional(Schema.Literals(["auto", "hide"])),
scrollbar: Schema.optional(Schema.Boolean),
thinking: Schema.optional(Schema.Literals(["show", "hide"])),
group_exploration: Schema.optional(Schema.Boolean),
directory_filter: Schema.optional(Schema.Boolean),
}),
),
which_key: Schema.optional(
Schema.Struct({
layout: Schema.optional(Schema.Literals(["dock", "overlay"])),
pending_preview: Schema.optional(Schema.Boolean),
}),
),
hints: Schema.optional(
Schema.Struct({
tips: Schema.optional(Schema.Boolean),
getting_started: Schema.optional(Schema.Boolean),
}),
),
updates: Schema.optional(
Schema.Struct({
skipped: Schema.optional(Schema.String),
}),
),
animations: Schema.optional(Schema.Boolean),
mouse: Schema.optional(Schema.Boolean),
})
export type Info = Schema.Schema.Type<typeof Info>

View file

@ -1,6 +0,0 @@
export * as TuiKeybind from "./keybind"
import { Schema } from "effect"
export const KeybindOverrides = Schema.Struct({})
export type KeybindOverrides = Schema.Schema.Type<typeof KeybindOverrides>

View file

@ -1,4 +1,4 @@
import { createSignal, type Setter } from "solid-js"
import { createEffect, createSignal, type Setter } from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { Flock } from "@opencode-ai/core/util/flock"
@ -6,10 +6,12 @@ import { Global } from "@opencode-ai/core/global"
import { readJson, writeJsonAtomic } from "../util/persistence"
import { useTuiPaths } from "./runtime"
import path from "path"
import { useTuiConfigOptional, type TuiConfig } from "../config"
export const { use: useKV, provider: KVProvider } = createSimpleContext({
name: "KV",
init: () => {
init: (props: { config?: TuiConfig.Info }) => {
const config = props.config ?? useTuiConfigOptional()
const paths = useTuiPaths()
void Global.Path.state
const file = path.join(paths.state, "kv.json")
@ -21,7 +23,12 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
Flock.withLock(lock, () => readJson<Record<string, unknown>>(file))
.then((x) => {
setStore(x)
const values: Record<string, any> = { ...x }
Object.entries(configValues(config ?? {})).forEach(([key, value]) => {
if (value === undefined) delete values[key]
else values[key] = value
})
setStore(values)
})
.catch((error) => {
console.error("Failed to read KV state", { error })
@ -30,6 +37,14 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
setReady(true)
})
createEffect(() => {
if (!ready() || !config) return
Object.entries(configValues(config)).forEach(([key, value]) => {
if (value === undefined) setStore(key, undefined)
else setStore(key, value)
})
})
const result = {
get ready() {
return ready()
@ -64,3 +79,29 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
return result
},
})
function configValues(config: TuiConfig.Info) {
const values: Record<string, any> = {}
if (config.theme?.name !== undefined) values.theme = config.theme.name
if (config.theme?.mode !== undefined) {
values.theme_mode_lock = config.theme.mode === "system" ? undefined : config.theme.mode
values.theme_mode = undefined
}
if (config.attention?.sound_pack !== undefined) values.attention_sound_pack = config.attention.sound_pack
if (config.diffs?.wrap !== undefined) values.diff_wrap_mode = config.diffs.wrap
if (config.diffs?.tree !== undefined) values.diff_viewer_show_file_tree = config.diffs.tree
if (config.diffs?.single !== undefined) values.diff_viewer_single_patch = config.diffs.single
if (config.diffs?.view !== undefined)
values.diff_viewer_view = config.diffs.view === "auto" ? undefined : config.diffs.view
if (config.terminal?.title !== undefined) values.terminal_title_enabled = config.terminal.title
if (config.prompt?.editor !== undefined) values.file_context_enabled = config.prompt.editor
if (config.prompt?.paste !== undefined) values.paste_summary_enabled = config.prompt.paste === "compact"
if (config.session?.sidebar !== undefined) values.sidebar = config.session.sidebar
if (config.session?.scrollbar !== undefined) values.scrollbar_visible = config.session.scrollbar
if (config.session?.thinking !== undefined) values.thinking_mode = config.session.thinking
if (config.session?.grouping !== undefined) values.exploration_grouping = config.session.grouping === "auto"
if (config.hints?.tips !== undefined) values.tips_hidden = !config.hints.tips
if (config.hints?.onboarding !== undefined) values.dismissed_getting_started = !config.hints.onboarding
if (config.animations !== undefined) values.animations_enabled = config.animations
return values
}

View file

@ -23,7 +23,7 @@ import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useKV } from "./kv"
import { useTuiConfig } from "../config/v1"
import { useTuiConfig } from "../config"
import { Global } from "@opencode-ai/core/global"
import { Glob } from "@opencode-ai/core/util/glob"
import { readFile } from "node:fs/promises"
@ -118,14 +118,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (!lock && pick(kv.get("theme_mode")) !== undefined) kv.set("theme_mode", undefined)
draft.mode = mode
draft.lock = lock
const active = config.theme ?? kv.get("theme", "opencode")
const active = config.theme?.name ?? kv.get("theme", "opencode")
draft.active = typeof active === "string" ? active : "opencode"
draft.ready = false
}),
)
createEffect(() => {
const theme = config.theme
const theme = config.theme?.name
if (theme) setStore("active", theme)
})

View file

@ -269,7 +269,7 @@ const TIPS: Tip[] = [
(shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`,
(shortcuts) => press(shortcuts.messagesToggleConceal(), "to toggle code block visibility in messages"),
(shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`,
"Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth scrolling",
"Enable {highlight}scroll.acceleration{/highlight} in {highlight}cli.json{/highlight} for smooth scrolling",
(shortcuts) =>
shortcuts.commandList()
? `Toggle username display in chat via the command palette (${shortcutText(shortcuts.commandList())})`

View file

@ -144,7 +144,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : []))
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
const defaultView = createMemo(() => {
if (props.api.tuiConfig.diff_style === "stacked") return "unified"
if (props.api.tuiConfig.diffs?.view === "unified") return "unified"
if (props.api.tuiConfig.diffs?.view === "split") return "split"
return splitAvailable() ? "split" : "unified"
})
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))

View file

@ -14,8 +14,8 @@ import {
} from "@opentui/keymap/extras"
import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid"
import { createMemo, type Accessor } from "solid-js"
import { useTuiConfig } from "./config/v1"
import { TuiKeybind } from "./config/v1/keybind"
import { useTuiConfig } from "./config"
import { TuiKeybind } from "./config/keybind"
export const LEADER_TOKEN = "leader"
export const OPENCODE_BASE_MODE = "base"
@ -42,7 +42,7 @@ type BindingLookup = {
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
}
type FormatConfig = { keybinds: BindingLookup }
type ResolvedKeymapConfig = FormatConfig & { leader_timeout: number }
type ResolvedKeymapConfig = FormatConfig & ({ leader: { timeout: number } } | { leader_timeout: number })
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
@ -225,7 +225,7 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende
? registerTimedLeader(keymap, {
trigger: leader,
name: LEADER_TOKEN,
timeoutMs: config.leader_timeout,
timeoutMs: "leader" in config ? config.leader.timeout : config.leader_timeout,
})
: () => {}
const offEscape = registerEscapeClearsPendingSequence(keymap)

View file

@ -1,5 +1,5 @@
import type { TuiDialogSelectOption, TuiPluginApi, TuiSlotProps } from "@opencode-ai/plugin/tui"
import type { TuiConfig } from "../config/v1"
import type { TuiConfig } from "../config"
import type { useEvent } from "../context/event"
import type { useRoute } from "../context/route"
import type { useSDK } from "../context/sdk"

View file

@ -1,6 +1,6 @@
// Legacy `api.command` bridge for v1 plugins; remove in v2.
import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { TuiKeybind } from "../config/v1/keybind"
import { TuiKeybind } from "../config/keybind"
import type { DialogContext } from "../ui/dialog"
const COMMAND_PALETTE_SHOW = "command.palette.show"

View file

@ -4,7 +4,7 @@ import type {
TuiPluginInstallResult,
TuiPluginStatus,
} from "@opencode-ai/plugin/tui"
import type { TuiConfig } from "../config/v1"
import type { TuiConfig } from "../config"
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
import { createPluginRoutes } from "./api"
import { createSlots, type HostSlots } from "./slots"

View file

@ -8,8 +8,6 @@ import { usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local"
import { usePluginRuntime } from "../plugin/runtime"
import { useEditorContext } from "../context/editor"
import { useTerminalDimensions } from "@opentui/solid"
import { useTuiConfig } from "../config/v1"
import { HomeSessionDestinationProvider } from "./home/session-destination"
import { useData } from "../context/data"
import { LocationProvider } from "../context/location"
@ -29,16 +27,9 @@ export function Home() {
const args = useArgs()
const local = useLocal()
const editor = useEditorContext()
const dimensions = useTerminalDimensions()
const tuiConfig = useTuiConfig()
const data = useData()
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? [])
const promptMaxWidth = createMemo(() => {
const configured = tuiConfig.prompt?.max_width
if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7))
return configured ?? 75
})
let sent = false
onMount(() => {
@ -83,7 +74,7 @@ export function Home() {
</pluginRuntime.Slot>
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={promptMaxWidth()} zIndex={1000} paddingTop={1} flexShrink={0}>
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
<Prompt
ref={bind}

View file

@ -10,7 +10,7 @@ import { useSDK } from "../../context/sdk"
import { useClipboard } from "../../context/clipboard"
import { SplitBorder } from "../../ui/border"
import { useToast } from "../../ui/toast"
import { useTuiConfig } from "../../config/v1"
import { useTuiConfig } from "../../config"
import { useBindings, useOpencodeModeStack } from "../../keymap"
const FORM_MODE = "form"

View file

@ -64,7 +64,7 @@ import { FormPrompt } from "./form"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { DialogExportResult } from "../../ui/dialog-export-result"
import { sessionEpilogue } from "../../util/presentation"
import { useTuiConfig } from "../../config/v1"
import { useTuiConfig } from "../../config"
import { useClipboard } from "../../context/clipboard"
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
@ -2465,8 +2465,9 @@ function Edit(props: ToolProps) {
const pathFormatter = usePathFormatter()
const view = createMemo(() => {
const diffStyle = ctx.tui.diff_style
if (diffStyle === "stacked") return "unified"
const diffView = ctx.tui.diffs?.view
if (diffView === "unified") return "unified"
if (diffView === "split") return "split"
// Default to "auto" behavior
return ctx.width > 120 ? "split" : "unified"
})
@ -2541,7 +2542,8 @@ function ApplyPatch(props: ToolProps) {
})
})
const view = createMemo(() => {
if (ctx.tui.diff_style === "stacked") return "unified"
if (ctx.tui.diffs?.view === "unified") return "unified"
if (ctx.tui.diffs?.view === "split") return "split"
return ctx.width > 120 ? "split" : "unified"
})

View file

@ -12,7 +12,7 @@ import { filetype } from "../../util/filetype"
import { Locale } from "../../util/locale"
import { webSearchProviderLabel } from "../../util/tool-display"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../config/v1"
import { useTuiConfig } from "../../config"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
@ -34,8 +34,9 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
})
const view = createMemo(() => {
const diffStyle = config.diff_style
if (diffStyle === "stacked") return "unified"
const diffView = config.diffs?.view
if (diffView === "unified") return "unified"
if (diffView === "split") return "split"
return dimensions().width > 120 ? "split" : "unified"
})

View file

@ -1,7 +1,7 @@
import { useData } from "../../context/data"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../config/v1"
import { useTuiConfig } from "../../config"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { usePluginRuntime } from "../../plugin/runtime"

View file

@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner"
import { useTuiConfig } from "../config/v1"
import { useTuiConfig } from "../config"
import { useBindings, useCommandShortcut } from "../keymap"
export type DialogPromptProps = {

View file

@ -17,7 +17,7 @@ import { isDeepEqual } from "remeda"
import { useDialog, type DialogContext } from "./dialog"
import { Locale } from "../util/locale"
import { getScrollAcceleration } from "../util/scroll"
import { useTuiConfig } from "../config/v1"
import { useTuiConfig } from "../config"
import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
export interface DialogSelectProps<T> {

View file

@ -1,8 +1,10 @@
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
export type ScrollConfig = {
scroll_acceleration?: { enabled?: boolean }
scroll_speed?: number
scroll?: {
acceleration?: boolean
speed?: number
}
}
export class CustomSpeedScroll implements ScrollAcceleration {
@ -16,11 +18,11 @@ export class CustomSpeedScroll implements ScrollAcceleration {
}
export function getScrollAcceleration(tuiConfig?: ScrollConfig): ScrollAcceleration {
if (tuiConfig?.scroll_acceleration?.enabled) {
if (tuiConfig?.scroll?.acceleration) {
return new MacOSScrollAccel()
}
if (tuiConfig?.scroll_speed !== undefined) {
return new CustomSpeedScroll(tuiConfig.scroll_speed)
if (tuiConfig?.scroll?.speed !== undefined) {
return new CustomSpeedScroll(tuiConfig.scroll.speed)
}
return new CustomSpeedScroll(3)