feat(tui): add managed config interface
This commit is contained in:
@@ -115,6 +115,7 @@
|
|||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"opentui-spinner": "catalog:",
|
"opentui-spinner": "catalog:",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"opentui-spinner": "catalog:",
|
"opentui-spinner": "catalog:",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
|
|||||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { TuiConfig } from "../../tui-config"
|
import { Config } from "../../config"
|
||||||
import { Effect, Option } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { Server } from "../../services/server"
|
import { Server } from "../../services/server"
|
||||||
import { Updater } from "../../services/updater"
|
import { Updater } from "../../services/updater"
|
||||||
@@ -35,13 +35,16 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
preflight.loading()
|
preflight.loading()
|
||||||
const config = yield* TuiConfig.load()
|
const configService = yield* Config.Service
|
||||||
let disposeSlots: (() => void) | undefined
|
let disposeSlots: (() => void) | undefined
|
||||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||||
yield* run({
|
yield* run({
|
||||||
server,
|
server,
|
||||||
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||||
config,
|
config: {
|
||||||
|
get: () => Effect.runPromise(configService.get()),
|
||||||
|
update: (update) => Effect.runPromise(configService.update(update)),
|
||||||
|
},
|
||||||
terminalHandoff: () => preflight.finish(),
|
terminalHandoff: () => preflight.finish(),
|
||||||
log: (level, message, tags) => {
|
log: (level, message, tags) => {
|
||||||
const effect =
|
const effect =
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
export * as Config from "./config"
|
||||||
|
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
|
||||||
|
import { produce, type Draft } from "immer"
|
||||||
|
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||||
|
import path from "path"
|
||||||
|
import { ConfigMigration } from "./migrate"
|
||||||
|
import { Info } from "./schema"
|
||||||
|
|
||||||
|
export * from "./schema"
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly path: string
|
||||||
|
readonly get: () => Effect.Effect<Info>
|
||||||
|
readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
|
||||||
|
|
||||||
|
const decode = Schema.decodeUnknownOption(Info)
|
||||||
|
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
|
||||||
|
const empty: Info = {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
const global = yield* Global.Service
|
||||||
|
const file = path.join(global.config, "cli.json")
|
||||||
|
const lock = yield* Semaphore.make(1)
|
||||||
|
|
||||||
|
const readJson = Effect.fnUntraced(function* () {
|
||||||
|
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
if (text === undefined) return undefined
|
||||||
|
const errors: ParseError[] = []
|
||||||
|
const value: any = parse(text, errors, { allowTrailingComma: true })
|
||||||
|
if (errors.length) return undefined
|
||||||
|
return Option.getOrUndefined(decodeRecord(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const write = Effect.fnUntraced(function* (text: string) {
|
||||||
|
const temp = file + ".tmp"
|
||||||
|
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
|
||||||
|
yield* fs.writeFileString(temp, text, { mode: 0o600 })
|
||||||
|
yield* fs.rename(temp, file)
|
||||||
|
})
|
||||||
|
|
||||||
|
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
|
||||||
|
Effect.provideService(FileSystem.FileSystem, fs),
|
||||||
|
)
|
||||||
|
|
||||||
|
const get = Effect.fn("cli.config.get")(function* () {
|
||||||
|
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
|
||||||
|
return Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
|
||||||
|
lock
|
||||||
|
.withPermits(1)(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* migrate
|
||||||
|
const current = Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||||
|
const next = produce(current, update)
|
||||||
|
const edits = changes(current, next)
|
||||||
|
if (!edits.length) return current
|
||||||
|
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
|
||||||
|
const updated = edits.reduce(
|
||||||
|
(text, edit) =>
|
||||||
|
applyEdits(
|
||||||
|
text,
|
||||||
|
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||||
|
),
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
const errors: ParseError[] = []
|
||||||
|
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
|
||||||
|
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
|
||||||
|
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
|
||||||
|
return config
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Service.of({ path: file, get, update })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Edit = { readonly path: (string | number)[]; readonly value: any }
|
||||||
|
|
||||||
|
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
|
||||||
|
if (Object.is(before, after)) return []
|
||||||
|
if (
|
||||||
|
before !== null &&
|
||||||
|
after !== null &&
|
||||||
|
typeof before === "object" &&
|
||||||
|
typeof after === "object" &&
|
||||||
|
!Array.isArray(before) &&
|
||||||
|
!Array.isArray(after)
|
||||||
|
) {
|
||||||
|
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
|
||||||
|
if (!(key in after)) return [{ path: [...path, key], value: undefined }]
|
||||||
|
if (!(key in before)) return [{ path: [...path, key], value: after[key] }]
|
||||||
|
return changes(before[key], after[key], [...path, key])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return [{ path, value: after }]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * as Config from "./config"
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
export * as ConfigMigration from "./migrate"
|
||||||
|
|
||||||
|
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
|
||||||
|
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||||
|
import { parse, type ParseError } from "jsonc-parser"
|
||||||
|
import path from "path"
|
||||||
|
import type { Info } from "./schema"
|
||||||
|
|
||||||
|
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
|
||||||
|
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
|
||||||
|
|
||||||
|
export const run = Effect.fn("cli.config.migrate")(function* (input: {
|
||||||
|
readonly file: string
|
||||||
|
readonly config: string
|
||||||
|
readonly state: string
|
||||||
|
}) {
|
||||||
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
|
||||||
|
|
||||||
|
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
|
||||||
|
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
|
||||||
|
const kv = yield* readJson(path.join(input.state, "kv.json"))
|
||||||
|
const migrated = migrateV1(legacy, kv ?? {})
|
||||||
|
if (!Object.keys(migrated).length) return
|
||||||
|
|
||||||
|
const temp = input.file + ".tmp"
|
||||||
|
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
|
||||||
|
yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
|
||||||
|
yield* fs.rename(temp, input.file)
|
||||||
|
yield* Effect.logInfo("migrated cli config", {
|
||||||
|
from: [
|
||||||
|
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
|
||||||
|
kv === undefined ? undefined : path.join(input.state, "kv.json"),
|
||||||
|
].filter(Boolean),
|
||||||
|
to: input.file,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
|
||||||
|
const plugins = [
|
||||||
|
...(legacy?.plugin?.map((plugin) =>
|
||||||
|
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||||
|
) ?? []),
|
||||||
|
...Object.entries(legacy?.plugin_enabled ?? {}).map(([id, enabled]) => (enabled ? id : `-${id}`)),
|
||||||
|
]
|
||||||
|
const themeName = legacy?.theme ?? kv.theme
|
||||||
|
const themeMode = kv.theme_mode_lock
|
||||||
|
const attentionSoundPack = kv.attention_sound_pack
|
||||||
|
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
|
||||||
|
const thinking =
|
||||||
|
kv.thinking_mode ??
|
||||||
|
(kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(themeName !== undefined || themeMode !== undefined
|
||||||
|
? { theme: { ...(themeName === undefined ? {} : { name: themeName }), ...(themeMode === undefined ? {} : { mode: themeMode }) } }
|
||||||
|
: {}),
|
||||||
|
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
|
||||||
|
...(plugins.length ? { plugins } : {}),
|
||||||
|
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
|
||||||
|
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
scroll: {
|
||||||
|
...(legacy.scroll_speed === undefined ? {} : { speed: legacy.scroll_speed }),
|
||||||
|
...(legacy.scroll_acceleration?.enabled === undefined
|
||||||
|
? {}
|
||||||
|
: { acceleration: legacy.scroll_acceleration.enabled }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(legacy?.attention === undefined && attentionSoundPack === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
attention: {
|
||||||
|
...legacy?.attention,
|
||||||
|
...(attentionSoundPack === undefined ? {} : { sound_pack: attentionSoundPack }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(legacy?.diff_style === undefined &&
|
||||||
|
kv.diff_wrap_mode === undefined &&
|
||||||
|
kv.diff_viewer_show_file_tree === undefined &&
|
||||||
|
kv.diff_viewer_single_patch === undefined &&
|
||||||
|
diffView === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
diffs: {
|
||||||
|
...(kv.diff_wrap_mode === undefined ? {} : { wrap: kv.diff_wrap_mode }),
|
||||||
|
...(kv.diff_viewer_show_file_tree === undefined ? {} : { tree: kv.diff_viewer_show_file_tree }),
|
||||||
|
...(kv.diff_viewer_single_patch === undefined ? {} : { single: kv.diff_viewer_single_patch }),
|
||||||
|
...(diffView === undefined ? {} : { view: diffView }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(kv.terminal_title_enabled === undefined ? {} : { terminal: { title: kv.terminal_title_enabled } }),
|
||||||
|
...(kv.file_context_enabled === undefined && kv.paste_summary_enabled === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
prompt: {
|
||||||
|
...(kv.file_context_enabled === undefined ? {} : { editor: kv.file_context_enabled }),
|
||||||
|
...(kv.paste_summary_enabled === undefined
|
||||||
|
? {}
|
||||||
|
: { paste: kv.paste_summary_enabled ? ("compact" as const) : ("full" as const) }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(kv.sidebar === undefined &&
|
||||||
|
kv.scrollbar_visible === undefined &&
|
||||||
|
thinking === undefined &&
|
||||||
|
kv.exploration_grouping === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
session: {
|
||||||
|
...(kv.sidebar === undefined ? {} : { sidebar: kv.sidebar }),
|
||||||
|
...(kv.scrollbar_visible === undefined ? {} : { scrollbar: kv.scrollbar_visible }),
|
||||||
|
...(thinking === undefined ? {} : { thinking }),
|
||||||
|
...(kv.exploration_grouping === undefined
|
||||||
|
? {}
|
||||||
|
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(kv.tips_hidden === undefined && kv.dismissed_getting_started === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
hints: {
|
||||||
|
...(kv.tips_hidden === undefined ? {} : { tips: !kv.tips_hidden }),
|
||||||
|
...(kv.dismissed_getting_started === undefined
|
||||||
|
? {}
|
||||||
|
: { onboarding: !kv.dismissed_getting_started }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
|
||||||
|
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readJson = Effect.fnUntraced(function* (target: string) {
|
||||||
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
const text = yield* fs.readFileString(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
if (text === undefined) return undefined
|
||||||
|
const errors: ParseError[] = []
|
||||||
|
const value: any = parse(text, errors, { allowTrailingComma: true })
|
||||||
|
if (errors.length) return undefined
|
||||||
|
return Option.getOrUndefined(decodeRecord(value))
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||||
|
import { Schema } from "effect"
|
||||||
|
|
||||||
|
export const Info = Schema.Struct({ ...TuiConfig.Info.fields })
|
||||||
|
export type Info = Schema.Schema.Type<typeof Info>
|
||||||
@@ -3,6 +3,7 @@ import { Command } from "effect/unstable/cli"
|
|||||||
import { Spec } from "./spec"
|
import { Spec } from "./spec"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Updater } from "../services/updater"
|
import { Updater } from "../services/updater"
|
||||||
|
import { Config } from "../config"
|
||||||
|
|
||||||
export type Input<Value> =
|
export type Input<Value> =
|
||||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||||
@@ -13,18 +14,26 @@ export type Input<Value> =
|
|||||||
|
|
||||||
type RuntimeHandler = (
|
type RuntimeHandler = (
|
||||||
input: unknown,
|
input: unknown,
|
||||||
) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
) => Effect.Effect<
|
||||||
|
void,
|
||||||
|
unknown,
|
||||||
|
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
|
||||||
|
>
|
||||||
type Loader<Node extends Spec.Any> = () => Promise<{
|
type Loader<Node extends Spec.Any> = () => Promise<{
|
||||||
default: (
|
default: (
|
||||||
input: Input<Node>,
|
input: Input<Node>,
|
||||||
) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
) => Effect.Effect<
|
||||||
|
void,
|
||||||
|
any,
|
||||||
|
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
|
||||||
|
>
|
||||||
}>
|
}>
|
||||||
type ProvidedCommand = Command.Command<
|
type ProvidedCommand = Command.Command<
|
||||||
string,
|
string,
|
||||||
unknown,
|
unknown,
|
||||||
unknown,
|
unknown,
|
||||||
unknown,
|
unknown,
|
||||||
FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope
|
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
|
||||||
>
|
>
|
||||||
|
|
||||||
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { AppProcess } from "@opencode-ai/core/process"
|
import { AppProcess } from "@opencode-ai/core/process"
|
||||||
|
import { Config } from "./config"
|
||||||
|
|
||||||
const Handlers = Runtime.handlers(Commands, {
|
const Handlers = Runtime.handlers(Commands, {
|
||||||
$: () => import("./commands/handlers/default"),
|
$: () => import("./commands/handlers/default"),
|
||||||
@@ -51,6 +52,7 @@ Effect.logInfo("cli starting", {
|
|||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
||||||
Effect.annotateLogs({ role: "cli" }),
|
Effect.annotateLogs({ role: "cli" }),
|
||||||
|
Effect.provide(Config.layer),
|
||||||
Effect.provide(Updater.layer),
|
Effect.provide(Updater.layer),
|
||||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
||||||
Effect.provide(Observability.layer),
|
Effect.provide(Observability.layer),
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
export * as TuiConfig from "./tui-config"
|
|
||||||
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { TuiConfig } from "@opencode-ai/tui/config/v1"
|
|
||||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
|
||||||
import { parse, type ParseError } from "jsonc-parser"
|
|
||||||
import path from "path"
|
|
||||||
|
|
||||||
export const load = Effect.fn("TuiConfig.load")(function* () {
|
|
||||||
const fs = yield* FileSystem.FileSystem
|
|
||||||
const global = yield* Global.Service
|
|
||||||
const filepath = path.join(global.config, "tui.json")
|
|
||||||
const text = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
if (!text) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
|
|
||||||
|
|
||||||
const errors: ParseError[] = []
|
|
||||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
|
||||||
if (errors.length) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
|
|
||||||
|
|
||||||
return TuiConfig.resolve(
|
|
||||||
Option.getOrElse(Schema.decodeUnknownOption(TuiConfig.Info)(input), () => ({})),
|
|
||||||
{ terminalSuspend: process.platform !== "win32" },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import { Config } from "../src/config"
|
||||||
|
|
||||||
|
function run<A, E>(directory: string, effect: Effect.Effect<A, E, Config.Service>) {
|
||||||
|
return Effect.runPromise(
|
||||||
|
effect.pipe(
|
||||||
|
Effect.provide(Config.layer),
|
||||||
|
Effect.provide(Global.layerWith({ config: directory, state: directory })),
|
||||||
|
Effect.provide(NodeFileSystem.layer),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("migrates tui and kv config into cli.json", async () => {
|
||||||
|
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||||
|
await Bun.write(
|
||||||
|
path.join(directory, "tui.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
theme: "legacy",
|
||||||
|
keybinds: { leader: "ctrl+o" },
|
||||||
|
plugin: [["example", { mode: "safe" }]],
|
||||||
|
plugin_enabled: { disabled: false },
|
||||||
|
leader_timeout: 500,
|
||||||
|
scroll_speed: 2,
|
||||||
|
scroll_acceleration: { enabled: true },
|
||||||
|
diff_style: "stacked",
|
||||||
|
mouse: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(directory, "kv.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
theme_mode_lock: "light",
|
||||||
|
paste_summary_enabled: false,
|
||||||
|
exploration_grouping: false,
|
||||||
|
tips_hidden: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await run(
|
||||||
|
directory,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const service = yield* Config.Service
|
||||||
|
return yield* service.get()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(config).toMatchObject({
|
||||||
|
theme: { name: "legacy", mode: "light" },
|
||||||
|
keybinds: { leader: "ctrl+o" },
|
||||||
|
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
|
||||||
|
leader: { timeout: 500 },
|
||||||
|
scroll: { speed: 2, acceleration: true },
|
||||||
|
diffs: { view: "unified" },
|
||||||
|
prompt: { paste: "full" },
|
||||||
|
session: { grouping: "none" },
|
||||||
|
hints: { tips: false },
|
||||||
|
mouse: false,
|
||||||
|
})
|
||||||
|
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
||||||
|
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
||||||
|
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
||||||
|
expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true)
|
||||||
|
} finally {
|
||||||
|
await Bun.$`rm -rf ${directory}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("migrates before the first update and does not remigrate afterward", async () => {
|
||||||
|
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||||
|
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "legacy" }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await run(
|
||||||
|
directory,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const service = yield* Config.Service
|
||||||
|
yield* service.update((draft) => {
|
||||||
|
draft.animations = false
|
||||||
|
draft.mouse = false
|
||||||
|
})
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "changed" })),
|
||||||
|
)
|
||||||
|
return yield* service.get()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(config).toEqual({ theme: { name: "legacy" }, animations: false, mouse: false })
|
||||||
|
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual({
|
||||||
|
theme: { name: "legacy" },
|
||||||
|
animations: false,
|
||||||
|
mouse: false,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await Bun.$`rm -rf ${directory}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("updates a config draft while preserving JSONC comments", async () => {
|
||||||
|
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||||
|
await Bun.write(path.join(directory, "cli.json"), "{\n // Keep this comment\n \"animations\": true\n}\n")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await run(
|
||||||
|
directory,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const service = yield* Config.Service
|
||||||
|
return yield* service.update((draft) => {
|
||||||
|
draft.prompt = { paste: "compact" }
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(config).toEqual({ animations: true, prompt: { paste: "compact" } })
|
||||||
|
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
|
||||||
|
} finally {
|
||||||
|
await Bun.$`rm -rf ${directory}`
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { expect, test } from "bun:test"
|
|
||||||
import path from "path"
|
|
||||||
import { TuiConfig } from "../src/tui-config"
|
|
||||||
|
|
||||||
test("loads the global tui config", async () => {
|
|
||||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
|
||||||
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ keybinds: { leader: "ctrl+o" } }))
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config = await Effect.runPromise(
|
|
||||||
TuiConfig.load().pipe(
|
|
||||||
Effect.provide(Global.layerWith({ config: directory })),
|
|
||||||
Effect.provide(NodeFileSystem.layer),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o")
|
|
||||||
expect(config.keybinds.get("session.new")?.[0]?.key).toBe("<leader>n")
|
|
||||||
} finally {
|
|
||||||
await Bun.$`rm -rf ${directory}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
type SequenceBindingLike,
|
type SequenceBindingLike,
|
||||||
} from "@opentui/keymap/extras"
|
} from "@opentui/keymap/extras"
|
||||||
import type { JSX, SolidPlugin } from "@opentui/solid"
|
import type { JSX, SolidPlugin } from "@opentui/solid"
|
||||||
import type { Config as PluginConfig, PluginOptions } from "./index.js"
|
import type { PluginOptions } from "./index.js"
|
||||||
|
|
||||||
export type { CliRenderer, KeyEvent, Renderable, SlotMode } from "@opentui/core"
|
export type { CliRenderer, KeyEvent, Renderable, SlotMode } from "@opentui/core"
|
||||||
export { stringifyKeySequence, stringifyKeyStroke } from "@opentui/keymap"
|
export { stringifyKeySequence, stringifyKeyStroke } from "@opentui/keymap"
|
||||||
@@ -407,13 +407,34 @@ type TuiAttentionConfigView = {
|
|||||||
sounds: Partial<Record<TuiAttentionSoundName, string>>
|
sounds: Partial<Record<TuiAttentionSoundName, string>>
|
||||||
}
|
}
|
||||||
|
|
||||||
type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "plugin"> &
|
type TuiConfigView = {
|
||||||
NonNullable<PluginConfig["tui"]> & {
|
theme?: {
|
||||||
leader_timeout: number
|
name?: string
|
||||||
attention: TuiAttentionConfigView
|
mode?: "system" | "dark" | "light"
|
||||||
plugin_enabled?: Record<string, boolean>
|
|
||||||
keybinds: TuiBindingLookupView
|
|
||||||
}
|
}
|
||||||
|
plugins?: ReadonlyArray<string | { package: string; options?: Record<string, any> }>
|
||||||
|
leader: { timeout: number }
|
||||||
|
scroll?: { speed?: number; acceleration?: boolean }
|
||||||
|
attention: TuiAttentionConfigView
|
||||||
|
diffs?: {
|
||||||
|
wrap?: "word" | "none"
|
||||||
|
tree?: boolean
|
||||||
|
single?: boolean
|
||||||
|
view?: "auto" | "split" | "unified"
|
||||||
|
}
|
||||||
|
terminal?: { title?: boolean }
|
||||||
|
prompt?: { editor?: boolean; paste?: "compact" | "full" }
|
||||||
|
session?: {
|
||||||
|
sidebar?: "auto" | "hide"
|
||||||
|
scrollbar?: boolean
|
||||||
|
thinking?: "show" | "hide"
|
||||||
|
grouping?: "auto" | "none"
|
||||||
|
}
|
||||||
|
hints?: { tips?: boolean; onboarding?: boolean }
|
||||||
|
animations?: boolean
|
||||||
|
mouse: boolean
|
||||||
|
keybinds: TuiBindingLookupView
|
||||||
|
}
|
||||||
|
|
||||||
export type TuiApp = {
|
export type TuiApp = {
|
||||||
readonly version: string
|
readonly version: string
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.tsx",
|
".": "./src/index.tsx",
|
||||||
"./builtins": "./src/feature-plugins/builtins.ts",
|
"./builtins": "./src/feature-plugins/builtins.ts",
|
||||||
|
"./config": "./src/config/index.tsx",
|
||||||
|
"./config/keybind": "./src/config/keybind.ts",
|
||||||
"./config/v1": "./src/config/v1/index.tsx",
|
"./config/v1": "./src/config/v1/index.tsx",
|
||||||
"./config/v1/keybind": "./src/config/v1/keybind.ts",
|
"./config/v1/keybind": "./src/config/v1/keybind.ts",
|
||||||
"./config/v2": "./src/config/v2/index.ts",
|
|
||||||
"./config/v2/keybind": "./src/config/v2/keybind.ts",
|
|
||||||
"./context/args": "./src/context/args.tsx",
|
"./context/args": "./src/context/args.tsx",
|
||||||
"./context/epilogue": "./src/context/epilogue.tsx",
|
"./context/epilogue": "./src/context/epilogue.tsx",
|
||||||
"./context/exit": "./src/context/exit.tsx",
|
"./context/exit": "./src/context/exit.tsx",
|
||||||
|
|||||||
+27
-33
@@ -75,7 +75,7 @@ import * as Model from "./util/model"
|
|||||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
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 { createTuiApiAdapters } from "./plugin/adapters"
|
||||||
import { createTuiApi } from "./plugin/api"
|
import { createTuiApi } from "./plugin/api"
|
||||||
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
|
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
|
||||||
@@ -144,7 +144,6 @@ const appBindingCommands = [
|
|||||||
"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",
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type TuiInput = {
|
export type TuiInput = {
|
||||||
@@ -154,7 +153,7 @@ export type TuiInput = {
|
|||||||
reload?: () => Promise<void>
|
reload?: () => Promise<void>
|
||||||
}
|
}
|
||||||
args: Args
|
args: Args
|
||||||
config: TuiConfig.Resolved
|
config: TuiConfig.Interface
|
||||||
onSnapshot?: () => Promise<string[]>
|
onSnapshot?: () => Promise<string[]>
|
||||||
pluginHost: TuiPluginHost
|
pluginHost: TuiPluginHost
|
||||||
terminalHandoff?: () => Promise<
|
terminalHandoff?: () => Promise<
|
||||||
@@ -203,6 +202,8 @@ function isVersionGreater(left: string, right: string) {
|
|||||||
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||||
const log = input.log ?? (() => {})
|
const log = input.log ?? (() => {})
|
||||||
const global = yield* Global.Service
|
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 options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
|
||||||
const api = OpenCode.make(options)
|
const api = OpenCode.make(options)
|
||||||
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
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: {},
|
useKittyKeyboard: {},
|
||||||
autoFocus: false,
|
autoFocus: false,
|
||||||
openConsoleOnError: false,
|
openConsoleOnError: false,
|
||||||
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse,
|
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && config.mouse,
|
||||||
consoleOptions: {
|
consoleOptions: {
|
||||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||||
},
|
},
|
||||||
@@ -263,7 +264,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
win32DisableProcessedInput()
|
win32DisableProcessedInput()
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
yield* Effect.acquireRelease(
|
yield* Effect.acquireRelease(
|
||||||
Effect.sync(() => registerOpencodeKeymap(keymap, renderer, input.config)),
|
Effect.sync(() => registerOpencodeKeymap(keymap, renderer, config)),
|
||||||
(unregister) => Effect.sync(unregister),
|
(unregister) => Effect.sync(unregister),
|
||||||
)
|
)
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
@@ -337,19 +338,23 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
<ClipboardProvider>
|
<ClipboardProvider>
|
||||||
<OpencodeKeymapProvider keymap={keymap}>
|
<OpencodeKeymapProvider keymap={keymap}>
|
||||||
<ArgsProvider {...input.args}>
|
<ArgsProvider {...input.args}>
|
||||||
<KVProvider>
|
<TuiConfigProvider
|
||||||
<ToastProvider>
|
config={config}
|
||||||
<RouteProvider
|
service={input.config}
|
||||||
initialRoute={
|
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||||
input.args.continue
|
>
|
||||||
? {
|
<KVProvider>
|
||||||
type: "session",
|
<ToastProvider>
|
||||||
sessionID: "dummy",
|
<RouteProvider
|
||||||
}
|
initialRoute={
|
||||||
: undefined
|
input.args.continue
|
||||||
}
|
? {
|
||||||
>
|
type: "session",
|
||||||
<TuiConfigProvider config={input.config}>
|
sessionID: "dummy",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<PluginRuntimeProvider value={pluginRuntime}>
|
<PluginRuntimeProvider value={pluginRuntime}>
|
||||||
<SDKProvider
|
<SDKProvider
|
||||||
client={createOpencodeClient({ ...options, directory })}
|
client={createOpencodeClient({ ...options, directory })}
|
||||||
@@ -397,10 +402,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
</PluginRuntimeProvider>
|
</PluginRuntimeProvider>
|
||||||
</TuiConfigProvider>
|
</RouteProvider>
|
||||||
</RouteProvider>
|
</ToastProvider>
|
||||||
</ToastProvider>
|
</KVProvider>
|
||||||
</KVProvider>
|
</TuiConfigProvider>
|
||||||
</ArgsProvider>
|
</ArgsProvider>
|
||||||
</OpencodeKeymapProvider>
|
</OpencodeKeymapProvider>
|
||||||
</ClipboardProvider>
|
</ClipboardProvider>
|
||||||
@@ -1004,17 +1009,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||||||
dialog.clear()
|
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",
|
name: "permission.mode",
|
||||||
title:
|
title:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
TuiAttentionSoundPack,
|
TuiAttentionSoundPack,
|
||||||
TuiAttentionSoundPackInfo,
|
TuiAttentionSoundPackInfo,
|
||||||
} from "@opencode-ai/plugin/tui"
|
} from "@opencode-ai/plugin/tui"
|
||||||
import { AttentionSoundName, type TuiConfig } from "./config/v1"
|
import { AttentionSoundName, type TuiConfig } from "./config"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import stripAnsi from "strip-ansi"
|
import stripAnsi from "strip-ansi"
|
||||||
import * as TuiAudio from "./audio"
|
import * as TuiAudio from "./audio"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
useKeymapSelector,
|
useKeymapSelector,
|
||||||
useOpencodeKeymap,
|
useOpencodeKeymap,
|
||||||
} from "../keymap"
|
} from "../keymap"
|
||||||
import { useTuiConfig } from "../config/v1"
|
import { useTuiConfig } from "../config"
|
||||||
|
|
||||||
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
|
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { McpServer } from "@opencode-ai/client"
|
|||||||
import { useClipboard } from "../context/clipboard"
|
import { useClipboard } from "../context/clipboard"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { useTuiConfig } from "../config/v1"
|
import { useTuiConfig } from "../config"
|
||||||
import { getScrollAcceleration } from "../util/scroll"
|
import { getScrollAcceleration } from "../util/scroll"
|
||||||
import { useBindings } from "../keymap"
|
import { useBindings } from "../keymap"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { InputRenderable, TextAttributes } from "@opentui/core"
|
import { InputRenderable, TextAttributes } from "@opentui/core"
|
||||||
import { Slug } from "@opencode-ai/core/util/slug"
|
import { Slug } from "@opencode-ai/core/util/slug"
|
||||||
import { createSignal, onMount } from "solid-js"
|
import { createSignal, onMount } from "solid-js"
|
||||||
import { useTuiConfig } from "../config/v1"
|
import { useTuiConfig } from "../config"
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
import { useBindings, useCommandShortcut } from "../keymap"
|
import { useBindings, useCommandShortcut } from "../keymap"
|
||||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createMemo, For } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { FilePath } from "../ui/file-path"
|
import { FilePath } from "../ui/file-path"
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
import { useTuiConfig } from "../config/v1"
|
import { useTuiConfig } from "../config"
|
||||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||||
import { getScrollAcceleration } from "../util/scroll"
|
import { getScrollAcceleration } from "../util/scroll"
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useSDK } from "../../context/sdk"
|
|||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
import { useTuiPaths } from "../../context/runtime"
|
import { useTuiPaths } from "../../context/runtime"
|
||||||
import { useTuiConfig } from "../../config/v1"
|
import { useTuiConfig } from "../../config"
|
||||||
import { useLocation } from "../../context/location"
|
import { useLocation } from "../../context/location"
|
||||||
import { useTheme, selectedForeground } from "../../context/theme"
|
import { useTheme, selectedForeground } from "../../context/theme"
|
||||||
import { SplitBorder } from "../../ui/border"
|
import { SplitBorder } from "../../ui/border"
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import { createFadeIn } from "../../util/signal"
|
|||||||
import { DialogSkill } from "../dialog-skill"
|
import { DialogSkill } from "../dialog-skill"
|
||||||
import { useArgs } from "../../context/args"
|
import { useArgs } from "../../context/args"
|
||||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
|
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
|
||||||
import { useTuiConfig } from "../../config/v1"
|
import { useTuiConfig } from "../../config"
|
||||||
import { usePromptMove } from "./move"
|
import { usePromptMove } from "./move"
|
||||||
import { readLocalAttachment } from "./local-attachment"
|
import { readLocalAttachment } from "./local-attachment"
|
||||||
import { useData } from "../../context/data"
|
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)))
|
const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48)))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./v1/keybind"
|
||||||
|
export * as TuiKeybind from "./v1/keybind"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * as TuiConfig from "."
|
export * as TuiConfig from "."
|
||||||
|
export * as TuiConfigV1 from "."
|
||||||
|
|
||||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export const Definitions = {
|
|||||||
app_toggle_file_context: keybind("none", "Toggle file context"),
|
app_toggle_file_context: keybind("none", "Toggle file context"),
|
||||||
app_toggle_diffwrap: keybind("none", "Toggle diff wrapping"),
|
app_toggle_diffwrap: keybind("none", "Toggle diff wrapping"),
|
||||||
app_toggle_paste_summary: keybind("none", "Toggle paste summary"),
|
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"),
|
command_list: keybind("ctrl+p", "List available commands"),
|
||||||
help_show: keybind("none", "Open help dialog"),
|
help_show: keybind("none", "Open help dialog"),
|
||||||
docs_open: keybind("none", "Open documentation"),
|
docs_open: keybind("none", "Open documentation"),
|
||||||
@@ -257,7 +256,6 @@ export const CommandMap = {
|
|||||||
app_toggle_file_context: "app.toggle.file_context",
|
app_toggle_file_context: "app.toggle.file_context",
|
||||||
app_toggle_diffwrap: "app.toggle.diffwrap",
|
app_toggle_diffwrap: "app.toggle.diffwrap",
|
||||||
app_toggle_paste_summary: "app.toggle.paste_summary",
|
app_toggle_paste_summary: "app.toggle.paste_summary",
|
||||||
app_toggle_session_directory_filter: "app.toggle.session_directory_filter",
|
|
||||||
command_list: "command.palette.show",
|
command_list: "command.palette.show",
|
||||||
help_show: "help.show",
|
help_show: "help.show",
|
||||||
docs_open: "docs.open",
|
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>
|
|
||||||
@@ -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 { createStore, unwrap } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { Flock } from "@opencode-ai/core/util/flock"
|
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 { readJson, writeJsonAtomic } from "../util/persistence"
|
||||||
import { useTuiPaths } from "./runtime"
|
import { useTuiPaths } from "./runtime"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { useTuiConfigOptional, type TuiConfig } from "../config"
|
||||||
|
|
||||||
export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
||||||
name: "KV",
|
name: "KV",
|
||||||
init: () => {
|
init: (props: { config?: TuiConfig.Info }) => {
|
||||||
|
const config = props.config ?? useTuiConfigOptional()
|
||||||
const paths = useTuiPaths()
|
const paths = useTuiPaths()
|
||||||
void Global.Path.state
|
void Global.Path.state
|
||||||
const file = path.join(paths.state, "kv.json")
|
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))
|
Flock.withLock(lock, () => readJson<Record<string, unknown>>(file))
|
||||||
.then((x) => {
|
.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) => {
|
.catch((error) => {
|
||||||
console.error("Failed to read KV state", { error })
|
console.error("Failed to read KV state", { error })
|
||||||
@@ -30,6 +37,14 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
|||||||
setReady(true)
|
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 = {
|
const result = {
|
||||||
get ready() {
|
get ready() {
|
||||||
return ready()
|
return ready()
|
||||||
@@ -64,3 +79,29 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
|||||||
return result
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { useKV } from "./kv"
|
import { useKV } from "./kv"
|
||||||
import { useTuiConfig } from "../config/v1"
|
import { useTuiConfig } from "../config"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Glob } from "@opencode-ai/core/util/glob"
|
import { Glob } from "@opencode-ai/core/util/glob"
|
||||||
import { readFile } from "node:fs/promises"
|
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)
|
if (!lock && pick(kv.get("theme_mode")) !== undefined) kv.set("theme_mode", undefined)
|
||||||
draft.mode = mode
|
draft.mode = mode
|
||||||
draft.lock = lock
|
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.active = typeof active === "string" ? active : "opencode"
|
||||||
draft.ready = false
|
draft.ready = false
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const theme = config.theme
|
const theme = config.theme?.name
|
||||||
if (theme) setStore("active", theme)
|
if (theme) setStore("active", theme)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ const TIPS: Tip[] = [
|
|||||||
(shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`,
|
(shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`,
|
||||||
(shortcuts) => press(shortcuts.messagesToggleConceal(), "to toggle code block visibility in messages"),
|
(shortcuts) => press(shortcuts.messagesToggleConceal(), "to toggle code block visibility in messages"),
|
||||||
(shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`,
|
(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) =>
|
||||||
shortcuts.commandList()
|
shortcuts.commandList()
|
||||||
? `Toggle username display in chat via the command palette (${shortcutText(shortcuts.commandList())})`
|
? `Toggle username display in chat via the command palette (${shortcutText(shortcuts.commandList())})`
|
||||||
|
|||||||
@@ -144,7 +144,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
|||||||
const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : []))
|
const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : []))
|
||||||
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
|
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
|
||||||
const defaultView = createMemo(() => {
|
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"
|
return splitAvailable() ? "split" : "unified"
|
||||||
})
|
})
|
||||||
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))
|
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import {
|
|||||||
} from "@opentui/keymap/extras"
|
} from "@opentui/keymap/extras"
|
||||||
import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid"
|
import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid"
|
||||||
import { createMemo, type Accessor } from "solid-js"
|
import { createMemo, type Accessor } from "solid-js"
|
||||||
import { useTuiConfig } from "./config/v1"
|
import { useTuiConfig } from "./config"
|
||||||
import { TuiKeybind } from "./config/v1/keybind"
|
import { TuiKeybind } from "./config/keybind"
|
||||||
|
|
||||||
export const LEADER_TOKEN = "leader"
|
export const LEADER_TOKEN = "leader"
|
||||||
export const OPENCODE_BASE_MODE = "base"
|
export const OPENCODE_BASE_MODE = "base"
|
||||||
@@ -42,7 +42,7 @@ type BindingLookup = {
|
|||||||
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
|
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
|
||||||
}
|
}
|
||||||
type FormatConfig = { keybinds: BindingLookup }
|
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>()
|
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende
|
|||||||
? registerTimedLeader(keymap, {
|
? registerTimedLeader(keymap, {
|
||||||
trigger: leader,
|
trigger: leader,
|
||||||
name: LEADER_TOKEN,
|
name: LEADER_TOKEN,
|
||||||
timeoutMs: config.leader_timeout,
|
timeoutMs: "leader" in config ? config.leader.timeout : config.leader_timeout,
|
||||||
})
|
})
|
||||||
: () => {}
|
: () => {}
|
||||||
const offEscape = registerEscapeClearsPendingSequence(keymap)
|
const offEscape = registerEscapeClearsPendingSequence(keymap)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { TuiDialogSelectOption, TuiPluginApi, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
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 { useEvent } from "../context/event"
|
||||||
import type { useRoute } from "../context/route"
|
import type { useRoute } from "../context/route"
|
||||||
import type { useSDK } from "../context/sdk"
|
import type { useSDK } from "../context/sdk"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Legacy `api.command` bridge for v1 plugins; remove in v2.
|
// Legacy `api.command` bridge for v1 plugins; remove in v2.
|
||||||
import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
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"
|
import type { DialogContext } from "../ui/dialog"
|
||||||
|
|
||||||
const COMMAND_PALETTE_SHOW = "command.palette.show"
|
const COMMAND_PALETTE_SHOW = "command.palette.show"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
TuiPluginInstallResult,
|
TuiPluginInstallResult,
|
||||||
TuiPluginStatus,
|
TuiPluginStatus,
|
||||||
} from "@opencode-ai/plugin/tui"
|
} 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 { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
|
||||||
import { createPluginRoutes } from "./api"
|
import { createPluginRoutes } from "./api"
|
||||||
import { createSlots, type HostSlots } from "./slots"
|
import { createSlots, type HostSlots } from "./slots"
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { usePromptRef } from "../context/prompt"
|
|||||||
import { useLocal } from "../context/local"
|
import { useLocal } from "../context/local"
|
||||||
import { usePluginRuntime } from "../plugin/runtime"
|
import { usePluginRuntime } from "../plugin/runtime"
|
||||||
import { useEditorContext } from "../context/editor"
|
import { useEditorContext } from "../context/editor"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
|
||||||
import { useTuiConfig } from "../config/v1"
|
|
||||||
import { HomeSessionDestinationProvider } from "./home/session-destination"
|
import { HomeSessionDestinationProvider } from "./home/session-destination"
|
||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
import { LocationProvider } from "../context/location"
|
import { LocationProvider } from "../context/location"
|
||||||
@@ -29,16 +27,9 @@ export function Home() {
|
|||||||
const args = useArgs()
|
const args = useArgs()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const editor = useEditorContext()
|
const editor = useEditorContext()
|
||||||
const dimensions = useTerminalDimensions()
|
|
||||||
const tuiConfig = useTuiConfig()
|
|
||||||
const data = useData()
|
const data = useData()
|
||||||
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
|
// 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 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
|
let sent = false
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -83,7 +74,7 @@ export function Home() {
|
|||||||
</pluginRuntime.Slot>
|
</pluginRuntime.Slot>
|
||||||
</box>
|
</box>
|
||||||
<box height={1} minHeight={0} flexShrink={1} />
|
<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}>
|
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
|
||||||
<Prompt
|
<Prompt
|
||||||
ref={bind}
|
ref={bind}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useSDK } from "../../context/sdk"
|
|||||||
import { useClipboard } from "../../context/clipboard"
|
import { useClipboard } from "../../context/clipboard"
|
||||||
import { SplitBorder } from "../../ui/border"
|
import { SplitBorder } from "../../ui/border"
|
||||||
import { useToast } from "../../ui/toast"
|
import { useToast } from "../../ui/toast"
|
||||||
import { useTuiConfig } from "../../config/v1"
|
import { useTuiConfig } from "../../config"
|
||||||
import { useBindings, useOpencodeModeStack } from "../../keymap"
|
import { useBindings, useOpencodeModeStack } from "../../keymap"
|
||||||
|
|
||||||
const FORM_MODE = "form"
|
const FORM_MODE = "form"
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ import { FormPrompt } from "./form"
|
|||||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||||
import { DialogExportResult } from "../../ui/dialog-export-result"
|
import { DialogExportResult } from "../../ui/dialog-export-result"
|
||||||
import { sessionEpilogue } from "../../util/presentation"
|
import { sessionEpilogue } from "../../util/presentation"
|
||||||
import { useTuiConfig } from "../../config/v1"
|
import { useTuiConfig } from "../../config"
|
||||||
import { useClipboard } from "../../context/clipboard"
|
import { useClipboard } from "../../context/clipboard"
|
||||||
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
@@ -2465,8 +2465,9 @@ function Edit(props: ToolProps) {
|
|||||||
const pathFormatter = usePathFormatter()
|
const pathFormatter = usePathFormatter()
|
||||||
|
|
||||||
const view = createMemo(() => {
|
const view = createMemo(() => {
|
||||||
const diffStyle = ctx.tui.diff_style
|
const diffView = ctx.tui.diffs?.view
|
||||||
if (diffStyle === "stacked") return "unified"
|
if (diffView === "unified") return "unified"
|
||||||
|
if (diffView === "split") return "split"
|
||||||
// Default to "auto" behavior
|
// Default to "auto" behavior
|
||||||
return ctx.width > 120 ? "split" : "unified"
|
return ctx.width > 120 ? "split" : "unified"
|
||||||
})
|
})
|
||||||
@@ -2541,7 +2542,8 @@ function ApplyPatch(props: ToolProps) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
const view = createMemo(() => {
|
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"
|
return ctx.width > 120 ? "split" : "unified"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { filetype } from "../../util/filetype"
|
|||||||
import { Locale } from "../../util/locale"
|
import { Locale } from "../../util/locale"
|
||||||
import { webSearchProviderLabel } from "../../util/tool-display"
|
import { webSearchProviderLabel } from "../../util/tool-display"
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
import { useTuiConfig } from "../../config/v1"
|
import { useTuiConfig } from "../../config"
|
||||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
|
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
|
||||||
import { usePathFormatter } from "../../context/path-format"
|
import { usePathFormatter } from "../../context/path-format"
|
||||||
|
|
||||||
@@ -34,8 +34,9 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const view = createMemo(() => {
|
const view = createMemo(() => {
|
||||||
const diffStyle = config.diff_style
|
const diffView = config.diffs?.view
|
||||||
if (diffStyle === "stacked") return "unified"
|
if (diffView === "unified") return "unified"
|
||||||
|
if (diffView === "split") return "split"
|
||||||
return dimensions().width > 120 ? "split" : "unified"
|
return dimensions().width > 120 ? "split" : "unified"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
import { createMemo, Show } from "solid-js"
|
import { createMemo, Show } from "solid-js"
|
||||||
import { useTheme } from "../../context/theme"
|
import { useTheme } from "../../context/theme"
|
||||||
import { useTuiConfig } from "../../config/v1"
|
import { useTuiConfig } from "../../config"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { usePluginRuntime } from "../../plugin/runtime"
|
import { usePluginRuntime } from "../../plugin/runtime"
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
|
|||||||
import { useDialog, type DialogContext } from "./dialog"
|
import { useDialog, type DialogContext } from "./dialog"
|
||||||
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
|
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
|
||||||
import { Spinner } from "../component/spinner"
|
import { Spinner } from "../component/spinner"
|
||||||
import { useTuiConfig } from "../config/v1"
|
import { useTuiConfig } from "../config"
|
||||||
import { useBindings, useCommandShortcut } from "../keymap"
|
import { useBindings, useCommandShortcut } from "../keymap"
|
||||||
|
|
||||||
export type DialogPromptProps = {
|
export type DialogPromptProps = {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { isDeepEqual } from "remeda"
|
|||||||
import { useDialog, type DialogContext } from "./dialog"
|
import { useDialog, type DialogContext } from "./dialog"
|
||||||
import { Locale } from "../util/locale"
|
import { Locale } from "../util/locale"
|
||||||
import { getScrollAcceleration } from "../util/scroll"
|
import { getScrollAcceleration } from "../util/scroll"
|
||||||
import { useTuiConfig } from "../config/v1"
|
import { useTuiConfig } from "../config"
|
||||||
import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
|
import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
|
||||||
|
|
||||||
export interface DialogSelectProps<T> {
|
export interface DialogSelectProps<T> {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
|
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
|
||||||
|
|
||||||
export type ScrollConfig = {
|
export type ScrollConfig = {
|
||||||
scroll_acceleration?: { enabled?: boolean }
|
scroll?: {
|
||||||
scroll_speed?: number
|
acceleration?: boolean
|
||||||
|
speed?: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CustomSpeedScroll implements ScrollAcceleration {
|
export class CustomSpeedScroll implements ScrollAcceleration {
|
||||||
@@ -16,11 +18,11 @@ export class CustomSpeedScroll implements ScrollAcceleration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getScrollAcceleration(tuiConfig?: ScrollConfig): ScrollAcceleration {
|
export function getScrollAcceleration(tuiConfig?: ScrollConfig): ScrollAcceleration {
|
||||||
if (tuiConfig?.scroll_acceleration?.enabled) {
|
if (tuiConfig?.scroll?.acceleration) {
|
||||||
return new MacOSScrollAccel()
|
return new MacOSScrollAccel()
|
||||||
}
|
}
|
||||||
if (tuiConfig?.scroll_speed !== undefined) {
|
if (tuiConfig?.scroll?.speed !== undefined) {
|
||||||
return new CustomSpeedScroll(tuiConfig.scroll_speed)
|
return new CustomSpeedScroll(tuiConfig.scroll.speed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CustomSpeedScroll(3)
|
return new CustomSpeedScroll(3)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createTestRenderer } from "@opentui/core/testing"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
|
||||||
import { createEventStream, createFetch, directory, json } from "./fixture/tui-sdk"
|
import { createEventStream, createFetch, directory, json } from "./fixture/tui-sdk"
|
||||||
|
|
||||||
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||||
@@ -32,7 +31,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
|||||||
const task = Effect.runPromise(
|
const task = Effect.runPromise(
|
||||||
run({
|
run({
|
||||||
server: { endpoint: { url: server.url.toString() } },
|
server: { endpoint: { url: server.url.toString() } },
|
||||||
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
config: { get: async () => ({}), update: async () => ({}) },
|
||||||
args: {},
|
args: {},
|
||||||
log: () => {},
|
log: () => {},
|
||||||
pluginHost: {
|
pluginHost: {
|
||||||
@@ -118,7 +117,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
|||||||
const task = Effect.runPromise(
|
const task = Effect.runPromise(
|
||||||
run({
|
run({
|
||||||
server: { endpoint: { url: server.url.toString() } },
|
server: { endpoint: { url: server.url.toString() } },
|
||||||
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
config: { get: async () => ({}), update: async () => ({}) },
|
||||||
args: { sessionID: "dummy" },
|
args: { sessionID: "dummy" },
|
||||||
log: () => {},
|
log: () => {},
|
||||||
pluginHost: {
|
pluginHost: {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import path from "node:path"
|
|||||||
import { onCleanup } from "solid-js"
|
import { onCleanup } from "solid-js"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||||
import type { TuiKeybind } from "../../../src/config/v1/keybind"
|
import type { TuiKeybind } from "../../../src/config/keybind"
|
||||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||||
|
|
||||||
async function wait(fn: () => boolean, timeout = 2000) {
|
async function wait(fn: () => boolean, timeout = 2000) {
|
||||||
@@ -41,7 +41,7 @@ async function mountPrompt(input: {
|
|||||||
import("../../../src/ui/dialog-prompt"),
|
import("../../../src/ui/dialog-prompt"),
|
||||||
import("../../../src/context/kv"),
|
import("../../../src/context/kv"),
|
||||||
import("../../../src/context/theme"),
|
import("../../../src/context/theme"),
|
||||||
import("../../../src/config/v1"),
|
import("../../../src/config"),
|
||||||
import("../../../src/ui/toast"),
|
import("../../../src/ui/toast"),
|
||||||
import("../../../src/keymap"),
|
import("../../../src/keymap"),
|
||||||
])
|
])
|
||||||
@@ -51,7 +51,7 @@ async function mountPrompt(input: {
|
|||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
const resolvedConfig = createTuiResolvedConfig({
|
const resolvedConfig = createTuiResolvedConfig({
|
||||||
keybinds: input.keybinds,
|
keybinds: input.keybinds,
|
||||||
leader_timeout: 1000,
|
leader: { timeout: 1000 },
|
||||||
})
|
})
|
||||||
const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
|
const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
|
||||||
onCleanup(off)
|
onCleanup(off)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { JSX } from "solid-js"
|
|||||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||||
import { KVProvider } from "../../../src/context/kv"
|
import { KVProvider } from "../../../src/context/kv"
|
||||||
import { ThemeProvider } from "../../../src/context/theme"
|
import { ThemeProvider } from "../../../src/context/theme"
|
||||||
import { TuiConfigProvider } from "../../../src/config/v1"
|
import { TuiConfigProvider } from "../../../src/config"
|
||||||
import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
|
import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
|
||||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition }
|
|||||||
import type { Session } from "@opencode-ai/sdk/v2"
|
import type { Session } from "@opencode-ai/sdk/v2"
|
||||||
import { KVProvider } from "../../../src/context/kv"
|
import { KVProvider } from "../../../src/context/kv"
|
||||||
import { ThemeProvider } from "../../../src/context/theme"
|
import { ThemeProvider } from "../../../src/context/theme"
|
||||||
import { TuiConfigProvider } from "../../../src/config/v1"
|
import { TuiConfigProvider } from "../../../src/config"
|
||||||
import { SDKProvider } from "../../../src/context/sdk"
|
import { SDKProvider } from "../../../src/context/sdk"
|
||||||
import { TuiKeybind } from "../../../src/config/v1/keybind"
|
import { TuiKeybind } from "../../../src/config/keybind"
|
||||||
import { OpencodeKeymapProvider } from "../../../src/keymap"
|
import { OpencodeKeymapProvider } from "../../../src/keymap"
|
||||||
import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer"
|
import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer"
|
||||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { FormWithLocation } from "../../../src/context/data"
|
|||||||
import { KVProvider } from "../../../src/context/kv"
|
import { KVProvider } from "../../../src/context/kv"
|
||||||
import { SDKProvider } from "../../../src/context/sdk"
|
import { SDKProvider } from "../../../src/context/sdk"
|
||||||
import { ThemeProvider } from "../../../src/context/theme"
|
import { ThemeProvider } from "../../../src/context/theme"
|
||||||
import { TuiConfigProvider } from "../../../src/config/v1"
|
import { TuiConfigProvider } from "../../../src/config"
|
||||||
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/keymap"
|
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/keymap"
|
||||||
import { ToastProvider } from "../../../src/ui/toast"
|
import { ToastProvider } from "../../../src/ui/toast"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import { testRender } from "@opentui/solid"
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
resolve,
|
||||||
|
TuiConfigProvider,
|
||||||
|
useTuiConfig,
|
||||||
|
useTuiConfigService,
|
||||||
|
type Interface,
|
||||||
|
} from "../src/config"
|
||||||
|
|
||||||
|
test("resolves nested config and keybind defaults", () => {
|
||||||
|
const config = resolve(
|
||||||
|
{
|
||||||
|
keybinds: { leader: "ctrl+o" },
|
||||||
|
leader: { timeout: 500 },
|
||||||
|
scroll: { speed: 2, acceleration: true },
|
||||||
|
diffs: { view: "split" },
|
||||||
|
},
|
||||||
|
{ terminalSuspend: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(config.leader.timeout).toBe(500)
|
||||||
|
expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o")
|
||||||
|
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
|
||||||
|
expect(config.diffs).toEqual({ view: "split" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("provides config and its host interface", async () => {
|
||||||
|
const config = resolve({}, { terminalSuspend: true })
|
||||||
|
let current = {}
|
||||||
|
const service: Interface = {
|
||||||
|
get: async () => current,
|
||||||
|
update: async (update) => {
|
||||||
|
const draft: Record<string, any> = { ...current }
|
||||||
|
update(draft)
|
||||||
|
current = draft
|
||||||
|
return draft
|
||||||
|
},
|
||||||
|
}
|
||||||
|
let configService: Interface | undefined
|
||||||
|
|
||||||
|
function Consumer() {
|
||||||
|
const config = useTuiConfig()
|
||||||
|
configService = useTuiConfigService()
|
||||||
|
return <text>{`${config.mouse ? "mouse" : "none"} ${config.keybinds.get("leader")?.[0]?.key}`}</text>
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TuiConfigProvider config={config} service={service}>
|
||||||
|
<Consumer />
|
||||||
|
</TuiConfigProvider>
|
||||||
|
))
|
||||||
|
try {
|
||||||
|
await app.renderOnce()
|
||||||
|
expect(app.captureCharFrame()).toContain("mouse ctrl+x")
|
||||||
|
if (!configService) throw new Error("Config service was not provided")
|
||||||
|
await configService.update((draft) => {
|
||||||
|
draft.mouse = false
|
||||||
|
draft.keybinds = { leader: "ctrl+o" }
|
||||||
|
})
|
||||||
|
await app.renderOnce()
|
||||||
|
expect(app.captureCharFrame()).toContain("none ctrl+o")
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { resolve, type Info, type Resolved } from "../../src/config/v1"
|
import { resolve, type Info, type Resolved } from "../../src/config"
|
||||||
import { TuiKeybind } from "../../src/config/v1/keybind"
|
import { TuiKeybind } from "../../src/config/keybind"
|
||||||
|
|
||||||
type ResolvedInput = Omit<Info, "attention" | "keybinds" | "leader_timeout"> & {
|
type ResolvedInput = Omit<Info, "attention" | "keybinds" | "leader"> & {
|
||||||
attention?: Partial<Resolved["attention"]>
|
attention?: Partial<Resolved["attention"]>
|
||||||
keybinds?: Partial<TuiKeybind.Keybinds>
|
keybinds?: Partial<TuiKeybind.Keybinds>
|
||||||
leader_timeout?: number
|
leader?: { timeout?: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTuiResolvedConfig(input: ResolvedInput = {}) {
|
export function createTuiResolvedConfig(input: ResolvedInput = {}) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createBindingLookup } from "@opentui/keymap/extras"
|
|||||||
import { testRender, useRenderer } from "@opentui/solid"
|
import { testRender, useRenderer } from "@opentui/solid"
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { onCleanup } from "solid-js"
|
import { onCleanup } from "solid-js"
|
||||||
import { TuiKeybind } from "../src/config/v1/keybind"
|
import { TuiKeybind } from "../src/config/keybind"
|
||||||
import {
|
import {
|
||||||
formatKeySequence,
|
formatKeySequence,
|
||||||
getOpencodeModeStack,
|
getOpencodeModeStack,
|
||||||
|
|||||||
Reference in New Issue
Block a user