more-tui-plugins
Sebastian Herrlinger 2026-02-13 23:22:45 +01:00
parent 27090c122d
commit 9a5cf7dfe5
7 changed files with 175 additions and 72 deletions

View File

@ -1,7 +1,7 @@
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection"
import { MouseButton, TextAttributes } from "@opentui/core"
import { createCliRenderer, MouseButton, TextAttributes, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
@ -103,6 +103,43 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
import type { EventSource } from "./context/sdk"
function rendererConfig(config: TuiConfig.Info): CliRendererConfig {
const input = config.tui?.renderer
const kitty = input?.use_kitty_keyboard
return {
targetFps: input?.target_fps ?? 60,
maxFps: input?.max_fps,
gatherStats: input?.gather_stats ?? false,
exitOnCtrlC: false,
useMouse: input?.use_mouse,
enableMouseMovement: input?.enable_mouse_movement,
useAlternateScreen: input?.use_alternate_screen,
autoFocus: input?.auto_focus ?? false,
useKittyKeyboard:
kitty === undefined || kitty === true
? {}
: kitty === false
? null
: {
disambiguate: kitty.disambiguate,
alternateKeys: kitty.alternate_keys,
events: kitty.events,
allKeysAsEscapes: kitty.all_keys_as_escapes,
reportText: kitty.report_text,
},
openConsoleOnError: input?.open_console_on_error ?? false,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => {
Clipboard.copy(text).catch((error) => {
console.error(`Failed to copy console selection to clipboard: ${error}`)
})
},
},
}
}
export function tui(input: {
url: string
args: Args
@ -130,73 +167,58 @@ export function tui(input: {
resolve()
}
render(
() => {
return (
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
>
<ArgsProvider {...input.args}>
<ExitProvider onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</ErrorBoundary>
)
},
{
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => {
Clipboard.copy(text).catch((error) => {
console.error(`Failed to copy console selection to clipboard: ${error}`)
})
},
},
},
)
const renderer = await createCliRenderer(rendererConfig(input.config))
await render(() => {
return (
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
>
<ArgsProvider {...input.args}>
<ExitProvider onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
renderer={renderer}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</ErrorBoundary>
)
}, renderer)
})
}

View File

@ -3,6 +3,7 @@ import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js"
import { TuiPlugin } from "../plugin"
import type { CliRenderer } from "@opentui/core"
export type EventSource = {
on: (handler: (event: Event) => void) => () => void
@ -12,6 +13,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: {
url: string
renderer: CliRenderer
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
@ -35,6 +37,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
event: emitter,
url: props.url,
directory: props.directory,
renderer: props.renderer,
}).catch((error) => {
console.error("Failed to load TUI plugins", error)
})

View File

@ -920,6 +920,34 @@ export namespace Config {
ref: "KeybindsConfig",
})
const TUIRenderer = z
.object({
target_fps: z.number().int().positive().optional().describe("Target FPS for the renderer"),
max_fps: z.number().int().positive().optional().describe("Maximum FPS for immediate rerenders"),
gather_stats: z.boolean().optional().describe("Enable renderer frame statistics collection"),
use_mouse: z.boolean().optional().describe("Enable mouse tracking"),
enable_mouse_movement: z.boolean().optional().describe("Track mouse movement events"),
auto_focus: z.boolean().optional().describe("Auto focus nearest focusable item on click"),
use_alternate_screen: z.boolean().optional().describe("Use alternate screen buffer"),
open_console_on_error: z.boolean().optional().describe("Open renderer console on uncaught errors"),
use_kitty_keyboard: z
.union([
z.boolean(),
z
.object({
disambiguate: z.boolean().optional(),
alternate_keys: z.boolean().optional(),
events: z.boolean().optional(),
all_keys_as_escapes: z.boolean().optional(),
report_text: z.boolean().optional(),
})
.strict(),
])
.optional()
.describe("Kitty keyboard protocol settings. true enables defaults, false disables it."),
})
.strict()
export const TUI = z.object({
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
scroll_acceleration: z
@ -932,6 +960,7 @@ export namespace Config {
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
renderer: TUIRenderer.optional().describe("Renderer options for the terminal UI"),
})
export const Server = z

View File

@ -81,3 +81,42 @@ test("only reads plugin list from tui.json", async () => {
},
})
})
test("parses renderer options from tui config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify(
{
tui: {
renderer: {
target_fps: 75,
auto_focus: true,
use_kitty_keyboard: {
events: true,
report_text: true,
},
},
},
},
null,
2,
),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.tui?.renderer?.target_fps).toBe(75)
expect(config.tui?.renderer?.auto_focus).toBe(true)
expect(config.tui?.renderer?.use_kitty_keyboard).toEqual({
events: true,
report_text: true,
})
},
})
})

View File

@ -66,16 +66,19 @@ export type TuiEventBus = {
) => () => void
}
export type TuiPluginInput = {
export type TuiPluginInput<Renderer = unknown> = {
client: ReturnType<typeof createOpencodeClientV2>
event: TuiEventBus
url: string
directory?: string
renderer: Renderer
}
export type TuiPlugin = (input: TuiPluginInput, options?: PluginOptions) => Promise<void>
export type TuiPlugin<Renderer = unknown> = (input: TuiPluginInput<Renderer>, options?: PluginOptions) => Promise<void>
export type PluginModule = Plugin | { server?: Plugin; tui?: TuiPlugin; themes?: Record<string, ThemeJson> }
export type PluginModule<Renderer = unknown> =
| Plugin
| { server?: Plugin; tui?: TuiPlugin<Renderer>; themes?: Record<string, ThemeJson> }
export type AuthHook = {
provider: string

View File

@ -164,7 +164,12 @@ You can configure TUI-specific settings through the `tui` option.
"scroll_acceleration": {
"enabled": true
},
"diff_style": "auto"
"diff_style": "auto",
"renderer": {
"target_fps": 60,
"auto_focus": false,
"use_kitty_keyboard": true
}
}
}
```
@ -174,6 +179,7 @@ Available options:
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.**
- `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column.
- `renderer` - Renderer startup options such as `target_fps`, `max_fps`, `gather_stats`, `use_mouse`, `enable_mouse_movement`, `use_alternate_screen`, `auto_focus`, `open_console_on_error`, and `use_kitty_keyboard`.
[Learn more about using the TUI here](/docs/tui).

View File

@ -179,6 +179,7 @@ TUI input includes:
- `client`: the SDK client for the connected server
- `event`: an event bus for server events
- `renderer`: the active OpenTUI renderer instance
- `url`: server URL
- `directory`: optional working directory