refactor(tui): extract standalone package (#31193)
This commit is contained in:
parent
7a2c49e762
commit
106f8e94d6
84 changed files with 1147 additions and 1918 deletions
|
|
@ -1,7 +1,13 @@
|
|||
import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { ClipboardProvider, useClipboard } from "./context/clipboard"
|
||||
import { EpilogueProvider } from "./context/epilogue"
|
||||
import * as Selection from "./util/selection"
|
||||
import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core"
|
||||
import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "./context/route"
|
||||
import {
|
||||
Switch,
|
||||
|
|
@ -15,17 +21,8 @@ import {
|
|||
batch,
|
||||
Show,
|
||||
on,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import {
|
||||
TuiBuildInfoProvider,
|
||||
TuiEnvironmentProvider,
|
||||
useTuiBuildInfo,
|
||||
useTuiEnvironment,
|
||||
type TuiBuildInfo,
|
||||
type TuiEnvironment,
|
||||
} from "./runtime"
|
||||
import { TuiPlatformProvider, useTuiPlatform, type TuiPlatform } from "./platform"
|
||||
import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime"
|
||||
import { DialogProvider, useDialog } from "./ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "./component/dialog-provider"
|
||||
import { ErrorComponent } from "./component/error-component"
|
||||
|
|
@ -57,7 +54,6 @@ import { PromptStashProvider } from "./component/prompt/stash"
|
|||
import { DialogAlert } from "./ui/dialog-alert"
|
||||
import { DialogConfirm } from "./ui/dialog-confirm"
|
||||
import { ToastProvider, useToast } from "./ui/toast"
|
||||
import { createExit, ExitProvider, useExit, type Exit } from "./context/exit"
|
||||
import { isDefaultTitle } from "./util/session"
|
||||
import { KVProvider, useKV } from "./context/kv"
|
||||
import * as Model from "./util/model"
|
||||
|
|
@ -67,14 +63,7 @@ import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
|||
import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config"
|
||||
import { createTuiApiAdapters } from "./plugin/adapters"
|
||||
import { createTuiApi } from "./plugin/api"
|
||||
import {
|
||||
createPluginRuntime,
|
||||
PluginRuntimeProvider,
|
||||
usePluginRuntime,
|
||||
type PluginRuntime,
|
||||
type TuiPluginHost,
|
||||
} from "./plugin/runtime"
|
||||
import type { TuiAttention } from "@opencode-ai/plugin/tui"
|
||||
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
|
||||
import { CommandPaletteDialog } from "./component/command-palette"
|
||||
import {
|
||||
COMMAND_PALETTE_COMMAND,
|
||||
|
|
@ -87,6 +76,10 @@ import {
|
|||
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
import { createTuiAttention } from "./attention"
|
||||
import * as TuiAudio from "./audio"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
|
||||
import { destroyRenderer } from "./util/renderer"
|
||||
|
||||
const appGlobalBindingCommands = [
|
||||
"session.list",
|
||||
|
|
@ -136,81 +129,19 @@ const appBindingCommands = [
|
|||
"app.toggle.session_directory_filter",
|
||||
] as const
|
||||
|
||||
export type TuiRuntimeInput = {
|
||||
environment: TuiEnvironment
|
||||
build: TuiBuildInfo
|
||||
}
|
||||
|
||||
export type TuiHost = Readonly<{
|
||||
platform: TuiPlatform
|
||||
attention(input: {
|
||||
renderer: CliRenderer
|
||||
config: TuiConfig.Resolved
|
||||
kv: ReturnType<typeof useKV>
|
||||
}): TuiAttention & { dispose(): void }
|
||||
logger: { error(message: string, extra?: Record<string, unknown>): void }
|
||||
lifecycle: Readonly<{
|
||||
prepare?(): (() => void) | undefined
|
||||
flushInput?(): void
|
||||
onSighup?(handler: () => void): () => void
|
||||
writeStdout?(text: string): void
|
||||
writeStderr?(text: string): void
|
||||
suspend?(resume: () => void): void
|
||||
}>
|
||||
disposeAudio?(): void
|
||||
formatError(error: unknown): string | undefined
|
||||
formatUnknownError(error: unknown): string
|
||||
}>
|
||||
|
||||
export function tuiRendererConfig(_config: TuiConfig.Resolved, runtime: TuiRuntimeInput): CliRendererConfig {
|
||||
return {
|
||||
externalOutputMode: "passthrough",
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: {},
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
useMouse: runtime.environment.capabilities.mouse,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createTuiRenderer(config: TuiConfig.Resolved, runtime: TuiRuntimeInput) {
|
||||
return createCliRenderer(tuiRendererConfig(config, runtime))
|
||||
}
|
||||
|
||||
export type TuiHandle = {
|
||||
ready: Promise<void>
|
||||
done: Promise<void>
|
||||
exit: Exit
|
||||
}
|
||||
|
||||
export type TuiInput = TuiRuntimeInput & {
|
||||
export type TuiInput = {
|
||||
url: string
|
||||
args: Args
|
||||
config: TuiConfig.Resolved
|
||||
renderer: CliRenderer
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
directory?: string
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
events?: EventSource
|
||||
pluginHost: TuiPluginHost
|
||||
host: TuiHost
|
||||
}
|
||||
|
||||
type TuiLifecycle = {
|
||||
exit: Exit
|
||||
exited: Promise<void>
|
||||
fail(error: unknown): Promise<never>
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, host: TuiHost) {
|
||||
const formatted = host.formatError(error)
|
||||
if (formatted !== undefined) return formatted
|
||||
function errorMessage(error: unknown) {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
|
|
@ -222,7 +153,7 @@ function errorMessage(error: unknown, host: TuiHost) {
|
|||
) {
|
||||
return error.data.message
|
||||
}
|
||||
return host.formatUnknownError(error)
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function isVersionGreater(left: string, right: string) {
|
||||
|
|
@ -242,223 +173,164 @@ function isVersionGreater(left: string, right: string) {
|
|||
return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true }) > 0
|
||||
}
|
||||
|
||||
export function tui(input: TuiInput): TuiHandle {
|
||||
const unguard = input.host.lifecycle.prepare?.()
|
||||
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
const global = yield* Global.Service
|
||||
const epilogue = { value: undefined as string | undefined }
|
||||
const output = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const renderer = yield* Effect.acquireRelease(
|
||||
Effect.tryPromise(() =>
|
||||
createCliRenderer({
|
||||
externalOutputMode: "passthrough",
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: {},
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
(renderer) => Effect.sync(() => destroyRenderer(renderer)),
|
||||
)
|
||||
win32DisableProcessedInput()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => registerOpencodeKeymap(keymap, renderer, input.config)),
|
||||
(unregister) => Effect.sync(unregister),
|
||||
)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
try {
|
||||
await input.pluginHost.dispose()
|
||||
} catch (error) {
|
||||
console.error("Failed to dispose TUI plugins", error)
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(TuiAudio.dispose))
|
||||
const shutdown = yield* Deferred.make<void>()
|
||||
const onSighup = () => destroyRenderer(renderer)
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => process.on("SIGHUP", onSighup)),
|
||||
() => Effect.sync(() => process.off("SIGHUP", onSighup)),
|
||||
)
|
||||
renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void))
|
||||
const pluginRuntime = createPluginRuntime()
|
||||
|
||||
const renderer = input.renderer
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
|
||||
const pluginRuntime = createPluginRuntime()
|
||||
const lifecycle = createTuiLifecycle({
|
||||
renderer,
|
||||
unguard,
|
||||
host: input.host,
|
||||
cleanup: async () => {
|
||||
unregisterKeymap()
|
||||
try {
|
||||
await input.pluginHost.dispose()
|
||||
} catch (error) {
|
||||
console.error("Failed to dispose TUI plugins", error)
|
||||
} finally {
|
||||
input.host.disposeAudio?.()
|
||||
}
|
||||
},
|
||||
})
|
||||
const ready = mount({ ...input, keymap, pluginRuntime, exit: lifecycle.exit }).catch((error) => lifecycle.fail(error))
|
||||
const done = waitUntilDone(ready, lifecycle.exited)
|
||||
yield* Effect.tryPromise(async () => {
|
||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
if (renderer.isDestroyed) return
|
||||
|
||||
return { ready, done, exit: lifecycle.exit }
|
||||
}
|
||||
|
||||
export async function mount(
|
||||
input: TuiInput & { keymap: ReturnType<typeof createDefaultOpenTuiKeymap>; pluginRuntime: PluginRuntime; exit: Exit },
|
||||
) {
|
||||
const renderer = input.renderer
|
||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
if (renderer.isDestroyed) return
|
||||
|
||||
await render(() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorComponent error={error} reset={reset} exit={input.exit} version={input.build.version} mode={mode} />
|
||||
)}
|
||||
>
|
||||
<TuiEnvironmentProvider value={input.environment}>
|
||||
<TuiPlatformProvider value={input.host.platform}>
|
||||
<TuiBuildInfoProvider value={input.build}>
|
||||
<OpencodeKeymapProvider keymap={input.keymap}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ExitProvider exit={input.exit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<PluginRuntimeProvider value={input.pluginRuntime}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<ProjectProvider>
|
||||
<LegacySyncProvider logger={input.host.logger}>
|
||||
<SyncProviderV2>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalBridge>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App
|
||||
onSnapshot={input.onSnapshot}
|
||||
pluginHost={input.pluginHost}
|
||||
host={input.host}
|
||||
/>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalBridge>
|
||||
</ThemeProvider>
|
||||
</SyncProviderV2>
|
||||
</LegacySyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</TuiBuildInfoProvider>
|
||||
</TuiPlatformProvider>
|
||||
</TuiEnvironmentProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
}
|
||||
|
||||
function LegacySyncProvider(props: ParentProps & { logger: TuiHost["logger"] }) {
|
||||
const kv = useKV()
|
||||
return (
|
||||
<SyncProvider kv={kv} logger={props.logger}>
|
||||
{props.children}
|
||||
</SyncProvider>
|
||||
await render(() => {
|
||||
return (
|
||||
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
cwd: process.cwd(),
|
||||
home: global.home,
|
||||
state: global.state,
|
||||
worktree: global.data + "/worktree",
|
||||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider
|
||||
value={{
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : process.env.DISPLAY ? "x11" : undefined,
|
||||
}}
|
||||
>
|
||||
<TuiStartupProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<EpilogueProvider set={(value) => (epilogue.value = value)}>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<SyncProviderV2>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App
|
||||
onSnapshot={input.onSnapshot}
|
||||
pluginHost={input.pluginHost}
|
||||
/>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProviderV2>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</EpilogueProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiPathsProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
})
|
||||
yield* Deferred.await(shutdown)
|
||||
return epilogue.value
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function LocalBridge(props: ParentProps) {
|
||||
const theme = useTheme()
|
||||
const toast = useToast()
|
||||
const route = useRoute()
|
||||
return (
|
||||
<LocalProvider theme={theme.theme} toast={toast} route={route}>
|
||||
{props.children}
|
||||
</LocalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function createTuiLifecycle(input: {
|
||||
renderer: CliRenderer
|
||||
unguard?: () => void
|
||||
cleanup: () => Promise<void>
|
||||
host: TuiHost
|
||||
}): TuiLifecycle {
|
||||
let resolveExited!: () => void
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
resolveExited = resolve
|
||||
yield* Effect.sync(() => {
|
||||
win32FlushInputBuffer()
|
||||
if (output) process.stdout.write(output + "\n")
|
||||
})
|
||||
let exitCompleted = false
|
||||
let exiting = false
|
||||
let cleanupTask: Promise<void> | undefined
|
||||
})
|
||||
|
||||
const completeExit = () => {
|
||||
if (exitCompleted) return
|
||||
exitCompleted = true
|
||||
resolveExited()
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
cleanupTask ??= (async () => {
|
||||
offSighup?.()
|
||||
try {
|
||||
await input.cleanup()
|
||||
} finally {
|
||||
input.unguard?.()
|
||||
}
|
||||
})()
|
||||
return cleanupTask
|
||||
}
|
||||
|
||||
const exit = createExit(async (reason, message) => {
|
||||
exiting = true
|
||||
await cleanup()
|
||||
if (!input.renderer.isDestroyed) {
|
||||
input.renderer.setTerminalTitle("")
|
||||
input.renderer.destroy()
|
||||
}
|
||||
input.host.lifecycle.flushInput?.()
|
||||
if (reason) {
|
||||
const formatted = input.host.formatError(reason) ?? input.host.formatUnknownError(reason)
|
||||
if (formatted) input.host.lifecycle.writeStderr?.(formatted + "\n")
|
||||
}
|
||||
const text = message()
|
||||
if (text) input.host.lifecycle.writeStdout?.(text + "\n")
|
||||
completeExit()
|
||||
})
|
||||
const onSighup = () => {
|
||||
void exit()
|
||||
}
|
||||
|
||||
input.renderer.once("destroy", () => {
|
||||
if (exiting) return
|
||||
void cleanup().finally(() => {
|
||||
input.host.lifecycle.flushInput?.()
|
||||
completeExit()
|
||||
})
|
||||
})
|
||||
const offSighup = input.host.lifecycle.onSighup?.(onSighup)
|
||||
|
||||
return {
|
||||
exit,
|
||||
exited,
|
||||
async fail(error) {
|
||||
exiting = true
|
||||
await cleanup().catch(() => {})
|
||||
if (!input.renderer.isDestroyed) input.renderer.destroy()
|
||||
completeExit()
|
||||
throw error
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilDone(ready: Promise<void>, exited: Promise<void>) {
|
||||
await ready
|
||||
await exited
|
||||
}
|
||||
|
||||
function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost; host: TuiHost }) {
|
||||
const environment = useTuiEnvironment()
|
||||
const build = useTuiBuildInfo()
|
||||
function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost }) {
|
||||
const startup = useTuiStartup()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
|
@ -474,15 +346,14 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const attention = props.host.attention({ renderer, config: tuiConfig, kv })
|
||||
const platform = useTuiPlatform()
|
||||
const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
|
||||
const clipboard = useClipboard()
|
||||
|
||||
const api = createTuiApi(
|
||||
createTuiApiAdapters({
|
||||
version: build.version,
|
||||
version: InstallationVersion,
|
||||
tuiConfig,
|
||||
dialog,
|
||||
keymap,
|
||||
|
|
@ -518,8 +389,8 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
const offSelectionKeys = keymap.intercept(
|
||||
"key",
|
||||
({ event }) => {
|
||||
if (environment.capabilities.copyOnSelect) return
|
||||
Selection.handleSelectionKey(renderer, toast, event, platform.clipboard)
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
Selection.handleSelectionKey(renderer, toast, event, clipboard)
|
||||
},
|
||||
{ priority: 1 },
|
||||
)
|
||||
|
|
@ -532,8 +403,8 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
renderer.console.onCopySelection = async (text: string) => {
|
||||
if (!text || text.length === 0) return
|
||||
|
||||
await platform.clipboard
|
||||
?.write?.(text)
|
||||
await clipboard
|
||||
.write?.(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
||||
|
|
@ -546,7 +417,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
|
||||
// Update terminal window title based on current route and session
|
||||
createEffect(() => {
|
||||
if (!terminalTitleEnabled() || !environment.capabilities.terminalTitle) return
|
||||
if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
|
||||
|
||||
if (route.data.type === "home") {
|
||||
renderer.setTerminalTitle("OpenCode")
|
||||
|
|
@ -695,8 +566,8 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
run: async () => {
|
||||
const workspace = currentWorktreeWorkspace()
|
||||
if (!workspace?.directory) return
|
||||
await platform.clipboard
|
||||
?.write?.(workspace.directory)
|
||||
await clipboard
|
||||
.write?.(workspace.directory)
|
||||
.then(() => toast.show({ message: "Copied worktree path", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
|
|
@ -706,7 +577,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
name: "workspace.list",
|
||||
title: "Manage workspaces",
|
||||
category: "Workspace",
|
||||
hidden: !environment.capabilities.workspaces,
|
||||
hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
slashName: "workspaces",
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogWorkspaceList />)
|
||||
|
|
@ -915,7 +786,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
title: "Exit the app",
|
||||
slashName: "exit",
|
||||
slashAliases: ["quit", "q"],
|
||||
run: () => exit(),
|
||||
run: () => destroyRenderer(renderer),
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
|
|
@ -955,10 +826,11 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
title: "Suspend terminal",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
enabled: environment.capabilities.terminalSuspend,
|
||||
enabled: process.platform !== "win32",
|
||||
run: () => {
|
||||
renderer.suspend()
|
||||
props.host.lifecycle.suspend?.(() => renderer.resume())
|
||||
process.once("SIGCONT", () => renderer.resume())
|
||||
process.kill(0, "SIGTSTP")
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -1094,7 +966,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
if (workspace !== project.workspace.current()) return
|
||||
const error = evt.properties.error
|
||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||
const message = errorMessage(error, props.host)
|
||||
const message = errorMessage(error)
|
||||
|
||||
toast.show({
|
||||
variant: "error",
|
||||
|
|
@ -1148,7 +1020,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
|
||||
)
|
||||
|
||||
void exit()
|
||||
destroyRenderer(renderer)
|
||||
})
|
||||
|
||||
const plugin = createMemo(() => {
|
||||
|
|
@ -1166,18 +1038,20 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
flexDirection="column"
|
||||
backgroundColor={theme.background}
|
||||
onMouseDown={(evt) => {
|
||||
if (environment.capabilities.copyOnSelect) return
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
if (!Selection.copy(renderer, toast, platform.clipboard)) return
|
||||
if (!Selection.copy(renderer, toast, clipboard)) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={
|
||||
environment.capabilities.copyOnSelect ? () => Selection.copy(renderer, toast, platform.clipboard) : undefined
|
||||
!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT
|
||||
? () => Selection.copy(renderer, toast, clipboard)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Show when={environment.capabilities.showTimeToFirstDraw}>
|
||||
<Show when={Flag.OPENCODE_SHOW_TTFD}>
|
||||
<TimeToFirstDraw />
|
||||
</Show>
|
||||
<Show when={ready()}>
|
||||
|
|
@ -1199,7 +1073,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
</box>
|
||||
<pluginRuntime.Slot name="app" />
|
||||
</Show>
|
||||
<Show when={!environment.skipInitialLoading}>
|
||||
<Show when={!startup.skipInitialLoading}>
|
||||
<StartupLoading ready={ready} />
|
||||
</Show>
|
||||
</box>
|
||||
|
|
|
|||
260
packages/tui/src/attention.ts
Normal file
260
packages/tui/src/attention.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
/// <reference path="./audio.d.ts" />
|
||||
import type {
|
||||
TuiAttention,
|
||||
TuiAttentionNotifyInput,
|
||||
TuiAttentionNotifyResult,
|
||||
TuiAttentionNotifySkipReason,
|
||||
TuiAttentionWhen,
|
||||
TuiKV,
|
||||
TuiAttentionSoundName,
|
||||
TuiAttentionSoundPack,
|
||||
TuiAttentionSoundPackInfo,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
import { AttentionSoundName, type TuiConfig } from "./config"
|
||||
import { Schema } from "effect"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import * as TuiAudio from "./audio"
|
||||
import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
|
||||
import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" }
|
||||
import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" }
|
||||
import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" }
|
||||
import doneSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
|
||||
import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" }
|
||||
|
||||
type FocusState = "unknown" | "focused" | "blurred"
|
||||
|
||||
type AttentionRenderer = {
|
||||
readonly isDestroyed: boolean
|
||||
on(event: "focus" | "blur", listener: () => void): unknown
|
||||
off(event: "focus" | "blur", listener: () => void): unknown
|
||||
triggerNotification(message: string, title?: string): boolean
|
||||
}
|
||||
|
||||
type RegisteredSoundPack = TuiAttentionSoundPack & {
|
||||
builtin: boolean
|
||||
}
|
||||
|
||||
type TuiAttentionHost = TuiAttention & {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
const DEFAULT_TITLE = "opencode"
|
||||
const DEFAULT_PACK_ID = "opencode.default"
|
||||
const KV_SOUND_PACK = "attention_sound_pack"
|
||||
const TITLE_LIMIT = 80
|
||||
const MESSAGE_LIMIT = 240
|
||||
const BUILTIN_PACK: RegisteredSoundPack = {
|
||||
id: DEFAULT_PACK_ID,
|
||||
name: "OpenCode Default",
|
||||
builtin: true,
|
||||
sounds: {
|
||||
default: defaultSoundPath,
|
||||
question: questionSoundPath,
|
||||
permission: permissionSoundPath,
|
||||
error: errorSoundPath,
|
||||
done: doneSoundPath,
|
||||
subagent_done: subagentDoneSoundPath,
|
||||
},
|
||||
}
|
||||
|
||||
function skipped(reason: TuiAttentionNotifySkipReason): TuiAttentionNotifyResult {
|
||||
return {
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: reason,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(input: string | undefined, fallback: string, limit: number) {
|
||||
const text = stripAnsi(input ?? "")
|
||||
.replace(/[ \t]*[\r\n]+[ \t]*/g, " ")
|
||||
.replace(/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, "")
|
||||
.trim()
|
||||
const normalized = text.length ? text : fallback
|
||||
return Array.from(normalized).slice(0, limit).join("")
|
||||
}
|
||||
|
||||
function clampVolume(volume: number) {
|
||||
if (!Number.isFinite(volume)) return 0
|
||||
return Math.min(1, Math.max(0, volume))
|
||||
}
|
||||
|
||||
function soundVolume(input: TuiAttentionNotifyInput, config: Pick<TuiConfig.Resolved, "attention">) {
|
||||
if (!config.attention.sound) return
|
||||
if (input.sound === false) return
|
||||
if (input.sound === undefined) return clampVolume(config.attention.volume)
|
||||
if (input.sound === true) return clampVolume(config.attention.volume)
|
||||
return clampVolume(input.sound.volume ?? config.attention.volume)
|
||||
}
|
||||
|
||||
function normalizePack(pack: TuiAttentionSoundPack): RegisteredSoundPack | undefined {
|
||||
const id = pack.id.trim()
|
||||
if (!id) return
|
||||
return {
|
||||
id,
|
||||
name: pack.name?.trim() || undefined,
|
||||
builtin: false,
|
||||
sounds: Object.fromEntries(
|
||||
Object.entries(pack.sounds).filter(
|
||||
(item): item is [TuiAttentionSoundName, string] =>
|
||||
Schema.is(AttentionSoundName)(item[0]) && typeof item[1] === "string" && item[1].trim().length > 0,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
|
||||
if (when === "always") return
|
||||
if (focus === "unknown") return "focus_unknown"
|
||||
if (when === "blurred" && focus === "focused") return "focused"
|
||||
if (when === "focused" && focus === "blurred") return "blurred"
|
||||
}
|
||||
|
||||
export function createTuiAttention(input: {
|
||||
renderer: AttentionRenderer
|
||||
config: Pick<TuiConfig.Resolved, "attention">
|
||||
kv?: TuiKV
|
||||
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
|
||||
}): TuiAttentionHost {
|
||||
let focus: FocusState = "unknown"
|
||||
let disposed = false
|
||||
let activePackID: string | undefined
|
||||
const packs = new Map<string, RegisteredSoundPack>([[BUILTIN_PACK.id, BUILTIN_PACK]])
|
||||
const audio = input.audio ?? TuiAudio
|
||||
|
||||
const onFocus = () => {
|
||||
focus = "focused"
|
||||
}
|
||||
const onBlur = () => {
|
||||
focus = "blurred"
|
||||
}
|
||||
|
||||
input.renderer.on("focus", onFocus)
|
||||
input.renderer.on("blur", onBlur)
|
||||
|
||||
function configuredPackID() {
|
||||
const stored = input.kv?.get<string | undefined>(KV_SOUND_PACK, undefined)
|
||||
return activePackID ?? stored ?? input.config.attention.sound_pack
|
||||
}
|
||||
|
||||
function currentPack() {
|
||||
return packs.get(configuredPackID()) ?? BUILTIN_PACK
|
||||
}
|
||||
|
||||
function soundCandidates(name: TuiAttentionSoundName) {
|
||||
return [input.config.attention.sounds[name], currentPack().sounds[name], BUILTIN_PACK.sounds[name]].filter(
|
||||
(item, index, list): item is string => typeof item === "string" && list.indexOf(item) === index,
|
||||
)
|
||||
}
|
||||
|
||||
async function playSound(name: TuiAttentionSoundName, volume: number) {
|
||||
try {
|
||||
for (const file of soundCandidates(name)) {
|
||||
const current = await audio.loadSoundFile(file).catch((error) => {
|
||||
console.debug("failed to load attention sound", { file, error })
|
||||
return null
|
||||
})
|
||||
if (disposed) return false
|
||||
if (current == null) continue
|
||||
if (audio.play(current, { volume }) != null) return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.debug("failed to play attention sound", { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async notify(request) {
|
||||
try {
|
||||
if (!input.config.attention.enabled) return skipped("attention_disabled")
|
||||
if (disposed || input.renderer.isDestroyed) return skipped("renderer_destroyed")
|
||||
|
||||
const message = normalizeText(request.message, "", MESSAGE_LIMIT)
|
||||
if (!message) return skipped("empty_message")
|
||||
|
||||
const requestedNotification = typeof request.notification === "object" ? request.notification : undefined
|
||||
const notificationSkip = focusSkip(requestedNotification?.when ?? "blurred", focus)
|
||||
const notificationRequested = input.config.attention.notifications && request.notification !== false
|
||||
const shouldNotify = notificationRequested && !notificationSkip
|
||||
const notification = shouldNotify
|
||||
? (() => {
|
||||
try {
|
||||
return input.renderer.triggerNotification(
|
||||
message,
|
||||
normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT),
|
||||
)
|
||||
} catch (error) {
|
||||
console.debug("failed to trigger attention notification", { error })
|
||||
return false
|
||||
}
|
||||
})()
|
||||
: false
|
||||
const volume = soundVolume(request, input.config)
|
||||
const requestedSound = typeof request.sound === "object" ? request.sound : undefined
|
||||
const soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus)
|
||||
const soundName =
|
||||
requestedSound?.name && Schema.is(AttentionSoundName)(requestedSound.name) ? requestedSound.name : "default"
|
||||
const sound = volume === undefined || soundSkip ? false : await playSound(soundName, volume)
|
||||
|
||||
if (!notification && !sound) {
|
||||
if (notificationRequested && notificationSkip) return skipped(notificationSkip)
|
||||
if (soundSkip) return skipped(soundSkip)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: notification || sound,
|
||||
notification,
|
||||
sound,
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug("failed to handle attention notification", { error })
|
||||
return {
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
soundboard: {
|
||||
registerPack(pack) {
|
||||
const next = normalizePack(pack)
|
||||
if (!next) return () => {}
|
||||
packs.set(next.id, next)
|
||||
let disposed = false
|
||||
return () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
if (packs.get(next.id) === next) packs.delete(next.id)
|
||||
}
|
||||
},
|
||||
activate(id, options) {
|
||||
const pack = packs.get(id)
|
||||
if (!pack) return false
|
||||
activePackID = pack.id
|
||||
if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id)
|
||||
return true
|
||||
},
|
||||
current() {
|
||||
return currentPack().id
|
||||
},
|
||||
list(): TuiAttentionSoundPackInfo[] {
|
||||
const current = currentPack().id
|
||||
return Array.from(packs.values()).map((pack) => ({
|
||||
id: pack.id,
|
||||
name: pack.name,
|
||||
active: pack.id === current,
|
||||
builtin: pack.builtin,
|
||||
}))
|
||||
},
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
input.renderer.off("focus", onFocus)
|
||||
input.renderer.off("blur", onBlur)
|
||||
},
|
||||
}
|
||||
}
|
||||
9
packages/tui/src/audio.d.ts
vendored
Normal file
9
packages/tui/src/audio.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
declare module "*.mp3" {
|
||||
const path: string
|
||||
export default path
|
||||
}
|
||||
|
||||
declare module "@opencode-ai/ui/audio/*.mp3" {
|
||||
const path: string
|
||||
export default path
|
||||
}
|
||||
53
packages/tui/src/audio.ts
Normal file
53
packages/tui/src/audio.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core"
|
||||
import { readFile } from "node:fs/promises"
|
||||
|
||||
let audio: Audio | null | undefined
|
||||
const sounds = new Map<string, Promise<AudioSound | null>>()
|
||||
|
||||
function getAudio() {
|
||||
if (audio !== undefined) return audio
|
||||
try {
|
||||
const next = Audio.create({ autoStart: false })
|
||||
next.on("error", (error: Error, context: AudioErrorContext) => {
|
||||
console.debug("tui audio error", { error, context })
|
||||
})
|
||||
audio = next
|
||||
return next
|
||||
} catch (error) {
|
||||
console.debug("failed to create tui audio", { error })
|
||||
audio = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSoundFile(file: string) {
|
||||
const current = getAudio()
|
||||
if (!current) return Promise.resolve(null)
|
||||
const cached = sounds.get(file)
|
||||
if (cached) return cached
|
||||
const task = readFile(file)
|
||||
.then((bytes) => current.loadSound(bytes))
|
||||
.catch((error) => {
|
||||
console.debug("failed to load tui sound", { file, error })
|
||||
return null
|
||||
})
|
||||
sounds.set(file, task)
|
||||
return task
|
||||
}
|
||||
|
||||
export function play(sound: AudioSound, options?: AudioPlayOptions) {
|
||||
const current = getAudio()
|
||||
if (!current) return null
|
||||
if (!current.isStarted() && !current.start()) return null
|
||||
return current.play(sound, options)
|
||||
}
|
||||
|
||||
export function stopVoice(voice: AudioVoice) {
|
||||
return audio?.stopVoice(voice) ?? false
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
audio?.dispose()
|
||||
audio = undefined
|
||||
sounds.clear()
|
||||
}
|
||||
120
packages/tui/src/clipboard.ts
Normal file
120
packages/tui/src/clipboard.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { execFile, spawn } from "node:child_process"
|
||||
import { readFile, rm } from "node:fs/promises"
|
||||
import { platform, release, tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { promisify } from "node:util"
|
||||
|
||||
const exec = promisify(execFile)
|
||||
|
||||
function command(command: string, args: string[] = [], input?: string) {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
|
||||
const output: Buffer[] = []
|
||||
child.on("error", reject)
|
||||
child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) return resolve(Buffer.concat(output))
|
||||
reject(new Error(`${command} exited with code ${code}`))
|
||||
})
|
||||
if (input !== undefined) child.stdin?.end(input)
|
||||
})
|
||||
}
|
||||
|
||||
function writeOsc52(text: string) {
|
||||
if (!process.stdout.isTTY) return
|
||||
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
|
||||
process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence)
|
||||
}
|
||||
|
||||
export async function read() {
|
||||
if (platform() === "darwin") {
|
||||
const file = path.join(tmpdir(), "opencode-clipboard.png")
|
||||
try {
|
||||
await exec("osascript", [
|
||||
"-e",
|
||||
'set imageData to the clipboard as "PNGf"',
|
||||
"-e",
|
||||
`set fileRef to open for access POSIX file "${file}" with write permission`,
|
||||
"-e",
|
||||
"set eof fileRef to 0",
|
||||
"-e",
|
||||
"write imageData to fileRef",
|
||||
"-e",
|
||||
"close access fileRef",
|
||||
])
|
||||
return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
|
||||
} catch {
|
||||
// Fall through to text clipboard.
|
||||
} finally {
|
||||
await rm(file, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
if (platform() === "win32" || release().includes("WSL")) {
|
||||
const script =
|
||||
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
|
||||
const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() =>
|
||||
Buffer.alloc(0),
|
||||
)
|
||||
if (image.length) return { data: image.toString().trim(), mime: "image/png" }
|
||||
}
|
||||
|
||||
if (platform() === "linux") {
|
||||
const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0))
|
||||
if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" }
|
||||
const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() =>
|
||||
Buffer.alloc(0),
|
||||
)
|
||||
if (x11.length) return { data: x11.toString("base64"), mime: "image/png" }
|
||||
}
|
||||
|
||||
const { default: clipboardy } = await import("clipboardy")
|
||||
const text = await clipboardy.read().catch(() => undefined)
|
||||
if (text) return { data: text, mime: "text/plain" }
|
||||
}
|
||||
|
||||
export function copyCommand(os: NodeJS.Platform, wayland: boolean, has: (name: string) => boolean): string[] | undefined {
|
||||
if (os === "darwin" && has("osascript")) return ["osascript"]
|
||||
if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"]
|
||||
if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"]
|
||||
if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"]
|
||||
if (os === "win32" && has("powershell.exe")) {
|
||||
return [
|
||||
"powershell.exe",
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
let copyMethod: Promise<(text: string) => Promise<void>> | undefined
|
||||
|
||||
function getCopyMethod() {
|
||||
return (copyMethod ??= (async () => {
|
||||
const { which } = await import("@opencode-ai/core/util/which")
|
||||
const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name)))
|
||||
if (native?.[0] === "osascript") {
|
||||
return async (text: string) => {
|
||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
if (native) {
|
||||
return async (text: string) => {
|
||||
await command(native[0], native.slice(1), text).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
return async (text: string) => {
|
||||
const { default: clipboardy } = await import("clipboardy")
|
||||
await clipboardy.write(text).catch(() => undefined)
|
||||
}
|
||||
})())
|
||||
}
|
||||
|
||||
export async function write(text: string) {
|
||||
writeOsc52(text)
|
||||
const method = await getCopyMethod()
|
||||
await method(text)
|
||||
}
|
||||
|
|
@ -7,7 +7,8 @@ import { useDialog } from "../ui/dialog"
|
|||
import { useSDK } from "../context/sdk"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSync } from "../context/sync"
|
||||
import { abbreviateHome, useTuiEnvironment } from "../runtime"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { Locale } from "../util/locale"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
|
@ -32,7 +33,7 @@ export function DialogMoveSession(props: {
|
|||
const sync = useSync()
|
||||
const projectContext = useProject()
|
||||
const toast = useToast()
|
||||
const environment = useTuiEnvironment()
|
||||
const paths = useTuiPaths()
|
||||
const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [removing, setRemoving] = createSignal(props.initialRemoving)
|
||||
|
|
@ -101,7 +102,7 @@ export function DialogMoveSession(props: {
|
|||
})
|
||||
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
|
||||
return list.map((item) => {
|
||||
const title = abbreviateHome(item.location, environment.paths.home)
|
||||
const title = abbreviateHome(item.location, paths.home)
|
||||
const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location)
|
||||
const visible = Locale.truncateLeft(title, titleWidth)
|
||||
const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { useToast } from "../ui/toast"
|
|||
import { isConsoleManagedProvider } from "../util/provider-origin"
|
||||
import { useConnected } from "./use-connected"
|
||||
import { useBindings } from "../keymap"
|
||||
import { useTuiPlatform } from "../platform"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
|
||||
const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
opencode: 0,
|
||||
|
|
@ -242,7 +242,7 @@ function AutoMethod(props: AutoMethodProps) {
|
|||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const platform = useTuiPlatform()
|
||||
const clipboard = useClipboard()
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
|
|
@ -253,8 +253,8 @@ function AutoMethod(props: AutoMethodProps) {
|
|||
cmd: () => {
|
||||
const code =
|
||||
props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
|
||||
platform.clipboard
|
||||
?.write?.(code)
|
||||
clipboard
|
||||
.write?.(code)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useProject } from "../context/project"
|
|||
import { useTheme } from "../context/theme"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useLocal } from "../context/local"
|
||||
import { useTuiEnvironment } from "../runtime"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
|
@ -27,7 +27,6 @@ export function DialogSessionList() {
|
|||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const local = useLocal()
|
||||
const environment = useTuiEnvironment()
|
||||
const toast = useToast()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
|
|
@ -174,7 +173,7 @@ export function DialogSessionList() {
|
|||
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
||||
|
||||
let footer: JSX.Element | string = ""
|
||||
if (environment.capabilities.workspaces) {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
if (x.workspaceID) {
|
||||
footer = workspace ? (
|
||||
<WorkspaceLabel
|
||||
|
|
|
|||
|
|
@ -1,22 +1,23 @@
|
|||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import { useTuiPlatform } from "../platform"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { destroyRenderer } from "../util/renderer"
|
||||
|
||||
export function ErrorComponent(props: {
|
||||
error: Error
|
||||
reset: () => void
|
||||
exit: () => Promise<void>
|
||||
version: string
|
||||
mode?: "dark" | "light"
|
||||
}) {
|
||||
const term = useTerminalDimensions()
|
||||
const platform = useTuiPlatform()
|
||||
const renderer = useRenderer()
|
||||
const clipboard = useClipboard()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
void props.exit()
|
||||
destroyRenderer(renderer)
|
||||
}
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
|
@ -43,10 +44,10 @@ export function ErrorComponent(props: {
|
|||
)
|
||||
}
|
||||
|
||||
issueURL.searchParams.set("opencode-version", props.version)
|
||||
issueURL.searchParams.set("opencode-version", InstallationVersion)
|
||||
|
||||
const copyIssueURL = () => {
|
||||
void platform.clipboard?.write?.(issueURL.toString()).then(() => {
|
||||
void clipboard.write?.(issueURL.toString()).then(() => {
|
||||
setCopied(true)
|
||||
})
|
||||
}
|
||||
|
|
@ -69,7 +70,7 @@ export function ErrorComponent(props: {
|
|||
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Reset TUI</text>
|
||||
</box>
|
||||
<box onMouseUp={() => void props.exit()} backgroundColor={colors.primary} padding={1}>
|
||||
<box onMouseUp={() => destroyRenderer(renderer)} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Exit</text>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { useProject } from "../../context/project"
|
|||
import { useSDK } from "../../context/sdk"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiEnvironment } from "../../runtime"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useTuiConfig } from "../../config"
|
||||
import { useTheme, selectedForeground } from "../../context/theme"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
|
|
@ -92,7 +92,7 @@ export function Autocomplete(props: {
|
|||
const dimensions = useTerminalDimensions()
|
||||
const frecency = useFrecency()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const environment = useTuiEnvironment()
|
||||
const paths = useTuiPaths()
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
selected: 0,
|
||||
|
|
@ -236,7 +236,7 @@ export function Autocomplete(props: {
|
|||
}
|
||||
|
||||
function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) {
|
||||
const baseDir = (sync.path.directory || environment.cwd).replace(/\/+$/, "")
|
||||
const baseDir = (sync.path.directory || paths.cwd).replace(/\/+$/, "")
|
||||
const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item)
|
||||
const urlObj = pathToFileURL(fullPath)
|
||||
const filename =
|
||||
|
|
@ -306,7 +306,7 @@ export function Autocomplete(props: {
|
|||
})
|
||||
|
||||
function normalizeMentionPath(filePath: string) {
|
||||
const baseDir = sync.path.directory || environment.cwd
|
||||
const baseDir = sync.path.directory || paths.cwd
|
||||
const absolute = path.resolve(filePath)
|
||||
const relative = path.relative(baseDir, absolute)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@ import "opentui-spinner/solid"
|
|||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { useLocal } from "../../context/local"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { tint, useTheme } from "../../context/theme"
|
||||
import { EmptyBorder, SplitBorder } from "../../ui/border"
|
||||
import { useTuiEnvironment } from "../../runtime"
|
||||
import { useTuiPlatform } from "../../platform"
|
||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import { Spinner } from "../spinner"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useRoute } from "../../context/route"
|
||||
|
|
@ -25,6 +26,8 @@ import { useProject } from "../../context/project"
|
|||
import { useSync } from "../../context/sync"
|
||||
import { useEvent } from "../../context/event"
|
||||
import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
|
||||
import { openEditor } from "../../editor"
|
||||
import { destroyRenderer } from "../../util/renderer"
|
||||
import { promptOffsetWidth } from "../../prompt/display"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { usePromptHistory, type PromptInfo } from "../../prompt/history"
|
||||
|
|
@ -34,7 +37,6 @@ import { usePromptStash } from "../../prompt/stash"
|
|||
import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useExit } from "../../context/exit"
|
||||
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { errorMessage } from "../../util/error"
|
||||
|
|
@ -143,8 +145,9 @@ export function Prompt(props: PromptProps) {
|
|||
const leader = useLeaderActive()
|
||||
const local = useLocal()
|
||||
const args = useArgs()
|
||||
const environment = useTuiEnvironment()
|
||||
const platform = useTuiPlatform()
|
||||
const paths = useTuiPaths()
|
||||
const terminalEnvironment = useTuiTerminalEnvironment()
|
||||
const clipboard = useClipboard()
|
||||
const sdk = useSDK()
|
||||
const editor = useEditorContext()
|
||||
const route = useRoute()
|
||||
|
|
@ -366,7 +369,7 @@ export function Prompt(props: PromptProps) {
|
|||
run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
|
||||
ctx.event.preventDefault()
|
||||
ctx.event.stopPropagation()
|
||||
const content = await platform.clipboard?.read?.()
|
||||
const content = await clipboard.read?.()
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
await pasteAttachment({
|
||||
filename: "clipboard",
|
||||
|
|
@ -430,12 +433,13 @@ export function Prompt(props: PromptProps) {
|
|||
const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
|
||||
|
||||
const value = text
|
||||
const content = await platform.editor?.open({
|
||||
const content = await openEditor({
|
||||
renderer,
|
||||
value,
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
environment.cwd,
|
||||
paths.cwd,
|
||||
})
|
||||
if (!content) return
|
||||
|
||||
|
|
@ -526,7 +530,7 @@ export function Prompt(props: PromptProps) {
|
|||
desc: "Change the workspace for the session",
|
||||
name: "workspace.set",
|
||||
category: "Session",
|
||||
enabled: environment.capabilities.workspaces,
|
||||
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
slashName: "warp",
|
||||
run: () => {
|
||||
workspace.open()
|
||||
|
|
@ -950,7 +954,7 @@ export function Prompt(props: PromptProps) {
|
|||
if (!agent) return false
|
||||
const trimmed = store.prompt.input.trim()
|
||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||
void exit()
|
||||
destroyRenderer(renderer)
|
||||
return true
|
||||
}
|
||||
const selectedModel = local.model.current()
|
||||
|
|
@ -1124,7 +1128,6 @@ export function Prompt(props: PromptProps) {
|
|||
if (finishMoveProgress) move.finishSubmit()
|
||||
return true
|
||||
}
|
||||
const exit = useExit()
|
||||
|
||||
function pasteText(text: string, virtualText: string) {
|
||||
const currentOffset = input.cursorOffset
|
||||
|
|
@ -1163,10 +1166,10 @@ export function Prompt(props: PromptProps) {
|
|||
async function pasteInputText(text: string) {
|
||||
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
const filepath = pastedFilepath(pastedContent, environment.platform)
|
||||
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
|
||||
const isUrl = /^(https?):\/\//.test(filepath)
|
||||
if (!isUrl) {
|
||||
const attachment = await readLocalAttachment(platform.files, filepath)
|
||||
const attachment = await readLocalAttachment(filepath)
|
||||
const filename = path.basename(filepath)
|
||||
if (attachment?.type === "text") {
|
||||
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,39 @@
|
|||
import type { PlatformFiles } from "../../platform"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export type LocalFiles = Readonly<{
|
||||
readText(path: string): Promise<string>
|
||||
readBytes(path: string): Promise<Uint8Array>
|
||||
mime(path: string): Promise<string>
|
||||
}>
|
||||
|
||||
export type LocalAttachment =
|
||||
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
|
||||
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
|
||||
|
||||
export async function readLocalAttachment(files: PlatformFiles, path: string): Promise<LocalAttachment | undefined> {
|
||||
export function readLocalAttachment(file: string) {
|
||||
return readLocalAttachmentWith(
|
||||
{
|
||||
readText: (value) => readFile(value, "utf8"),
|
||||
readBytes: (value) => readFile(value),
|
||||
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
|
||||
},
|
||||
file,
|
||||
)
|
||||
}
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".avif": "image/avif",
|
||||
".gif": "image/gif",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
|
||||
const mime = await files.mime(path).catch(() => undefined)
|
||||
if (!mime) return
|
||||
if (mime === "image/svg+xml") {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import path from "path"
|
||||
import { useTuiEnvironment } from "../../runtime"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
|
|
@ -20,7 +20,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
|||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const homeDestination = useHomeSessionDestination()
|
||||
const environment = useTuiEnvironment()
|
||||
const paths = useTuiPaths()
|
||||
const [creating, setCreating] = createSignal(false)
|
||||
const [creatingDots, setCreatingDots] = createSignal(3)
|
||||
const [progress, setProgress] = createSignal<string>()
|
||||
|
|
@ -35,7 +35,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
|||
{
|
||||
projectID,
|
||||
strategy: "git_worktree",
|
||||
directory: path.join(environment.paths.worktree, projectID.slice(0, 6)),
|
||||
directory: path.join(paths.worktree, projectID.slice(0, 6)),
|
||||
context,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
|
|
|
|||
18
packages/tui/src/context/clipboard.tsx
Normal file
18
packages/tui/src/context/clipboard.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { createContext, type JSX, useContext } from "solid-js"
|
||||
import { read, write } from "../clipboard"
|
||||
|
||||
export type ClipboardContent = Readonly<{ data: string; mime: string }>
|
||||
export type ClipboardService = Readonly<{
|
||||
read?(): Promise<ClipboardContent | undefined>
|
||||
write?(text: string): Promise<void>
|
||||
}>
|
||||
const clipboard = { read, write }
|
||||
const ClipboardContext = createContext<ClipboardService>(clipboard)
|
||||
|
||||
export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
|
||||
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
|
||||
}
|
||||
|
||||
export function useClipboard() {
|
||||
return useContext(ClipboardContext)
|
||||
}
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
import { createMemo } from "solid-js"
|
||||
import { useProject } from "./project"
|
||||
import { useSync } from "./sync"
|
||||
import { abbreviateHome, useTuiEnvironment } from "../runtime"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
|
||||
export function useDirectory() {
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const environment = useTuiEnvironment()
|
||||
const paths = useTuiPaths()
|
||||
return createMemo(() => {
|
||||
const directory = project.instance.path().directory || environment.cwd
|
||||
const result = abbreviateHome(directory, environment.paths.home)
|
||||
const directory = project.instance.path().directory || paths.cwd
|
||||
const result = abbreviateHome(directory, paths.home)
|
||||
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
|
||||
return result
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { onCleanup, onMount } from "solid-js"
|
|||
import { createStore } from "solid-js/store"
|
||||
import { Option, Schema, SchemaGetter } from "effect"
|
||||
import { isRecord } from "../util/record"
|
||||
import { useTuiEnvironment } from "../runtime"
|
||||
import { useOptionalTuiPlatform } from "../platform"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { editorIntegration } from "../editor"
|
||||
|
||||
const MCP_PROTOCOL_VERSION = "2025-11-25"
|
||||
|
||||
|
|
@ -104,11 +104,20 @@ type EditorConnection = {
|
|||
source: string
|
||||
}
|
||||
|
||||
export type EditorIntegration = Readonly<{
|
||||
connection?(directory: string): EditorConnection | undefined
|
||||
selection?(directory: string): Promise<unknown>
|
||||
}>
|
||||
|
||||
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
|
||||
name: "EditorContext",
|
||||
init: (props: { WebSocketImpl?: typeof WebSocket }) => {
|
||||
const environment = useTuiEnvironment()
|
||||
const platform = useOptionalTuiPlatform()
|
||||
init: (props: { integration?: EditorIntegration; WebSocketImpl?: typeof WebSocket }) => {
|
||||
const paths = useTuiPaths()
|
||||
const editor = props.integration ?? editorIntegration
|
||||
const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT
|
||||
const parsedPort = value ? Number.parseInt(value, 10) : undefined
|
||||
const port = parsedPort && Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : undefined
|
||||
const zedTerminal = process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
|
||||
const mentionListeners = new Set<(mention: EditorMention) => void>()
|
||||
const WebSocketImpl = props.WebSocketImpl ?? WebSocket
|
||||
const [store, setStore] = createStore<{
|
||||
|
|
@ -130,7 +139,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||
let requestID = 0
|
||||
let zedSelection: Promise<void> | undefined
|
||||
let lastZedSelectionKey: string | undefined
|
||||
let directory = environment.cwd
|
||||
let directory = paths.cwd
|
||||
let preserveSelectionOnReconnect = false
|
||||
const pending = new Map<number, string>()
|
||||
|
||||
|
|
@ -163,20 +172,20 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||
const connect = () => {
|
||||
if (closed) return
|
||||
|
||||
const connection = resolveEditorConnection(directory, environment.editor.port, platform?.editor?.connection)
|
||||
const connection = resolveEditorConnection(directory, port, editor.connection)
|
||||
if (!connection) {
|
||||
if (!environment.editor.zedTerminal) {
|
||||
if (!zedTerminal) {
|
||||
setStore("status", "disabled")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
if (!editor.selection) {
|
||||
setStore("status", "disabled")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (!platform?.editor?.selection) {
|
||||
setStore("status", "disabled")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
zedSelection ??= platform.editor
|
||||
zedSelection ??= editor
|
||||
.selection(directory)
|
||||
.then((result) => {
|
||||
if (closed || socket) return
|
||||
|
|
@ -278,7 +287,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||
}
|
||||
|
||||
const reconnectWithDirectory = (nextDirectory?: string) => {
|
||||
const resolved = nextDirectory || environment.cwd
|
||||
const resolved = nextDirectory || paths.cwd
|
||||
const sameDirectory = directory === resolved
|
||||
clearSelectionForReconnect({ resetZedSelectionKey: !sameDirectory })
|
||||
if (sameDirectory) return
|
||||
|
|
@ -311,8 +320,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||
return {
|
||||
enabled() {
|
||||
return Boolean(
|
||||
resolveEditorConnection(directory, environment.editor.port, platform?.editor?.connection) ||
|
||||
(environment.editor.zedTerminal && platform?.editor?.selection),
|
||||
resolveEditorConnection(directory, port, editor.connection) || (zedTerminal && editor.selection),
|
||||
)
|
||||
},
|
||||
connected() {
|
||||
|
|
|
|||
6
packages/tui/src/context/epilogue.tsx
Normal file
6
packages/tui/src/context/epilogue.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export const { use: useEpilogue, provider: EpilogueProvider } = createSimpleContext({
|
||||
name: "Epilogue",
|
||||
init: (props: { set(value?: string): void }) => props.set,
|
||||
})
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export type Exit = ((reason?: unknown) => Promise<void>) & {
|
||||
message: {
|
||||
set: (value?: string) => () => void
|
||||
clear: () => void
|
||||
get: () => string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function createExit(run: (reason: unknown | undefined, message: () => string | undefined) => Promise<void>) {
|
||||
let message: string | undefined
|
||||
let task: Promise<void> | undefined
|
||||
const store = {
|
||||
set: (value?: string) => {
|
||||
const prev = message
|
||||
message = value
|
||||
return () => {
|
||||
message = prev
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
message = undefined
|
||||
},
|
||||
get: () => message,
|
||||
}
|
||||
|
||||
return Object.assign(
|
||||
(reason?: unknown) => {
|
||||
task ??= run(reason, store.get)
|
||||
return task
|
||||
},
|
||||
{
|
||||
message: store,
|
||||
},
|
||||
) satisfies Exit
|
||||
}
|
||||
|
||||
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
name: "Exit",
|
||||
init: (input: { exit: Exit }) => input.exit,
|
||||
})
|
||||
|
|
@ -1,18 +1,25 @@
|
|||
import { createSignal, type Setter } from "solid-js"
|
||||
import { createStore, unwrap } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useOptionalTuiPlatform } from "../platform"
|
||||
import { Flock } from "@opencode-ai/core/util/flock"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
import path from "path"
|
||||
|
||||
export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
||||
name: "KV",
|
||||
init: () => {
|
||||
const platform = useOptionalTuiPlatform()
|
||||
const paths = useTuiPaths()
|
||||
void Global.Path.state
|
||||
const file = path.join(paths.state, "kv.json")
|
||||
const lock = `tui-kv:${file}`
|
||||
const [ready, setReady] = createSignal(false)
|
||||
const [store, setStore] = createStore<Record<string, any>>()
|
||||
// Queue same-process writes so rapid updates persist in order.
|
||||
let write = Promise.resolve()
|
||||
|
||||
;(platform?.state?.read() ?? Promise.resolve({}))
|
||||
;Flock.withLock(lock, () => readJson<Record<string, unknown>>(file))
|
||||
.then((x) => {
|
||||
setStore(x)
|
||||
})
|
||||
|
|
@ -48,7 +55,9 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
|||
setStore(key, value)
|
||||
const snapshot = structuredClone(unwrap(store))
|
||||
write = write
|
||||
.then(() => platform?.state?.write(snapshot))
|
||||
.then(() =>
|
||||
Flock.withLock(lock, () => writeJsonAtomic(file, snapshot)),
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error("Failed to write KV state", { error })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ import { batch, createEffect, createMemo } from "solid-js"
|
|||
import { useSync } from "./sync"
|
||||
import { useEvent } from "./event"
|
||||
import path from "path"
|
||||
import { useTuiEnvironment } from "../runtime"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
import { useArgs } from "./args"
|
||||
import { useSDK } from "./sdk"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
import { useTheme } from "./theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useRoute } from "./route"
|
||||
|
||||
export type LocalTheme = {
|
||||
secondary: RGBA
|
||||
|
|
@ -20,17 +23,6 @@ export type LocalTheme = {
|
|||
info: RGBA
|
||||
}
|
||||
|
||||
export type LocalDependencies = {
|
||||
theme: LocalTheme
|
||||
toast: {
|
||||
show(options: { variant: "info" | "warning" | "error"; message: string; duration?: number }): void
|
||||
}
|
||||
route: {
|
||||
readonly data: { type: string; sessionID?: string }
|
||||
navigate(route: { type: "session"; sessionID: string }): void
|
||||
}
|
||||
}
|
||||
|
||||
export function parseModel(model: string) {
|
||||
const [providerID, ...rest] = model.split("/")
|
||||
return {
|
||||
|
|
@ -57,11 +49,13 @@ export function recentModels(
|
|||
|
||||
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
init: (props: LocalDependencies) => {
|
||||
init: () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = props.toast
|
||||
const environment = useTuiEnvironment()
|
||||
const toast = useToast()
|
||||
const theme = useTheme().theme
|
||||
const route = useRoute()
|
||||
const paths = useTuiPaths()
|
||||
|
||||
function isModelValid(model: { providerID: string; modelID: string }) {
|
||||
const provider = sync.data.provider.find((x) => x.id === model.providerID)
|
||||
|
|
@ -82,7 +76,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
const [agentStore, setAgentStore] = createStore({
|
||||
current: undefined as string | undefined,
|
||||
})
|
||||
const theme = props.theme
|
||||
const colors = createMemo(() => [
|
||||
theme.secondary,
|
||||
theme.accent,
|
||||
|
|
@ -164,7 +157,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
variant: {},
|
||||
})
|
||||
|
||||
const filePath = path.join(environment.paths.state, "model.json")
|
||||
const filePath = path.join(paths.state, "model.json")
|
||||
const state = {
|
||||
pending: false,
|
||||
}
|
||||
|
|
@ -421,7 +414,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
pinned: [],
|
||||
})
|
||||
|
||||
const filePath = path.join(environment.paths.state, "session.json")
|
||||
const filePath = path.join(paths.state, "session.json")
|
||||
const state = {
|
||||
pending: false,
|
||||
}
|
||||
|
|
@ -453,7 +446,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
if (state.pending) save()
|
||||
})
|
||||
|
||||
const route = props.route
|
||||
const event = useEvent()
|
||||
|
||||
const slots = createMemo(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import path from "path"
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
import { abbreviateHome, useTuiEnvironment } from "../runtime"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
|
||||
const context = createContext<{
|
||||
path: () => string
|
||||
|
|
@ -8,12 +9,12 @@ const context = createContext<{
|
|||
}>()
|
||||
|
||||
export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) {
|
||||
const environment = useTuiEnvironment()
|
||||
const paths = useTuiPaths()
|
||||
return (
|
||||
<context.Provider
|
||||
value={{
|
||||
path: () => props.path || environment.cwd,
|
||||
format: (input) => formatPath(input, props.path || environment.cwd, environment.paths.home),
|
||||
path: () => props.path || paths.cwd,
|
||||
format: (input) => formatPath(input, props.path || paths.cwd, paths.home),
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import type { PromptInfo } from "../prompt/history"
|
||||
import { useTuiEnvironment } from "../runtime"
|
||||
import { useTuiStartup } from "./runtime"
|
||||
|
||||
export type HomeRoute = {
|
||||
type: "home"
|
||||
|
|
@ -25,9 +25,9 @@ export type Route = HomeRoute | SessionRoute | PluginRoute
|
|||
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
name: "Route",
|
||||
init: (props: { initialRoute?: Route }) => {
|
||||
const environment = useTuiEnvironment()
|
||||
const startup = useTuiStartup()
|
||||
const [store, setStore] = createStore<Route>(
|
||||
props.initialRoute ?? initialRoute(environment.initialRoute) ?? { type: "home" },
|
||||
props.initialRoute ?? initialRoute(startup.initialRoute) ?? { type: "home" },
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
|||
62
packages/tui/src/context/runtime.tsx
Normal file
62
packages/tui/src/context/runtime.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { createComponent, createContext, type JSX, useContext } from "solid-js"
|
||||
|
||||
export type TuiPaths = Readonly<{
|
||||
cwd: string
|
||||
home: string
|
||||
state: string
|
||||
worktree: string
|
||||
}>
|
||||
|
||||
export type TuiTerminalEnvironment = Readonly<{
|
||||
platform: string
|
||||
multiplexer?: "tmux" | "screen"
|
||||
displayServer?: "wayland" | "x11"
|
||||
}>
|
||||
|
||||
export type TuiStartup = Readonly<{
|
||||
initialRoute?: unknown
|
||||
skipInitialLoading: boolean
|
||||
}>
|
||||
|
||||
const PathsContext = createContext<TuiPaths>()
|
||||
const TerminalEnvironmentContext = createContext<TuiTerminalEnvironment>()
|
||||
const StartupContext = createContext<TuiStartup>()
|
||||
|
||||
function provider<T>(context: ReturnType<typeof createContext<T>>, value: T, children: () => JSX.Element) {
|
||||
return createComponent(context.Provider, {
|
||||
value: Object.freeze({ ...value }),
|
||||
get children() {
|
||||
return children()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function TuiPathsProvider(props: { value: TuiPaths; children: JSX.Element }) {
|
||||
return provider(PathsContext, props.value, () => props.children)
|
||||
}
|
||||
|
||||
export function TuiTerminalEnvironmentProvider(props: { value: TuiTerminalEnvironment; children: JSX.Element }) {
|
||||
return provider(TerminalEnvironmentContext, props.value, () => props.children)
|
||||
}
|
||||
|
||||
export function TuiStartupProvider(props: { value: TuiStartup; children: JSX.Element }) {
|
||||
return provider(StartupContext, props.value, () => props.children)
|
||||
}
|
||||
|
||||
function required<T>(context: ReturnType<typeof createContext<T>>, name: string) {
|
||||
const value = useContext(context)
|
||||
if (!value) throw new Error(`${name} is missing`)
|
||||
return value
|
||||
}
|
||||
|
||||
export function useTuiPaths() {
|
||||
return required(PathsContext, "TuiPathsProvider")
|
||||
}
|
||||
|
||||
export function useTuiTerminalEnvironment() {
|
||||
return required(TerminalEnvironmentContext, "TuiTerminalEnvironmentProvider")
|
||||
}
|
||||
|
||||
export function useTuiStartup() {
|
||||
return required(StartupContext, "TuiStartupProvider")
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import { useTuiEnvironment } from "../runtime"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
|
||||
|
|
@ -17,7 +17,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||
headers?: RequestInit["headers"]
|
||||
events?: EventSource
|
||||
}) => {
|
||||
const environment = useTuiEnvironment()
|
||||
const abort = new AbortController()
|
||||
let sse: AbortController | undefined
|
||||
|
||||
|
|
@ -94,7 +93,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||
sseMaxRetryAttempts: 0,
|
||||
})
|
||||
|
||||
if (environment.capabilities.workspaces) {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
// Start syncing workspaces, it's important to do this after
|
||||
// we've started listening to events
|
||||
await sdk.sync.start().catch(() => {})
|
||||
|
|
@ -122,7 +121,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||
const unsub = await props.events.subscribe(handleEvent)
|
||||
onCleanup(unsub)
|
||||
|
||||
if (environment.capabilities.workspaces) {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
// Start syncing workspaces, it's important to do this after
|
||||
// we've started listening to events
|
||||
await sdk.sync.start().catch(() => {})
|
||||
|
|
|
|||
|
|
@ -24,13 +24,15 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
|||
import { useProject } from "./project"
|
||||
import { useEvent } from "./event"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useTuiEnvironment } from "../runtime"
|
||||
import { useTuiStartup } from "./runtime"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useExit } from "./exit"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useArgs } from "./args"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import path from "path"
|
||||
import { aggregateFailures } from "./aggregate-failures"
|
||||
import { useKV } from "./kv"
|
||||
import { destroyRenderer } from "../util/renderer"
|
||||
|
||||
const emptyConsoleState: ConsoleState = {
|
||||
consoleManagedProviders: [],
|
||||
|
|
@ -50,19 +52,11 @@ function search<T>(items: T[], target: string, key: (item: T) => string) {
|
|||
return { found: false, index: left }
|
||||
}
|
||||
|
||||
export type SyncDependencies = {
|
||||
kv: { get(key: string, defaultValue: boolean): boolean }
|
||||
logger: { error(message: string, extra?: Record<string, unknown>): void }
|
||||
}
|
||||
|
||||
export const {
|
||||
context: SyncContext,
|
||||
use: useSync,
|
||||
provider: SyncProvider,
|
||||
} = createSimpleContext({
|
||||
export const { context: SyncContext, use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
init: (dependencies: SyncDependencies) => {
|
||||
const environment = useTuiEnvironment()
|
||||
init: () => {
|
||||
const startup = useTuiStartup()
|
||||
const kv = useKV()
|
||||
const [store, setStore] = createStore<{
|
||||
status: "loading" | "partial" | "complete"
|
||||
provider: Provider[]
|
||||
|
|
@ -136,7 +130,6 @@ export const {
|
|||
const event = useEvent()
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
const kv = dependencies.kv
|
||||
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
const syncingSessions = new Map<string, Promise<void>>()
|
||||
|
|
@ -427,7 +420,7 @@ export const {
|
|||
}
|
||||
})
|
||||
|
||||
const exit = useExit()
|
||||
const renderer = useRenderer()
|
||||
const args = useArgs()
|
||||
|
||||
async function bootstrap(input: { fatal?: boolean } = {}) {
|
||||
|
|
@ -520,13 +513,13 @@ export const {
|
|||
})
|
||||
})
|
||||
.catch(async (e) => {
|
||||
dependencies.logger.error("tui bootstrap failed", {
|
||||
console.error("tui bootstrap failed", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
name: e instanceof Error ? e.name : undefined,
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
})
|
||||
if (fatal) {
|
||||
await exit(e)
|
||||
destroyRenderer(renderer)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
|
|
@ -544,7 +537,7 @@ export const {
|
|||
return store.status
|
||||
},
|
||||
get ready() {
|
||||
if (environment.skipInitialLoading) return true
|
||||
if (startup.skipInitialLoading) return true
|
||||
return store.status !== "loading"
|
||||
},
|
||||
get path() {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,41 @@ import { createStore, produce } from "solid-js/store"
|
|||
import { createSimpleContext } from "./helper"
|
||||
import { useKV } from "./kv"
|
||||
import { useTuiConfig } from "../config"
|
||||
import { useOptionalTuiPlatform } from "../platform"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export type ThemeSource = Readonly<{
|
||||
discover(): Promise<Record<string, unknown>>
|
||||
subscribeRefresh?(refresh: () => void): () => void
|
||||
}>
|
||||
|
||||
const themeSource: ThemeSource = {
|
||||
async discover() {
|
||||
const directories = [Global.Path.config]
|
||||
for (let current = process.cwd(); ; current = path.dirname(current)) {
|
||||
directories.push(path.join(current, ".opencode"))
|
||||
if (path.dirname(current) === current) break
|
||||
}
|
||||
return discoverThemes(directories)
|
||||
},
|
||||
subscribeRefresh(refresh) {
|
||||
process.on("SIGUSR2", refresh)
|
||||
return () => process.off("SIGUSR2", refresh)
|
||||
},
|
||||
}
|
||||
|
||||
export async function discoverThemes(directories: string[]) {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const directory of directories) {
|
||||
const files = await Glob.scan("themes/*.json", { cwd: directory, absolute: true, dot: true, symlink: true })
|
||||
for (const file of files) {
|
||||
result[path.basename(file, ".json")] = JSON.parse(await readFile(file, "utf8")) as unknown
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_THEMES,
|
||||
|
|
@ -67,11 +101,11 @@ subscribeThemes((themes) => setStore("themes", themes))
|
|||
|
||||
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
name: "Theme",
|
||||
init: (props: { mode: "dark" | "light" }) => {
|
||||
init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => {
|
||||
const renderer = useRenderer()
|
||||
const config = useTuiConfig()
|
||||
const kv = useKV()
|
||||
const platform = useOptionalTuiPlatform()
|
||||
const themes = props.source ?? themeSource
|
||||
const pick = (value: unknown) => {
|
||||
if (value === "dark" || value === "light") return value
|
||||
return
|
||||
|
|
@ -96,7 +130,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
})
|
||||
|
||||
function syncCustomThemes() {
|
||||
return (platform?.themes?.discover() ?? Promise.resolve({}))
|
||||
return themes
|
||||
.discover()
|
||||
.then((themes) => {
|
||||
setCustomThemes(
|
||||
Object.entries(themes).reduce<Record<string, ThemeJson>>((result, [name, theme]) => {
|
||||
|
|
@ -207,7 +242,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
}, delay),
|
||||
)
|
||||
}
|
||||
const unsubscribeRefresh = platform?.themes?.subscribeRefresh?.(refresh)
|
||||
let unsubscribeRefresh: (() => void) | undefined
|
||||
unsubscribeRefresh = themes.subscribeRefresh?.(refresh)
|
||||
|
||||
onCleanup(() => {
|
||||
renderer.off(CliRenderEvents.THEME_MODE, handle)
|
||||
|
|
|
|||
286
packages/tui/src/editor-zed.ts
Normal file
286
packages/tui/src/editor-zed.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { Database } from "bun:sqlite"
|
||||
import { statSync } from "node:fs"
|
||||
import { readFile as readFileAsync } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Option, Schema } from "effect"
|
||||
import type { EditorSelection } from "./context/editor"
|
||||
|
||||
const ZedEditorRowSchema = Schema.Struct({
|
||||
item_kind: Schema.String,
|
||||
editor_id: Schema.NullOr(Schema.Number),
|
||||
workspace_id: Schema.Number,
|
||||
workspace_paths: Schema.NullOr(Schema.String),
|
||||
timestamp: Schema.String,
|
||||
buffer_path: Schema.NullOr(Schema.String),
|
||||
})
|
||||
|
||||
const ZedSelectionRowSchema = Schema.Struct({
|
||||
selection_start: Schema.NullOr(Schema.Number),
|
||||
selection_end: Schema.NullOr(Schema.Number),
|
||||
})
|
||||
|
||||
const ZedEditorContentsSchema = Schema.Struct({
|
||||
contents: Schema.NullOr(Schema.String),
|
||||
})
|
||||
|
||||
const decodeZedEditorRow = Schema.decodeUnknownOption(ZedEditorRowSchema)
|
||||
const decodeZedSelectionRow = Schema.decodeUnknownOption(ZedSelectionRowSchema)
|
||||
const decodeZedEditorContents = Schema.decodeUnknownOption(ZedEditorContentsSchema)
|
||||
|
||||
const utf8 = new TextEncoder()
|
||||
|
||||
type ZedEditorRow = Schema.Schema.Type<typeof ZedEditorRowSchema>
|
||||
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
|
||||
|
||||
export type ZedSelectionResult =
|
||||
| { type: "selection"; selection: EditorSelection }
|
||||
| { type: "empty" }
|
||||
| { type: "unavailable" }
|
||||
|
||||
export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): Promise<ZedSelectionResult> {
|
||||
const active = queryZedActiveEditor(dbPath, cwd)
|
||||
if (active.type !== "row") return active
|
||||
|
||||
const row = active.row
|
||||
if (!row.buffer_path) return { type: "empty" }
|
||||
|
||||
const selections = queryZedEditorSelections(dbPath, row)
|
||||
if (selections.type !== "selections") return selections
|
||||
const byteRanges = selections.selections
|
||||
.flatMap((selection) => {
|
||||
if (selection.selection_start == null || selection.selection_end == null) return []
|
||||
return [
|
||||
{
|
||||
start: Math.min(selection.selection_start, selection.selection_end),
|
||||
end: Math.max(selection.selection_start, selection.selection_end),
|
||||
},
|
||||
]
|
||||
})
|
||||
.sort((left, right) => left.start - right.start || left.end - right.end)
|
||||
if (byteRanges.length === 0) return { type: "unavailable" }
|
||||
|
||||
const contents = queryZedEditorContents(dbPath, row)
|
||||
const text =
|
||||
contents.type === "contents" && contents.contents != null
|
||||
? contents.contents
|
||||
: await readFileAsync(row.buffer_path, "utf8").catch(() => undefined)
|
||||
if (text == null) return { type: "unavailable" }
|
||||
|
||||
const ranges = byteRanges.map((range) => {
|
||||
const startOffset = utf8ByteOffsetToStringIndex(text, range.start)
|
||||
const endOffset = utf8ByteOffsetToStringIndex(text, range.end)
|
||||
return {
|
||||
text: text.slice(startOffset, endOffset),
|
||||
selection: offsetsToSelection(text, startOffset, endOffset),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
type: "selection",
|
||||
selection: {
|
||||
filePath: row.buffer_path,
|
||||
source: "zed",
|
||||
ranges,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function queryZedActiveEditor(dbPath: string, cwd: string) {
|
||||
let db: Database | undefined
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true })
|
||||
const raw = db
|
||||
.query(
|
||||
`select
|
||||
i.kind as item_kind,
|
||||
e.item_id as editor_id,
|
||||
i.workspace_id as workspace_id,
|
||||
w.paths as workspace_paths,
|
||||
w.timestamp as timestamp,
|
||||
e.buffer_path as buffer_path
|
||||
from items i
|
||||
join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id
|
||||
join workspaces w on w.workspace_id = i.workspace_id
|
||||
left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
|
||||
where i.active = 1 and p.active = 1
|
||||
order by w.timestamp desc`,
|
||||
)
|
||||
.all()
|
||||
|
||||
const rows = raw.flatMap((row) => {
|
||||
const parsed = decodeZedEditorRow(row)
|
||||
return Option.isSome(parsed) ? [parsed.value] : []
|
||||
})
|
||||
|
||||
if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const }
|
||||
|
||||
const row = rows
|
||||
.map((row) => ({ row, score: scoreZedWorkspace(row.workspace_paths, cwd) }))
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort((left, right) => right.score - left.score || right.row.timestamp.localeCompare(left.row.timestamp))[0]?.row
|
||||
if (!row) return { type: "empty" as const }
|
||||
if (row.item_kind !== "Editor") return { type: "unavailable" as const }
|
||||
if (!isZedActiveEditorRow(row)) return { type: "empty" as const }
|
||||
return { type: "row" as const, row }
|
||||
} catch {
|
||||
return { type: "unavailable" as const }
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
|
||||
function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
|
||||
let db: Database | undefined
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true })
|
||||
const raw = db
|
||||
.query(
|
||||
`select
|
||||
start as selection_start,
|
||||
end as selection_end
|
||||
from editor_selections
|
||||
where editor_id = $editorID and workspace_id = $workspaceID`,
|
||||
)
|
||||
.all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
|
||||
|
||||
const selections = raw.flatMap((selection) => {
|
||||
const parsed = decodeZedSelectionRow(selection)
|
||||
return Option.isSome(parsed) ? [parsed.value] : []
|
||||
})
|
||||
|
||||
if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
|
||||
return { type: "selections" as const, selections }
|
||||
} catch {
|
||||
return { type: "unavailable" as const }
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
|
||||
function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
|
||||
let db: Database | undefined
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true })
|
||||
const parsed = decodeZedEditorContents(
|
||||
db
|
||||
.query(
|
||||
`select contents
|
||||
from editors
|
||||
where item_id = $editorID and workspace_id = $workspaceID`,
|
||||
)
|
||||
.get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
|
||||
)
|
||||
if (Option.isNone(parsed)) return { type: "unavailable" as const }
|
||||
return { type: "contents" as const, contents: parsed.value.contents }
|
||||
} catch {
|
||||
return { type: "unavailable" as const }
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
|
||||
function isZedActiveEditorRow(row: ZedEditorRow): row is ZedActiveEditorRow {
|
||||
return row.item_kind === "Editor" && row.editor_id != null
|
||||
}
|
||||
|
||||
export function resolveZedDbPath() {
|
||||
const candidates = [
|
||||
process.env.OPENCODE_ZED_DB,
|
||||
path.join(os.homedir(), "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"),
|
||||
path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
|
||||
].filter((item): item is string => Boolean(item))
|
||||
|
||||
return candidates.find((item) => isFile(item))
|
||||
}
|
||||
|
||||
export function isZedTerminal() {
|
||||
return process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
|
||||
}
|
||||
|
||||
function isFile(item: string) {
|
||||
try {
|
||||
return statSync(item).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
|
||||
return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
|
||||
if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length)
|
||||
return score
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function zedWorkspacePaths(value: string | null) {
|
||||
if (!value) return []
|
||||
const parsed = parseJson(value)
|
||||
if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === "string")
|
||||
return value.split(/\r?\n/).filter(Boolean)
|
||||
}
|
||||
|
||||
export function offsetToPosition(text: string, offset: number) {
|
||||
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
|
||||
return offsetsToSelection(text, stringOffset, stringOffset).start
|
||||
}
|
||||
|
||||
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
|
||||
if (byteOffset <= 0) return 0
|
||||
|
||||
let bytes = 0
|
||||
for (let index = 0; index < text.length; ) {
|
||||
const codePoint = text.codePointAt(index)
|
||||
if (codePoint === undefined) return text.length
|
||||
|
||||
const nextIndex = index + (codePoint > 0xffff ? 2 : 1)
|
||||
bytes += utf8.encode(text.slice(index, nextIndex)).length
|
||||
if (bytes >= byteOffset) return nextIndex
|
||||
index = nextIndex
|
||||
}
|
||||
|
||||
return text.length
|
||||
}
|
||||
|
||||
function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
|
||||
const start = Math.max(0, Math.min(startOffset, text.length))
|
||||
const end = Math.max(0, Math.min(endOffset, text.length))
|
||||
let line = 1
|
||||
let lineStart = 0
|
||||
let startPosition = position(line, lineStart, start)
|
||||
let endPosition = position(line, lineStart, end)
|
||||
|
||||
for (let index = 0; index <= end; index++) {
|
||||
if (index === start) startPosition = position(line, lineStart, index)
|
||||
if (index === end) {
|
||||
endPosition = position(line, lineStart, index)
|
||||
break
|
||||
}
|
||||
if (text[index] === "\n") {
|
||||
line += 1
|
||||
lineStart = index + 1
|
||||
}
|
||||
}
|
||||
|
||||
return { start: startPosition, end: endPosition }
|
||||
}
|
||||
|
||||
function position(line: number, lineStart: number, offset: number) {
|
||||
return {
|
||||
line,
|
||||
character: offset - lineStart + 1,
|
||||
}
|
||||
}
|
||||
|
||||
function pathContains(parent: string, child: string) {
|
||||
const relative = path.relative(path.resolve(parent), path.resolve(child))
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
}
|
||||
|
||||
function parseJson(value: string) {
|
||||
try {
|
||||
return JSON.parse(value) as unknown
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
84
packages/tui/src/editor.ts
Normal file
84
packages/tui/src/editor.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import type { CliRenderer } from "@opentui/core"
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
||||
import { readFile, rm, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { spawn } from "node:child_process"
|
||||
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
|
||||
|
||||
export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string }) {
|
||||
const editor = process.env.VISUAL || process.env.EDITOR
|
||||
if (!editor) return
|
||||
const file = path.join(os.tmpdir(), `${Date.now()}.md`)
|
||||
await writeFile(file, input.value)
|
||||
input.renderer.suspend()
|
||||
input.renderer.currentRenderBuffer.clear()
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const parts = editor.split(" ")
|
||||
const child = spawn(parts[0]!, [...parts.slice(1), file], {
|
||||
cwd: input.cwd && existsSync(input.cwd) ? input.cwd : process.cwd(),
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
child.on("error", reject)
|
||||
child.on("exit", (code, signal) => {
|
||||
if (code === 0) return resolve()
|
||||
reject(new Error(`Editor exited with ${signal ? `signal ${signal}` : `code ${code}`}`))
|
||||
})
|
||||
})
|
||||
return (await readFile(file, "utf8")) || undefined
|
||||
} finally {
|
||||
await rm(file, { force: true }).catch(() => {})
|
||||
input.renderer.currentRenderBuffer.clear()
|
||||
input.renderer.resume()
|
||||
input.renderer.requestRender()
|
||||
}
|
||||
}
|
||||
|
||||
export function discoverEditorConnection(directory: string) {
|
||||
const root = path.join(os.homedir(), ".claude", "ide")
|
||||
const contains = (parent: string) => {
|
||||
const resolved = path.resolve(parent)
|
||||
const relative = path.relative(resolved, path.resolve(directory))
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
|
||||
}
|
||||
try {
|
||||
return readdirSync(root)
|
||||
.filter((entry) => entry.endsWith(".lock"))
|
||||
.flatMap((entry) => {
|
||||
const file = path.join(root, entry)
|
||||
const port = Number.parseInt(path.basename(file, ".lock"), 10)
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) return []
|
||||
try {
|
||||
const value = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>
|
||||
if (value.transport !== undefined && value.transport !== "ws") return []
|
||||
const folders = Array.isArray(value.workspaceFolders)
|
||||
? value.workspaceFolders.filter((item): item is string => typeof item === "string")
|
||||
: []
|
||||
const score = Math.max(0, ...folders.map(contains))
|
||||
if (!score) return []
|
||||
return [
|
||||
{
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
authToken: typeof value.authToken === "string" ? value.authToken : undefined,
|
||||
source: `lock:${port}`,
|
||||
score,
|
||||
mtime: statSync(file).mtimeMs,
|
||||
},
|
||||
]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
.sort((left, right) => right.score - left.score || right.mtime - left.mtime)
|
||||
.map(({ url, authToken, source }) => ({ url, authToken, source }))[0]
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const editorIntegration = {
|
||||
connection: discoverEditorConnection,
|
||||
selection: (directory: string) => resolveZedSelection(resolveZedDbPath() ?? "", directory),
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { abbreviateHome, useTuiEnvironment } from "../../runtime"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useHomeSessionDestination } from "../../routes/home/session-destination"
|
||||
|
||||
const id = "internal:home-footer"
|
||||
|
|
@ -9,13 +10,13 @@ const id = "internal:home-footer"
|
|||
function Directory(props: { api: TuiPluginApi }) {
|
||||
const theme = () => props.api.theme.current
|
||||
const destination = useHomeSessionDestination()
|
||||
const environment = useTuiEnvironment()
|
||||
const paths = useTuiPaths()
|
||||
const dir = createMemo(() => {
|
||||
const selected = destination?.destination()
|
||||
if (!selected || selected.type === "new") return
|
||||
const out = abbreviateHome(selected.directory, environment.paths.home)
|
||||
const out = abbreviateHome(selected.directory, paths.home)
|
||||
const branch =
|
||||
selected.directory === (props.api.state.path.directory || environment.cwd)
|
||||
selected.directory === (props.api.state.path.directory || paths.cwd)
|
||||
? props.api.state.vcs?.branch
|
||||
: undefined
|
||||
if (branch) return out + ":" + branch
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|||
import { createMemo, For, type Accessor } from "solid-js"
|
||||
import { DEFAULT_THEMES, useTheme } from "../../context/theme"
|
||||
import { useCommandShortcut } from "../../keymap"
|
||||
import { useTuiEnvironment } from "../../runtime"
|
||||
|
||||
const themeCount = Object.keys(DEFAULT_THEMES).length
|
||||
|
||||
|
|
@ -97,7 +96,6 @@ function configShortcut(api: TuiPluginApi, command: string): TipShortcut {
|
|||
|
||||
export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
||||
const theme = useTheme().theme
|
||||
const environment = useTuiEnvironment()
|
||||
const tipOffset = Math.random()
|
||||
const shortcuts: Shortcuts = {
|
||||
agentCycle: useCommandShortcut("agent.cycle"),
|
||||
|
|
@ -136,12 +134,10 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
|||
}
|
||||
const tip = createMemo(() => {
|
||||
if (props.connected === false) return NO_MODELS_TIP
|
||||
const tips = [...TIPS, environment.capabilities.terminalSuspend ? TERMINAL_SUSPEND_TIP : INPUT_UNDO_TIP].flatMap(
|
||||
(item) => {
|
||||
const value = typeof item === "string" ? item : item(shortcuts)
|
||||
return value ? [value] : []
|
||||
},
|
||||
)
|
||||
const tips = [...TIPS, process.platform !== "win32" ? TERMINAL_SUSPEND_TIP : INPUT_UNDO_TIP].flatMap((item) => {
|
||||
const value = typeof item === "string" ? item : item(shortcuts)
|
||||
return value ? [value] : []
|
||||
})
|
||||
return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP
|
||||
}, NO_MODELS_TIP)
|
||||
// Solid can expose a memo's initial value while a pure computation is pending.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { abbreviateHome, useTuiEnvironment } from "../../runtime"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
|
||||
const id = "internal:sidebar-footer"
|
||||
|
||||
function View(props: { api: TuiPluginApi; sessionID: string }) {
|
||||
const environment = useTuiEnvironment()
|
||||
const paths = useTuiPaths()
|
||||
const theme = () => props.api.theme.current
|
||||
const has = createMemo(() =>
|
||||
props.api.state.provider.some(
|
||||
|
|
@ -17,8 +18,8 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
|
|||
const show = createMemo(() => !has() && !done())
|
||||
const path = createMemo(() => {
|
||||
const session = props.api.state.session.get(props.sessionID)
|
||||
const dir = session?.directory || props.api.state.path.directory || environment.cwd
|
||||
const out = abbreviateHome(dir, environment.paths.home)
|
||||
const dir = session?.directory || props.api.state.path.directory || paths.cwd
|
||||
const out = abbreviateHome(dir, paths.home)
|
||||
const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
|
||||
const text = branch ? out + ":" + branch : out
|
||||
const list = text.split("/")
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
|||
import { RGBA, TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
|
||||
import { useBindings } from "../../keymap"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { useTuiEnvironment } from "../../runtime"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
|
||||
import { toolDisplayMetadata, webSearchProviderLabel } from "../../util/tool-display"
|
||||
import path from "path"
|
||||
|
|
@ -1122,7 +1122,7 @@ function input(input: Record<string, unknown>, omit?: string[]) {
|
|||
}
|
||||
|
||||
function usePathNormalizer() {
|
||||
const cwd = useTuiEnvironment().cwd
|
||||
const cwd = useTuiPaths().cwd
|
||||
return (input?: string) => normalizePath(input, cwd)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1 @@
|
|||
export {
|
||||
createTuiBuildInfo,
|
||||
createTuiEnvironment,
|
||||
TuiBuildInfoProvider,
|
||||
TuiEnvironmentProvider,
|
||||
useTuiBuildInfo,
|
||||
useTuiEnvironment,
|
||||
type TuiBuildInfo,
|
||||
type TuiEnvironment,
|
||||
} from "./runtime"
|
||||
export {
|
||||
createTuiRenderer,
|
||||
createTuiRenderer as createRenderer,
|
||||
mount,
|
||||
tui,
|
||||
tui as run,
|
||||
tuiRendererConfig,
|
||||
type TuiHandle,
|
||||
type TuiHost,
|
||||
type TuiInput,
|
||||
type TuiRuntimeInput,
|
||||
} from "./app"
|
||||
export { run, type TuiInput } from "./app"
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
import { createContext, type JSX, useContext } from "solid-js"
|
||||
|
||||
export type PlatformFiles = Readonly<{
|
||||
readText(path: string): Promise<string>
|
||||
readBytes(path: string): Promise<Uint8Array>
|
||||
mime(path: string): Promise<string>
|
||||
}>
|
||||
|
||||
export type PlatformClipboardContent = Readonly<{
|
||||
data: string
|
||||
mime: string
|
||||
}>
|
||||
|
||||
export type TuiPlatform = Readonly<{
|
||||
files: PlatformFiles
|
||||
state?: Readonly<{
|
||||
read(): Promise<Record<string, unknown>>
|
||||
write(value: Record<string, unknown>): Promise<void>
|
||||
}>
|
||||
themes?: Readonly<{
|
||||
discover(): Promise<Record<string, unknown>>
|
||||
subscribeRefresh?(refresh: () => void): () => void
|
||||
}>
|
||||
clipboard?: Readonly<{
|
||||
read?(): Promise<PlatformClipboardContent | undefined>
|
||||
write?(text: string): Promise<void>
|
||||
}>
|
||||
editor?: Readonly<{
|
||||
open(input: Readonly<{ value: string; cwd?: string }>): Promise<string | undefined>
|
||||
connection?(directory: string): Readonly<{ url: string; authToken?: string; source: string }> | undefined
|
||||
selection?(directory: string): Promise<unknown>
|
||||
}>
|
||||
export?: Readonly<{
|
||||
write(path: string, content: string): Promise<void>
|
||||
}>
|
||||
}>
|
||||
|
||||
const PlatformContext = createContext<TuiPlatform>()
|
||||
|
||||
export function TuiPlatformProvider(props: { value: TuiPlatform; children: JSX.Element }) {
|
||||
return <PlatformContext.Provider value={props.value}>{props.children}</PlatformContext.Provider>
|
||||
}
|
||||
|
||||
export function useTuiPlatform() {
|
||||
const value = useContext(PlatformContext)
|
||||
if (!value) throw new Error("TuiPlatformProvider is missing")
|
||||
return value
|
||||
}
|
||||
|
||||
export function useOptionalTuiPlatform() {
|
||||
return useContext(PlatformContext)
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import path from "path"
|
|||
import { onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "../context/helper"
|
||||
import { useTuiEnvironment } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { appendText, readText, writeText } from "../util/persistence"
|
||||
|
||||
type FrecencyEntry = { path: string; frequency: number; lastOpen: number }
|
||||
|
|
@ -38,8 +38,8 @@ function calculateFrecency(entry?: { frequency: number; lastOpen: number }) {
|
|||
export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({
|
||||
name: "Frecency",
|
||||
init: () => {
|
||||
const environment = useTuiEnvironment()
|
||||
const frecencyPath = path.join(environment.paths.state, "frecency.jsonl")
|
||||
const paths = useTuiPaths()
|
||||
const frecencyPath = path.join(paths.state, "frecency.jsonl")
|
||||
onMount(async () => {
|
||||
const lines = parseFrecency(await readText(frecencyPath).catch(() => ""))
|
||||
setStore(
|
||||
|
|
@ -55,7 +55,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont
|
|||
const [store, setStore] = createStore({ data: {} as Record<string, { frequency: number; lastOpen: number }> })
|
||||
|
||||
function updateFrecency(filePath: string) {
|
||||
const absolutePath = path.resolve(environment.cwd, filePath)
|
||||
const absolutePath = path.resolve(paths.cwd, filePath)
|
||||
const newEntry = { frequency: (store.data[absolutePath]?.frequency || 0) + 1, lastOpen: Date.now() }
|
||||
setStore("data", absolutePath, newEntry)
|
||||
appendText(frecencyPath, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
|
||||
|
|
@ -72,7 +72,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont
|
|||
}
|
||||
|
||||
return {
|
||||
getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(environment.cwd, filePath)]),
|
||||
getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(paths.cwd, filePath)]),
|
||||
updateFrecency,
|
||||
data: () => store.data,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { onMount } from "solid-js"
|
|||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
|
||||
import { createSimpleContext } from "../context/helper"
|
||||
import { useTuiEnvironment } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { appendText, readText, writeText } from "../util/persistence"
|
||||
|
||||
export type PromptInfo = {
|
||||
|
|
@ -49,8 +49,8 @@ export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptI
|
|||
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
|
||||
name: "PromptHistory",
|
||||
init: () => {
|
||||
const environment = useTuiEnvironment()
|
||||
const historyPath = path.join(environment.paths.state, "prompt-history.jsonl")
|
||||
const paths = useTuiPaths()
|
||||
const historyPath = path.join(paths.state, "prompt-history.jsonl")
|
||||
onMount(async () => {
|
||||
const lines = parsePromptHistory(await readText(historyPath).catch(() => ""))
|
||||
setStore("history", lines)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import path from "path"
|
|||
import { onMount } from "solid-js"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { createSimpleContext } from "../context/helper"
|
||||
import { useTuiEnvironment } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { appendText, readText, writeText } from "../util/persistence"
|
||||
import type { PromptInfo } from "./history"
|
||||
|
||||
|
|
@ -32,8 +32,8 @@ export function parsePromptStash(text: string) {
|
|||
export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({
|
||||
name: "PromptStash",
|
||||
init: () => {
|
||||
const environment = useTuiEnvironment()
|
||||
const stashPath = path.join(environment.paths.state, "prompt-stash.jsonl")
|
||||
const paths = useTuiPaths()
|
||||
const stashPath = path.join(paths.state, "prompt-stash.jsonl")
|
||||
onMount(async () => {
|
||||
const lines = parsePromptStash(await readText(stashPath).catch(() => ""))
|
||||
setStore("entries", lines)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
type Setter,
|
||||
} from "solid-js"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useTuiEnvironment } from "../../runtime"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
|
||||
export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
|
||||
|
||||
|
|
@ -22,10 +22,10 @@ const HomeSessionDestinationContext = createContext<Context>()
|
|||
|
||||
export function HomeSessionDestinationProvider(props: ParentProps) {
|
||||
const sync = useSync()
|
||||
const environment = useTuiEnvironment()
|
||||
const paths = useTuiPaths()
|
||||
const [selected, setDestination] = createSignal<HomeSessionDestination>()
|
||||
const destination = createMemo<HomeSessionDestination>(
|
||||
() => selected() ?? { type: "directory", directory: sync.path.directory || environment.cwd, subdirectory: false },
|
||||
() => selected() ?? { type: "directory", directory: sync.path.directory || paths.cwd, subdirectory: false },
|
||||
)
|
||||
return (
|
||||
<HomeSessionDestinationContext.Provider
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useSync } from "../../context/sync"
|
|||
import { DialogSelect } from "../../ui/dialog-select"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useRoute } from "../../context/route"
|
||||
import { useTuiPlatform } from "../../platform"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import type { PromptInfo } from "../../component/prompt/history"
|
||||
import { stripPromptPartIDs as strip } from "../../prompt/part"
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ export function DialogMessage(props: {
|
|||
const sdk = useSDK()
|
||||
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
|
||||
const route = useRoute()
|
||||
const platform = useTuiPlatform()
|
||||
const clipboard = useClipboard()
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
|
|
@ -69,7 +69,7 @@ export function DialogMessage(props: {
|
|||
return agg
|
||||
}, "")
|
||||
|
||||
await platform.clipboard?.write?.(text)
|
||||
await clipboard.write?.(text)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
For,
|
||||
Match,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
|
|
@ -15,12 +16,13 @@ import {
|
|||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import path from "node:path"
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
import { useRoute, useRouteData } from "../../context/route"
|
||||
import { useProject } from "../../context/project"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useEvent } from "../../context/event"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useTuiEnvironment } from "../../runtime"
|
||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
import { Spinner } from "../../component/spinner"
|
||||
import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||
|
|
@ -41,6 +43,7 @@ import { webSearchProviderLabel } from "../../util/tool-display"
|
|||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useEditorContext } from "../../context/editor"
|
||||
import { openEditor } from "../../editor"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogAlert } from "../../ui/dialog-alert"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
|
|
@ -59,17 +62,17 @@ import { Toast, useToast } from "../../ui/toast"
|
|||
import { useKV } from "../../context/kv.tsx"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { useEpilogue } from "../../context/epilogue"
|
||||
import { normalizePath } from "../../util/path"
|
||||
import { PermissionPrompt } from "./permission"
|
||||
import { QuestionPrompt } from "./question"
|
||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||
import * as Model from "../../util/model"
|
||||
import { formatTranscript } from "../../util/transcript"
|
||||
import { sessionEpilogue } from "../../util/presentation"
|
||||
import { setPreLayoutSiblingMargin } from "../../util/layout"
|
||||
import { sessionExitSummary } from "../../util/presentation"
|
||||
import { useTuiConfig } from "../../config"
|
||||
import { useTuiPlatform } from "../../platform"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||
|
|
@ -171,19 +174,30 @@ function use() {
|
|||
}
|
||||
|
||||
export function Session() {
|
||||
const platform = useTuiPlatform()
|
||||
const setEpilogue = useEpilogue()
|
||||
const clipboard = useClipboard()
|
||||
const writeExport = async (file: string, content: string) => {
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(file, content)
|
||||
}
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const route = useRouteData("session")
|
||||
const { navigate } = useRoute()
|
||||
const sync = useSync()
|
||||
const event = useEvent()
|
||||
const project = useProject()
|
||||
const environment = useTuiEnvironment()
|
||||
const paths = useTuiPaths()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const kv = useKV()
|
||||
const { theme } = useTheme()
|
||||
const promptRef = usePromptRef()
|
||||
const session = createMemo(() => sync.session.get(route.sessionID))
|
||||
|
||||
createEffect(() => {
|
||||
const title = Locale.truncate(session()?.title ?? "", 50)
|
||||
setEpilogue(sessionEpilogue({ title, sessionID: session()?.id }))
|
||||
})
|
||||
onCleanup(() => setEpilogue())
|
||||
const children = createMemo(() => {
|
||||
const parentID = session()?.parentID ?? session()?.id
|
||||
return sync.data.session
|
||||
|
|
@ -353,13 +367,6 @@ export function Session() {
|
|||
})
|
||||
})
|
||||
|
||||
const exit = useExit()
|
||||
|
||||
createEffect(() => {
|
||||
const title = Locale.truncate(session()?.title ?? "", 50)
|
||||
return exit.message.set(sessionExitSummary({ title, sessionID: session()?.id }))
|
||||
})
|
||||
|
||||
// Helper: Find next visible message boundary in direction
|
||||
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
|
||||
const children = scroll.getChildren()
|
||||
|
|
@ -460,8 +467,7 @@ export function Session() {
|
|||
},
|
||||
run: async () => {
|
||||
const copy = (url: string) =>
|
||||
platform.clipboard
|
||||
?.write?.(url)
|
||||
clipboard.write?.(url)
|
||||
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
|
||||
.catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }))
|
||||
const url = session()?.share?.url
|
||||
|
|
@ -896,8 +902,7 @@ export function Session() {
|
|||
return
|
||||
}
|
||||
|
||||
platform.clipboard
|
||||
?.write?.(text)
|
||||
clipboard.write?.(text)
|
||||
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
|
||||
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
|
||||
dialog.clear()
|
||||
|
|
@ -925,7 +930,7 @@ export function Session() {
|
|||
providers: sync.data.provider,
|
||||
},
|
||||
)
|
||||
await platform.clipboard?.write?.(transcript)
|
||||
await clipboard.write?.(transcript)
|
||||
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
|
||||
} catch {
|
||||
toast.show({ message: "Failed to copy session transcript", variant: "error" })
|
||||
|
|
@ -972,30 +977,32 @@ export function Session() {
|
|||
|
||||
if (options.openWithoutSaving) {
|
||||
// Just open in editor without saving
|
||||
await platform.editor?.open({
|
||||
await openEditor({
|
||||
renderer,
|
||||
value: transcript,
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
environment.cwd,
|
||||
paths.cwd,
|
||||
})
|
||||
} else {
|
||||
const exportDir = environment.cwd
|
||||
const exportDir = paths.cwd
|
||||
const filename = options.filename.trim()
|
||||
const filepath = path.join(exportDir, filename)
|
||||
|
||||
await platform.export?.write(filepath, transcript)
|
||||
await writeExport(filepath, transcript)
|
||||
|
||||
// Open with EDITOR if available
|
||||
const result = await platform.editor?.open({
|
||||
const result = await openEditor({
|
||||
renderer,
|
||||
value: transcript,
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
environment.cwd,
|
||||
paths.cwd,
|
||||
})
|
||||
if (result !== undefined) {
|
||||
await platform.export?.write(filepath, result)
|
||||
await writeExport(filepath, result)
|
||||
}
|
||||
|
||||
toast.show({ message: `Session exported to ${filename}`, variant: "success" })
|
||||
|
|
@ -2510,9 +2517,12 @@ function Skill(props: ToolProps) {
|
|||
|
||||
function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
|
||||
const { theme } = useTheme()
|
||||
const environment = useTuiEnvironment()
|
||||
const terminalEnvironment = useTuiTerminalEnvironment()
|
||||
const errors = createMemo(() => {
|
||||
const normalized = normalizePath(typeof props.filePath === "string" ? props.filePath : "", environment.platform)
|
||||
const normalized = normalizePath(
|
||||
typeof props.filePath === "string" ? props.filePath : "",
|
||||
terminalEnvironment.platform,
|
||||
)
|
||||
return parseDiagnostics(props.diagnostics, normalized)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useSync } from "../../context/sync"
|
|||
import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useTuiConfig } from "../../config"
|
||||
import { useTuiBuildInfo } from "../../runtime"
|
||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { usePluginRuntime } from "../../plugin/runtime"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
|
|
@ -15,7 +15,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const build = useTuiBuildInfo()
|
||||
const session = createMemo(() => sync.session.get(props.sessionID))
|
||||
const workspace = () => {
|
||||
const workspaceID = session()?.workspaceID
|
||||
|
|
@ -58,7 +57,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||
<text fg={theme.text}>
|
||||
<b>{session()!.title}</b>
|
||||
</text>
|
||||
<Show when={build.channel !== "latest"}>
|
||||
<Show when={InstallationChannel !== "latest"}>
|
||||
<text fg={theme.textMuted}>{props.sessionID}</text>
|
||||
</Show>
|
||||
<Show when={session()!.workspaceID}>
|
||||
|
|
@ -94,7 +93,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||
<span style={{ fg: theme.text }}>
|
||||
<b>Code</b>
|
||||
</span>{" "}
|
||||
<span>{build.version}</span>
|
||||
<span>{InstallationVersion}</span>
|
||||
</text>
|
||||
</pluginRuntime.Slot>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -1,88 +1,5 @@
|
|||
import { createComponent, createContext, type JSX, useContext } from "solid-js"
|
||||
import path from "path"
|
||||
|
||||
export type TuiEnvironment = Readonly<{
|
||||
cwd: string
|
||||
platform: string
|
||||
initialRoute?: unknown
|
||||
paths: Readonly<{
|
||||
home: string
|
||||
state: string
|
||||
worktree: string
|
||||
}>
|
||||
capabilities: Readonly<{
|
||||
mouse: boolean
|
||||
copyOnSelect: boolean
|
||||
terminalTitle: boolean
|
||||
terminalSuspend: boolean
|
||||
workspaces: boolean
|
||||
showTimeToFirstDraw: boolean
|
||||
}>
|
||||
terminal: Readonly<{
|
||||
multiplexer?: "tmux" | "screen"
|
||||
displayServer?: "wayland" | "x11"
|
||||
}>
|
||||
editor: Readonly<{
|
||||
command?: string
|
||||
port?: number
|
||||
zedTerminal: boolean
|
||||
zedDatabase?: string
|
||||
}>
|
||||
skipInitialLoading: boolean
|
||||
}>
|
||||
|
||||
export type TuiBuildInfo = Readonly<{
|
||||
version: string
|
||||
channel: string
|
||||
}>
|
||||
|
||||
const EnvironmentContext = createContext<TuiEnvironment>()
|
||||
const BuildInfoContext = createContext<TuiBuildInfo>()
|
||||
|
||||
export function TuiEnvironmentProvider(props: { value: TuiEnvironment; children: JSX.Element }) {
|
||||
return createComponent(EnvironmentContext.Provider, {
|
||||
value: props.value,
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function TuiBuildInfoProvider(props: { value: TuiBuildInfo; children: JSX.Element }) {
|
||||
return createComponent(BuildInfoContext.Provider, {
|
||||
value: props.value,
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTuiEnvironment() {
|
||||
const value = useContext(EnvironmentContext)
|
||||
if (!value) throw new Error("TuiEnvironmentProvider is missing")
|
||||
return value
|
||||
}
|
||||
|
||||
export function useTuiBuildInfo() {
|
||||
const value = useContext(BuildInfoContext)
|
||||
if (!value) throw new Error("TuiBuildInfoProvider is missing")
|
||||
return value
|
||||
}
|
||||
|
||||
export function createTuiEnvironment(input: TuiEnvironment): TuiEnvironment {
|
||||
return Object.freeze({
|
||||
...input,
|
||||
paths: Object.freeze({ ...input.paths }),
|
||||
capabilities: Object.freeze({ ...input.capabilities }),
|
||||
terminal: Object.freeze({ ...input.terminal }),
|
||||
editor: Object.freeze({ ...input.editor }),
|
||||
})
|
||||
}
|
||||
|
||||
export function createTuiBuildInfo(input: TuiBuildInfo): TuiBuildInfo {
|
||||
return Object.freeze({ ...input })
|
||||
}
|
||||
|
||||
export function abbreviateHome(input: string, home: string) {
|
||||
if (!home) return input
|
||||
const relative = path.relative(home, input)
|
||||
|
|
|
|||
130
packages/tui/src/terminal-win32.ts
Normal file
130
packages/tui/src/terminal-win32.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { dlopen, ptr } from "bun:ffi"
|
||||
import type { ReadStream } from "node:tty"
|
||||
|
||||
const STD_INPUT_HANDLE = -10
|
||||
const ENABLE_PROCESSED_INPUT = 0x0001
|
||||
|
||||
const kernel = () =>
|
||||
dlopen("kernel32.dll", {
|
||||
GetStdHandle: { args: ["i32"], returns: "ptr" },
|
||||
GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" },
|
||||
SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" },
|
||||
FlushConsoleInputBuffer: { args: ["ptr"], returns: "i32" },
|
||||
})
|
||||
|
||||
let k32: ReturnType<typeof kernel> | undefined
|
||||
|
||||
function load() {
|
||||
if (process.platform !== "win32") return false
|
||||
try {
|
||||
k32 ??= kernel()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear ENABLE_PROCESSED_INPUT on the console stdin handle.
|
||||
*/
|
||||
export function win32DisableProcessedInput() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buf = new Uint32Array(1)
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
|
||||
const mode = buf[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
|
||||
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard any queued console input (mouse events, key presses, etc.).
|
||||
*/
|
||||
export function win32FlushInputBuffer() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
k32!.symbols.FlushConsoleInputBuffer(handle)
|
||||
}
|
||||
|
||||
let unhook: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* Keep ENABLE_PROCESSED_INPUT disabled.
|
||||
*
|
||||
* On Windows, Ctrl+C becomes a CTRL_C_EVENT (instead of stdin input) when
|
||||
* ENABLE_PROCESSED_INPUT is set. Various runtimes can re-apply console modes
|
||||
* (sometimes on a later tick), and the flag is console-global, not per-process.
|
||||
*
|
||||
* We combine:
|
||||
* - A `setRawMode(...)` hook to re-clear after known raw-mode toggles.
|
||||
* - A low-frequency poll as a backstop for native/external mode changes.
|
||||
*/
|
||||
export function win32InstallCtrlCGuard() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
if (unhook) return unhook
|
||||
|
||||
const stdin = process.stdin as ReadStream
|
||||
const original = stdin.setRawMode
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buf = new Uint32Array(1)
|
||||
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
const initial = buf[0]!
|
||||
|
||||
const enforce = () => {
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
const mode = buf[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
|
||||
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
|
||||
// Some runtimes can re-apply console modes on the next tick; enforce twice.
|
||||
const later = () => {
|
||||
enforce()
|
||||
setImmediate(enforce)
|
||||
}
|
||||
|
||||
let wrapped: ReadStream["setRawMode"] | undefined
|
||||
|
||||
if (typeof original === "function") {
|
||||
wrapped = (mode: boolean) => {
|
||||
const result = original.call(stdin, mode)
|
||||
later()
|
||||
return result
|
||||
}
|
||||
|
||||
stdin.setRawMode = wrapped
|
||||
}
|
||||
|
||||
// Ensure it's cleared immediately too (covers any earlier mode changes).
|
||||
later()
|
||||
|
||||
const interval = setInterval(enforce, 100)
|
||||
interval.unref()
|
||||
|
||||
let done = false
|
||||
unhook = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
|
||||
clearInterval(interval)
|
||||
if (wrapped && stdin.setRawMode === wrapped) {
|
||||
stdin.setRawMode = original
|
||||
}
|
||||
|
||||
k32!.symbols.SetConsoleMode(handle, initial)
|
||||
unhook = undefined
|
||||
}
|
||||
|
||||
return unhook
|
||||
}
|
||||
|
|
@ -4,9 +4,9 @@ import { useTheme } from "../context/theme"
|
|||
import { MouseButton, Renderable, RGBA } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useToast } from "./toast"
|
||||
import { useTuiEnvironment } from "../runtime"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { useBindings, useOpencodeModeStack } from "../keymap"
|
||||
import { useOptionalTuiPlatform } from "../platform"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
|
||||
export function Dialog(
|
||||
props: ParentProps<{
|
||||
|
|
@ -180,13 +180,12 @@ export function DialogProvider(props: ParentProps) {
|
|||
const value = init()
|
||||
const renderer = useRenderer()
|
||||
const toast = useToast()
|
||||
const environment = useTuiEnvironment()
|
||||
const platform = useOptionalTuiPlatform()
|
||||
const clipboard = useClipboard()
|
||||
|
||||
function copySelection() {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (!text || !platform?.clipboard?.write) return false
|
||||
void platform.clipboard.write(text).then(
|
||||
if (!text || !clipboard.write) return false
|
||||
void clipboard.write(text).then(
|
||||
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
|
||||
(error) => toast.error(error),
|
||||
)
|
||||
|
|
@ -201,14 +200,14 @@ export function DialogProvider(props: ParentProps) {
|
|||
position="absolute"
|
||||
zIndex={3000}
|
||||
onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => {
|
||||
if (environment.capabilities.copyOnSelect) return
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
if (!copySelection()) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={environment.capabilities.copyOnSelect ? copySelection : undefined}
|
||||
onMouseUp={!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? copySelection : undefined}
|
||||
>
|
||||
<Show when={value.stack.length}>
|
||||
<Dialog onClose={() => value.clear()} size={value.size}>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ function wordmark(pad = "") {
|
|||
})
|
||||
}
|
||||
|
||||
export function sessionExitSummary(input: { title: string; sessionID?: string }) {
|
||||
export function sessionEpilogue(input: { title: string; sessionID?: string }) {
|
||||
const weak = (text: string) => `${dim}${text.padEnd(10, " ")}${reset}`
|
||||
return [
|
||||
...wordmark(" "),
|
||||
|
|
|
|||
6
packages/tui/src/util/renderer.ts
Normal file
6
packages/tui/src/util/renderer.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { CliRenderer } from "@opentui/core"
|
||||
|
||||
export function destroyRenderer(renderer: Pick<CliRenderer, "isDestroyed" | "setTerminalTitle" | "destroy">) {
|
||||
renderer.setTerminalTitle("")
|
||||
if (!renderer.isDestroyed) renderer.destroy()
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { TuiPlatform } from "../platform"
|
||||
import type { ClipboardService } from "../context/clipboard"
|
||||
|
||||
type Toast = {
|
||||
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
|
||||
|
|
@ -23,7 +23,7 @@ type SelectionKeyEvent = {
|
|||
stopPropagation: () => void
|
||||
}
|
||||
|
||||
export function copy(renderer: Renderer, toast: Toast, clipboard: TuiPlatform["clipboard"]): boolean {
|
||||
export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean {
|
||||
const selection = renderer.getSelection()
|
||||
if (!selection) return false
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ export function handleSelectionKey(
|
|||
renderer: Renderer,
|
||||
toast: Toast,
|
||||
event: SelectionKeyEvent,
|
||||
clipboard: TuiPlatform["clipboard"],
|
||||
clipboard: ClipboardService,
|
||||
) {
|
||||
const selection = renderer.getSelection()
|
||||
if (!selection) return
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue