refactor(tui): extract standalone package (#31193)

This commit is contained in:
Dax 2026-06-07 01:24:27 -04:00 committed by GitHub
commit 106f8e94d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 1147 additions and 1918 deletions

View file

@ -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

View file

@ -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)
},

View file

@ -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

View file

@ -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>

View file

@ -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)

View file

@ -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"}]`)

View file

@ -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") {

View file

@ -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 },