mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-01 06:42:26 +00:00
- Rename packages/opencode → packages/tfcode (directory only) - Rename bin/opencode → bin/tfcode (CLI binary) - Rename .opencode → .tfcode (config directory) - Update package.json name and bin field - Update config directory path references (.tfcode) - Keep internal code references as 'opencode' for easy upstream sync - Keep @opencode-ai/* workspace package names This minimal branding approach allows clean merges from upstream opencode repository while providing tfcode branding for users.
890 lines
26 KiB
TypeScript
890 lines
26 KiB
TypeScript
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
|
import { Clipboard } from "@tui/util/clipboard"
|
|
import { Selection } from "@tui/util/selection"
|
|
import { MouseButton, TextAttributes } from "@opentui/core"
|
|
import { RouteProvider, useRoute } from "@tui/context/route"
|
|
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
|
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
|
import { Flag } from "@/flag/flag"
|
|
import semver from "semver"
|
|
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
|
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
|
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
|
import { SyncProvider, useSync } from "@tui/context/sync"
|
|
import { LocalProvider, useLocal } from "@tui/context/local"
|
|
import { DialogModel, useConnected } from "@tui/component/dialog-model"
|
|
import { DialogMcp } from "@tui/component/dialog-mcp"
|
|
import { DialogStatus } from "@tui/component/dialog-status"
|
|
import { DialogThemeList } from "@tui/component/dialog-theme-list"
|
|
import { DialogHelp } from "./ui/dialog-help"
|
|
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
|
|
import { DialogAgent } from "@tui/component/dialog-agent"
|
|
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
|
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
|
|
import { KeybindProvider } from "@tui/context/keybind"
|
|
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
|
import { Home } from "@tui/routes/home"
|
|
import { Session } from "@tui/routes/session"
|
|
import { PromptHistoryProvider } from "./component/prompt/history"
|
|
import { FrecencyProvider } from "./component/prompt/frecency"
|
|
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 { ExitProvider, useExit } from "./context/exit"
|
|
import { Session as SessionApi } from "@/session"
|
|
import { TuiEvent } from "./event"
|
|
import { KVProvider, useKV } from "./context/kv"
|
|
import { Provider } from "@/provider/provider"
|
|
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
|
import open from "open"
|
|
import { writeHeapSnapshot } from "v8"
|
|
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
|
import { TuiConfigProvider } from "./context/tui-config"
|
|
import { TuiConfig } from "@/config/tui"
|
|
|
|
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
|
// can't set raw mode if not a TTY
|
|
if (!process.stdin.isTTY) return "dark"
|
|
|
|
return new Promise((resolve) => {
|
|
let timeout: NodeJS.Timeout
|
|
|
|
const cleanup = () => {
|
|
process.stdin.setRawMode(false)
|
|
process.stdin.removeListener("data", handler)
|
|
clearTimeout(timeout)
|
|
}
|
|
|
|
const handler = (data: Buffer) => {
|
|
const str = data.toString()
|
|
const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
|
if (match) {
|
|
cleanup()
|
|
const color = match[1]
|
|
// Parse RGB values from color string
|
|
// Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
|
|
let r = 0,
|
|
g = 0,
|
|
b = 0
|
|
|
|
if (color.startsWith("rgb:")) {
|
|
const parts = color.substring(4).split("/")
|
|
r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
|
|
g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
|
|
b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
|
|
} else if (color.startsWith("#")) {
|
|
r = parseInt(color.substring(1, 3), 16)
|
|
g = parseInt(color.substring(3, 5), 16)
|
|
b = parseInt(color.substring(5, 7), 16)
|
|
} else if (color.startsWith("rgb(")) {
|
|
const parts = color.substring(4, color.length - 1).split(",")
|
|
r = parseInt(parts[0])
|
|
g = parseInt(parts[1])
|
|
b = parseInt(parts[2])
|
|
}
|
|
|
|
// Calculate luminance using relative luminance formula
|
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
|
|
// Determine if dark or light based on luminance threshold
|
|
resolve(luminance > 0.5 ? "light" : "dark")
|
|
}
|
|
}
|
|
|
|
process.stdin.setRawMode(true)
|
|
process.stdin.on("data", handler)
|
|
process.stdout.write("\x1b]11;?\x07")
|
|
|
|
timeout = setTimeout(() => {
|
|
cleanup()
|
|
resolve("dark")
|
|
}, 1000)
|
|
})
|
|
}
|
|
|
|
import type { EventSource } from "./context/sdk"
|
|
import { Installation } from "@/installation"
|
|
|
|
export function tui(input: {
|
|
url: string
|
|
args: Args
|
|
config: TuiConfig.Info
|
|
directory?: string
|
|
fetch?: typeof fetch
|
|
headers?: RequestInit["headers"]
|
|
events?: EventSource
|
|
}) {
|
|
// promise to prevent immediate exit
|
|
return new Promise<void>(async (resolve) => {
|
|
const unguard = win32InstallCtrlCGuard()
|
|
win32DisableProcessedInput()
|
|
|
|
const mode = await getTerminalBackgroundColor()
|
|
|
|
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
|
|
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.
|
|
win32DisableProcessedInput()
|
|
|
|
const onExit = async () => {
|
|
unguard?.()
|
|
resolve()
|
|
}
|
|
|
|
render(
|
|
() => {
|
|
return (
|
|
<ErrorBoundary
|
|
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
|
|
>
|
|
<ArgsProvider {...input.args}>
|
|
<ExitProvider onExit={onExit}>
|
|
<KVProvider>
|
|
<ToastProvider>
|
|
<RouteProvider>
|
|
<TuiConfigProvider config={input.config}>
|
|
<SDKProvider
|
|
url={input.url}
|
|
directory={input.directory}
|
|
fetch={input.fetch}
|
|
headers={input.headers}
|
|
events={input.events}
|
|
>
|
|
<SyncProvider>
|
|
<ThemeProvider mode={mode}>
|
|
<LocalProvider>
|
|
<KeybindProvider>
|
|
<PromptStashProvider>
|
|
<DialogProvider>
|
|
<CommandProvider>
|
|
<FrecencyProvider>
|
|
<PromptHistoryProvider>
|
|
<PromptRefProvider>
|
|
<App />
|
|
</PromptRefProvider>
|
|
</PromptHistoryProvider>
|
|
</FrecencyProvider>
|
|
</CommandProvider>
|
|
</DialogProvider>
|
|
</PromptStashProvider>
|
|
</KeybindProvider>
|
|
</LocalProvider>
|
|
</ThemeProvider>
|
|
</SyncProvider>
|
|
</SDKProvider>
|
|
</TuiConfigProvider>
|
|
</RouteProvider>
|
|
</ToastProvider>
|
|
</KVProvider>
|
|
</ExitProvider>
|
|
</ArgsProvider>
|
|
</ErrorBoundary>
|
|
)
|
|
},
|
|
{
|
|
targetFps: 60,
|
|
gatherStats: false,
|
|
exitOnCtrlC: false,
|
|
useKittyKeyboard: {},
|
|
autoFocus: false,
|
|
openConsoleOnError: false,
|
|
consoleOptions: {
|
|
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
|
onCopySelection: (text) => {
|
|
Clipboard.copy(text).catch((error) => {
|
|
console.error(`Failed to copy console selection to clipboard: ${error}`)
|
|
})
|
|
},
|
|
},
|
|
},
|
|
)
|
|
})
|
|
}
|
|
|
|
function App() {
|
|
const route = useRoute()
|
|
const dimensions = useTerminalDimensions()
|
|
const renderer = useRenderer()
|
|
renderer.disableStdoutInterception()
|
|
const dialog = useDialog()
|
|
const local = useLocal()
|
|
const kv = useKV()
|
|
const command = useCommandDialog()
|
|
const sdk = useSDK()
|
|
const toast = useToast()
|
|
const { theme, mode, setMode } = useTheme()
|
|
const sync = useSync()
|
|
const exit = useExit()
|
|
const promptRef = usePromptRef()
|
|
|
|
useKeyboard((evt) => {
|
|
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
|
if (!renderer.getSelection()) return
|
|
|
|
// Windows Terminal-like behavior:
|
|
// - Ctrl+C copies and dismisses selection
|
|
// - Esc dismisses selection
|
|
// - Most other key input dismisses selection and is passed through
|
|
if (evt.ctrl && evt.name === "c") {
|
|
if (!Selection.copy(renderer, toast)) {
|
|
renderer.clearSelection()
|
|
return
|
|
}
|
|
|
|
evt.preventDefault()
|
|
evt.stopPropagation()
|
|
return
|
|
}
|
|
|
|
if (evt.name === "escape") {
|
|
renderer.clearSelection()
|
|
evt.preventDefault()
|
|
evt.stopPropagation()
|
|
return
|
|
}
|
|
|
|
renderer.clearSelection()
|
|
})
|
|
|
|
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
|
|
renderer.console.onCopySelection = async (text: string) => {
|
|
if (!text || text.length === 0) return
|
|
|
|
await Clipboard.copy(text)
|
|
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
|
.catch(toast.error)
|
|
|
|
renderer.clearSelection()
|
|
}
|
|
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
|
|
|
createEffect(() => {
|
|
console.log(JSON.stringify(route.data))
|
|
})
|
|
|
|
// Update terminal window title based on current route and session
|
|
createEffect(() => {
|
|
if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
|
|
|
|
if (route.data.type === "home") {
|
|
renderer.setTerminalTitle("OpenCode")
|
|
return
|
|
}
|
|
|
|
if (route.data.type === "session") {
|
|
const session = sync.session.get(route.data.sessionID)
|
|
if (!session || SessionApi.isDefaultTitle(session.title)) {
|
|
renderer.setTerminalTitle("OpenCode")
|
|
return
|
|
}
|
|
|
|
// Truncate title to 40 chars max
|
|
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
|
|
renderer.setTerminalTitle(`OC | ${title}`)
|
|
}
|
|
})
|
|
|
|
const args = useArgs()
|
|
onMount(() => {
|
|
batch(() => {
|
|
if (args.agent) local.agent.set(args.agent)
|
|
if (args.model) {
|
|
const { providerID, modelID } = Provider.parseModel(args.model)
|
|
if (!providerID || !modelID)
|
|
return toast.show({
|
|
variant: "warning",
|
|
message: `Invalid model format: ${args.model}`,
|
|
duration: 3000,
|
|
})
|
|
local.model.set({ providerID, modelID }, { recent: true })
|
|
}
|
|
// Handle --session without --fork immediately (fork is handled in createEffect below)
|
|
if (args.sessionID && !args.fork) {
|
|
route.navigate({
|
|
type: "session",
|
|
sessionID: args.sessionID,
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
let continued = false
|
|
createEffect(() => {
|
|
// When using -c, session list is loaded in blocking phase, so we can navigate at "partial"
|
|
if (continued || sync.status === "loading" || !args.continue) return
|
|
const match = sync.data.session
|
|
.toSorted((a, b) => b.time.updated - a.time.updated)
|
|
.find((x) => x.parentID === undefined)?.id
|
|
if (match) {
|
|
continued = true
|
|
if (args.fork) {
|
|
sdk.client.session.fork({ sessionID: match }).then((result) => {
|
|
if (result.data?.id) {
|
|
route.navigate({ type: "session", sessionID: result.data.id })
|
|
} else {
|
|
toast.show({ message: "Failed to fork session", variant: "error" })
|
|
}
|
|
})
|
|
} else {
|
|
route.navigate({ type: "session", sessionID: match })
|
|
}
|
|
}
|
|
})
|
|
|
|
// Handle --session with --fork: wait for sync to be fully complete before forking
|
|
// (session list loads in non-blocking phase for --session, so we must wait for "complete"
|
|
// to avoid a race where reconcile overwrites the newly forked session)
|
|
let forked = false
|
|
createEffect(() => {
|
|
if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return
|
|
forked = true
|
|
sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => {
|
|
if (result.data?.id) {
|
|
route.navigate({ type: "session", sessionID: result.data.id })
|
|
} else {
|
|
toast.show({ message: "Failed to fork session", variant: "error" })
|
|
}
|
|
})
|
|
})
|
|
|
|
createEffect(
|
|
on(
|
|
() => sync.status === "complete" && sync.data.provider.length === 0,
|
|
(isEmpty, wasEmpty) => {
|
|
// only trigger when we transition into an empty-provider state
|
|
if (!isEmpty || wasEmpty) return
|
|
dialog.replace(() => <DialogProviderList />)
|
|
},
|
|
),
|
|
)
|
|
|
|
const connected = useConnected()
|
|
command.register(() => [
|
|
{
|
|
title: "Switch session",
|
|
value: "session.list",
|
|
keybind: "session_list",
|
|
category: "Session",
|
|
suggested: sync.data.session.length > 0,
|
|
slash: {
|
|
name: "sessions",
|
|
aliases: ["resume", "continue"],
|
|
},
|
|
onSelect: () => {
|
|
dialog.replace(() => <DialogSessionList />)
|
|
},
|
|
},
|
|
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
|
? [
|
|
{
|
|
title: "Manage workspaces",
|
|
value: "workspace.list",
|
|
category: "Workspace",
|
|
suggested: true,
|
|
slash: {
|
|
name: "workspaces",
|
|
},
|
|
onSelect: () => {
|
|
dialog.replace(() => <DialogWorkspaceList />)
|
|
},
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
title: "New session",
|
|
suggested: route.data.type === "session",
|
|
value: "session.new",
|
|
keybind: "session_new",
|
|
category: "Session",
|
|
slash: {
|
|
name: "new",
|
|
aliases: ["clear"],
|
|
},
|
|
onSelect: () => {
|
|
const current = promptRef.current
|
|
// Don't require focus - if there's any text, preserve it
|
|
const currentPrompt = current?.current?.input ? current.current : undefined
|
|
const workspaceID =
|
|
route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
|
|
route.navigate({
|
|
type: "home",
|
|
initialPrompt: currentPrompt,
|
|
workspaceID,
|
|
})
|
|
dialog.clear()
|
|
},
|
|
},
|
|
{
|
|
title: "Switch model",
|
|
value: "model.list",
|
|
keybind: "model_list",
|
|
suggested: true,
|
|
category: "Agent",
|
|
slash: {
|
|
name: "models",
|
|
},
|
|
onSelect: () => {
|
|
dialog.replace(() => <DialogModel />)
|
|
},
|
|
},
|
|
{
|
|
title: "Model cycle",
|
|
value: "model.cycle_recent",
|
|
keybind: "model_cycle_recent",
|
|
category: "Agent",
|
|
hidden: true,
|
|
onSelect: () => {
|
|
local.model.cycle(1)
|
|
},
|
|
},
|
|
{
|
|
title: "Model cycle reverse",
|
|
value: "model.cycle_recent_reverse",
|
|
keybind: "model_cycle_recent_reverse",
|
|
category: "Agent",
|
|
hidden: true,
|
|
onSelect: () => {
|
|
local.model.cycle(-1)
|
|
},
|
|
},
|
|
{
|
|
title: "Favorite cycle",
|
|
value: "model.cycle_favorite",
|
|
keybind: "model_cycle_favorite",
|
|
category: "Agent",
|
|
hidden: true,
|
|
onSelect: () => {
|
|
local.model.cycleFavorite(1)
|
|
},
|
|
},
|
|
{
|
|
title: "Favorite cycle reverse",
|
|
value: "model.cycle_favorite_reverse",
|
|
keybind: "model_cycle_favorite_reverse",
|
|
category: "Agent",
|
|
hidden: true,
|
|
onSelect: () => {
|
|
local.model.cycleFavorite(-1)
|
|
},
|
|
},
|
|
{
|
|
title: "Switch agent",
|
|
value: "agent.list",
|
|
keybind: "agent_list",
|
|
category: "Agent",
|
|
slash: {
|
|
name: "agents",
|
|
},
|
|
onSelect: () => {
|
|
dialog.replace(() => <DialogAgent />)
|
|
},
|
|
},
|
|
{
|
|
title: "Toggle MCPs",
|
|
value: "mcp.list",
|
|
category: "Agent",
|
|
slash: {
|
|
name: "mcps",
|
|
},
|
|
onSelect: () => {
|
|
dialog.replace(() => <DialogMcp />)
|
|
},
|
|
},
|
|
{
|
|
title: "Agent cycle",
|
|
value: "agent.cycle",
|
|
keybind: "agent_cycle",
|
|
category: "Agent",
|
|
hidden: true,
|
|
onSelect: () => {
|
|
local.agent.move(1)
|
|
},
|
|
},
|
|
{
|
|
title: "Variant cycle",
|
|
value: "variant.cycle",
|
|
keybind: "variant_cycle",
|
|
category: "Agent",
|
|
hidden: true,
|
|
onSelect: () => {
|
|
local.model.variant.cycle()
|
|
},
|
|
},
|
|
{
|
|
title: "Agent cycle reverse",
|
|
value: "agent.cycle.reverse",
|
|
keybind: "agent_cycle_reverse",
|
|
category: "Agent",
|
|
hidden: true,
|
|
onSelect: () => {
|
|
local.agent.move(-1)
|
|
},
|
|
},
|
|
{
|
|
title: "Connect provider",
|
|
value: "provider.connect",
|
|
suggested: !connected(),
|
|
slash: {
|
|
name: "connect",
|
|
},
|
|
onSelect: () => {
|
|
dialog.replace(() => <DialogProviderList />)
|
|
},
|
|
category: "Provider",
|
|
},
|
|
{
|
|
title: "View status",
|
|
keybind: "status_view",
|
|
value: "opencode.status",
|
|
slash: {
|
|
name: "status",
|
|
},
|
|
onSelect: () => {
|
|
dialog.replace(() => <DialogStatus />)
|
|
},
|
|
category: "System",
|
|
},
|
|
{
|
|
title: "Switch theme",
|
|
value: "theme.switch",
|
|
keybind: "theme_list",
|
|
slash: {
|
|
name: "themes",
|
|
},
|
|
onSelect: () => {
|
|
dialog.replace(() => <DialogThemeList />)
|
|
},
|
|
category: "System",
|
|
},
|
|
{
|
|
title: "Toggle appearance",
|
|
value: "theme.switch_mode",
|
|
onSelect: (dialog) => {
|
|
setMode(mode() === "dark" ? "light" : "dark")
|
|
dialog.clear()
|
|
},
|
|
category: "System",
|
|
},
|
|
{
|
|
title: "Help",
|
|
value: "help.show",
|
|
slash: {
|
|
name: "help",
|
|
},
|
|
onSelect: () => {
|
|
dialog.replace(() => <DialogHelp />)
|
|
},
|
|
category: "System",
|
|
},
|
|
{
|
|
title: "Open docs",
|
|
value: "docs.open",
|
|
onSelect: () => {
|
|
open("https://opencode.ai/docs").catch(() => {})
|
|
dialog.clear()
|
|
},
|
|
category: "System",
|
|
},
|
|
{
|
|
title: "Exit the app",
|
|
value: "app.exit",
|
|
slash: {
|
|
name: "exit",
|
|
aliases: ["quit", "q"],
|
|
},
|
|
onSelect: () => exit(),
|
|
category: "System",
|
|
},
|
|
{
|
|
title: "Toggle debug panel",
|
|
category: "System",
|
|
value: "app.debug",
|
|
onSelect: (dialog) => {
|
|
renderer.toggleDebugOverlay()
|
|
dialog.clear()
|
|
},
|
|
},
|
|
{
|
|
title: "Toggle console",
|
|
category: "System",
|
|
value: "app.console",
|
|
onSelect: (dialog) => {
|
|
renderer.console.toggle()
|
|
dialog.clear()
|
|
},
|
|
},
|
|
{
|
|
title: "Write heap snapshot",
|
|
category: "System",
|
|
value: "app.heap_snapshot",
|
|
onSelect: (dialog) => {
|
|
const path = writeHeapSnapshot()
|
|
toast.show({
|
|
variant: "info",
|
|
message: `Heap snapshot written to ${path}`,
|
|
duration: 5000,
|
|
})
|
|
dialog.clear()
|
|
},
|
|
},
|
|
{
|
|
title: "Suspend terminal",
|
|
value: "terminal.suspend",
|
|
keybind: "terminal_suspend",
|
|
category: "System",
|
|
hidden: true,
|
|
onSelect: () => {
|
|
process.once("SIGCONT", () => {
|
|
renderer.resume()
|
|
})
|
|
|
|
renderer.suspend()
|
|
// pid=0 means send the signal to all processes in the process group
|
|
process.kill(0, "SIGTSTP")
|
|
},
|
|
},
|
|
{
|
|
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
|
|
value: "terminal.title.toggle",
|
|
keybind: "terminal_title_toggle",
|
|
category: "System",
|
|
onSelect: (dialog) => {
|
|
setTerminalTitleEnabled((prev) => {
|
|
const next = !prev
|
|
kv.set("terminal_title_enabled", next)
|
|
if (!next) renderer.setTerminalTitle("")
|
|
return next
|
|
})
|
|
dialog.clear()
|
|
},
|
|
},
|
|
{
|
|
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
|
|
value: "app.toggle.animations",
|
|
category: "System",
|
|
onSelect: (dialog) => {
|
|
kv.set("animations_enabled", !kv.get("animations_enabled", true))
|
|
dialog.clear()
|
|
},
|
|
},
|
|
{
|
|
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
|
value: "app.toggle.diffwrap",
|
|
category: "System",
|
|
onSelect: (dialog) => {
|
|
const current = kv.get("diff_wrap_mode", "word")
|
|
kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
|
|
dialog.clear()
|
|
},
|
|
},
|
|
])
|
|
|
|
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
|
|
command.trigger(evt.properties.command)
|
|
})
|
|
|
|
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
|
|
toast.show({
|
|
title: evt.properties.title,
|
|
message: evt.properties.message,
|
|
variant: evt.properties.variant,
|
|
duration: evt.properties.duration,
|
|
})
|
|
})
|
|
|
|
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
|
|
route.navigate({
|
|
type: "session",
|
|
sessionID: evt.properties.sessionID,
|
|
})
|
|
})
|
|
|
|
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
|
|
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
|
route.navigate({ type: "home" })
|
|
toast.show({
|
|
variant: "info",
|
|
message: "The current session was deleted",
|
|
})
|
|
}
|
|
})
|
|
|
|
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
|
|
const error = evt.properties.error
|
|
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
|
const message = (() => {
|
|
if (!error) return "An error occurred"
|
|
|
|
if (typeof error === "object") {
|
|
const data = error.data
|
|
if ("message" in data && typeof data.message === "string") {
|
|
return data.message
|
|
}
|
|
}
|
|
return String(error)
|
|
})()
|
|
|
|
toast.show({
|
|
variant: "error",
|
|
message,
|
|
duration: 5000,
|
|
})
|
|
})
|
|
|
|
sdk.event.on("installation.update-available", async (evt) => {
|
|
const version = evt.properties.version
|
|
|
|
const skipped = kv.get("skipped_version")
|
|
if (skipped && !semver.gt(version, skipped)) return
|
|
|
|
const choice = await DialogConfirm.show(
|
|
dialog,
|
|
`Update Available`,
|
|
`A new release v${version} is available. Would you like to update now?`,
|
|
"skip",
|
|
)
|
|
|
|
if (choice === false) {
|
|
kv.set("skipped_version", version)
|
|
return
|
|
}
|
|
|
|
if (choice !== true) return
|
|
|
|
toast.show({
|
|
variant: "info",
|
|
message: `Updating to v${version}...`,
|
|
duration: 30000,
|
|
})
|
|
|
|
const result = await sdk.client.global.upgrade({ target: version })
|
|
|
|
if (result.error || !result.data?.success) {
|
|
toast.show({
|
|
variant: "error",
|
|
title: "Update Failed",
|
|
message: "Update failed",
|
|
duration: 10000,
|
|
})
|
|
return
|
|
}
|
|
|
|
await DialogAlert.show(
|
|
dialog,
|
|
"Update Complete",
|
|
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
|
|
)
|
|
|
|
exit()
|
|
})
|
|
|
|
return (
|
|
<box
|
|
width={dimensions().width}
|
|
height={dimensions().height}
|
|
backgroundColor={theme.background}
|
|
onMouseDown={(evt) => {
|
|
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
|
if (evt.button !== MouseButton.RIGHT) return
|
|
|
|
if (!Selection.copy(renderer, toast)) return
|
|
evt.preventDefault()
|
|
evt.stopPropagation()
|
|
}}
|
|
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
|
|
>
|
|
<Switch>
|
|
<Match when={route.data.type === "home"}>
|
|
<Home />
|
|
</Match>
|
|
<Match when={route.data.type === "session"}>
|
|
<Session />
|
|
</Match>
|
|
</Switch>
|
|
</box>
|
|
)
|
|
}
|
|
|
|
function ErrorComponent(props: {
|
|
error: Error
|
|
reset: () => void
|
|
onExit: () => Promise<void>
|
|
mode?: "dark" | "light"
|
|
}) {
|
|
const term = useTerminalDimensions()
|
|
const renderer = useRenderer()
|
|
|
|
const handleExit = async () => {
|
|
renderer.setTerminalTitle("")
|
|
renderer.destroy()
|
|
win32FlushInputBuffer()
|
|
await props.onExit()
|
|
}
|
|
|
|
useKeyboard((evt) => {
|
|
if (evt.ctrl && evt.name === "c") {
|
|
handleExit()
|
|
}
|
|
})
|
|
const [copied, setCopied] = createSignal(false)
|
|
|
|
const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
|
|
|
|
// Choose safe fallback colors per mode since theme context may not be available
|
|
const isLight = props.mode === "light"
|
|
const colors = {
|
|
bg: isLight ? "#ffffff" : "#0a0a0a",
|
|
text: isLight ? "#1a1a1a" : "#eeeeee",
|
|
muted: isLight ? "#8a8a8a" : "#808080",
|
|
primary: isLight ? "#3b7dd8" : "#fab283",
|
|
}
|
|
|
|
if (props.error.message) {
|
|
issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
|
|
}
|
|
|
|
if (props.error.stack) {
|
|
issueURL.searchParams.set(
|
|
"description",
|
|
"```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
|
|
)
|
|
}
|
|
|
|
issueURL.searchParams.set("opencode-version", Installation.VERSION)
|
|
|
|
const copyIssueURL = () => {
|
|
Clipboard.copy(issueURL.toString()).then(() => {
|
|
setCopied(true)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<box flexDirection="column" gap={1} backgroundColor={colors.bg}>
|
|
<box flexDirection="row" gap={1} alignItems="center">
|
|
<text attributes={TextAttributes.BOLD} fg={colors.text}>
|
|
Please report an issue.
|
|
</text>
|
|
<box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}>
|
|
<text attributes={TextAttributes.BOLD} fg={colors.bg}>
|
|
Copy issue URL (exception info pre-filled)
|
|
</text>
|
|
</box>
|
|
{copied() && <text fg={colors.muted}>Successfully copied</text>}
|
|
</box>
|
|
<box flexDirection="row" gap={2} alignItems="center">
|
|
<text fg={colors.text}>A fatal error occurred!</text>
|
|
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
|
<text fg={colors.bg}>Reset TUI</text>
|
|
</box>
|
|
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
|
|
<text fg={colors.bg}>Exit</text>
|
|
</box>
|
|
</box>
|
|
<scrollbox height={Math.floor(term().height * 0.7)}>
|
|
<text fg={colors.muted}>{props.error.stack}</text>
|
|
</scrollbox>
|
|
<text fg={colors.text}>{props.error.message}</text>
|
|
</box>
|
|
)
|
|
}
|