feat(tui): add managed config interface
This commit is contained in:
parent
1e17202413
commit
3568dd1b99
52 changed files with 848 additions and 266 deletions
216
packages/tui/src/config/index.tsx
Normal file
216
packages/tui/src/config/index.tsx
Normal 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
|
||||
}
|
||||
2
packages/tui/src/config/keybind.ts
Normal file
2
packages/tui/src/config/keybind.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./v1/keybind"
|
||||
export * as TuiKeybind from "./v1/keybind"
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export * as TuiConfig from "."
|
||||
export * as TuiConfigV1 from "."
|
||||
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { Schema } from "effect"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue