Merge branch 'dev' into feat/canceled-prompts-in-history
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
||||
import { type AccountError } from "@/account/schema"
|
||||
import { Account } from "@/account/account"
|
||||
import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
|
||||
@@ -23,8 +23,7 @@ const DiagnosticsCommand = cmd({
|
||||
const out = await AppRuntime.runPromise(
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lsp.touchFile(args.file, true)
|
||||
yield* Effect.sleep(1000)
|
||||
yield* lsp.touchFile(args.file, "full")
|
||||
return yield* lsp.diagnostics()
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -25,7 +25,19 @@ export const GenerateCommand = {
|
||||
]
|
||||
}
|
||||
}
|
||||
const json = JSON.stringify(specs, null, 2)
|
||||
const raw = JSON.stringify(specs, null, 2)
|
||||
|
||||
// Format through prettier so output is byte-identical to committed file
|
||||
// regardless of whether ./script/format.ts runs afterward.
|
||||
const prettier = await import("prettier")
|
||||
const babel = await import("prettier/plugins/babel")
|
||||
const estree = await import("prettier/plugins/estree")
|
||||
const format = prettier.format ?? prettier.default?.format
|
||||
const json = await format(raw, {
|
||||
parser: "json",
|
||||
plugins: [babel.default ?? babel, estree.default ?? estree],
|
||||
printWidth: 120,
|
||||
})
|
||||
|
||||
// Wait for stdout to finish writing before process.exit() is called
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
@@ -985,7 +985,8 @@ export const GithubRunCommand = cmd({
|
||||
const err = result.info.error
|
||||
console.error("Agent error:", err)
|
||||
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
|
||||
throw new Error(`${err.name}: ${err.data?.message || ""}`)
|
||||
const message = "message" in err.data ? err.data.message : ""
|
||||
throw new Error(`${err.name}: ${message}`)
|
||||
}
|
||||
|
||||
const text = extractResponseText(result.parts)
|
||||
@@ -1014,7 +1015,8 @@ export const GithubRunCommand = cmd({
|
||||
const err = summary.info.error
|
||||
console.error("Summary agent error:", err)
|
||||
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
|
||||
throw new Error(`${err.name}: ${err.data?.message || ""}`)
|
||||
const message = "message" in err.data ? err.data.message : ""
|
||||
throw new Error(`${err.name}: ${message}`)
|
||||
}
|
||||
|
||||
const summaryText = extractResponseText(summary.parts)
|
||||
|
||||
@@ -168,7 +168,7 @@ export const ImportCommand = cmd({
|
||||
)
|
||||
|
||||
for (const msg of exportData.messages) {
|
||||
const msgInfo = MessageV2.Info.parse(msg.info)
|
||||
const msgInfo = MessageV2.Info.zod.parse(msg.info)
|
||||
const { id, sessionID: _, ...msgData } = msgInfo
|
||||
Database.use((db) =>
|
||||
db
|
||||
@@ -184,7 +184,7 @@ export const ImportCommand = cmd({
|
||||
)
|
||||
|
||||
for (const part of msg.parts) {
|
||||
const partInfo = MessageV2.Part.parse(part)
|
||||
const partInfo = MessageV2.Part.zod.parse(part)
|
||||
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
|
||||
Database.use((db) =>
|
||||
db
|
||||
|
||||
@@ -122,8 +122,8 @@ export function tui(input: {
|
||||
|
||||
const mode = await Terminal.getTerminalBackgroundColor()
|
||||
|
||||
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
|
||||
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.
|
||||
// Re-clear after getTerminalBackgroundColor() because setRawMode(false)
|
||||
// restores the original console mode, including processed input on Windows.
|
||||
win32DisableProcessedInput()
|
||||
|
||||
const onExit = async () => {
|
||||
@@ -148,7 +148,16 @@ export function tui(input: {
|
||||
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
@@ -333,7 +342,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
})
|
||||
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",
|
||||
@@ -420,12 +428,8 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
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
|
||||
route.navigate({
|
||||
type: "home",
|
||||
initialPrompt: currentPrompt,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -602,7 +606,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Toggle theme mode",
|
||||
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
|
||||
value: "theme.switch_mode",
|
||||
onSelect: (dialog) => {
|
||||
setMode(mode() === "dark" ? "light" : "dark")
|
||||
|
||||
@@ -3,6 +3,8 @@ import { UI } from "@/cli/ui"
|
||||
import { tui } from "./app"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { validateSession } from "./validate-session"
|
||||
|
||||
export const AttachCommand = cmd({
|
||||
command: "attach <url>",
|
||||
@@ -65,6 +67,20 @@ export const AttachCommand = cmd({
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
const config = await TuiConfig.get()
|
||||
|
||||
try {
|
||||
await validateSession({
|
||||
url: args.url,
|
||||
sessionID: args.session,
|
||||
directory,
|
||||
headers,
|
||||
})
|
||||
} catch (error) {
|
||||
UI.error(errorMessage(error))
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
await tui({
|
||||
url: args.url,
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { BoxRenderable, RGBA } from "@opentui/core"
|
||||
import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js"
|
||||
import { tint, useTheme } from "@tui/context/theme"
|
||||
|
||||
const PERIOD = 4600
|
||||
const RINGS = 3
|
||||
const WIDTH = 3.8
|
||||
const TAIL = 9.5
|
||||
const AMP = 0.55
|
||||
const TAIL_AMP = 0.16
|
||||
const BREATH_AMP = 0.05
|
||||
const BREATH_SPEED = 0.0008
|
||||
// Offset so bg ring emits from GO center at the moment the logo pulse peaks.
|
||||
const PHASE_OFFSET = 0.29
|
||||
|
||||
export type BgPulseMask = {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
pad?: number
|
||||
strength?: number
|
||||
}
|
||||
|
||||
export function BgPulse(props: { centerX?: number; centerY?: number; masks?: BgPulseMask[] }) {
|
||||
const { theme } = useTheme()
|
||||
const [now, setNow] = createSignal(performance.now())
|
||||
const [size, setSize] = createSignal<{ width: number; height: number }>({ width: 0, height: 0 })
|
||||
let box: BoxRenderable | undefined
|
||||
|
||||
const timer = setInterval(() => setNow(performance.now()), 50)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
|
||||
const sync = () => {
|
||||
if (!box) return
|
||||
setSize({ width: box.width, height: box.height })
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
sync()
|
||||
box?.on("resize", sync)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
box?.off("resize", sync)
|
||||
})
|
||||
|
||||
const grid = createMemo(() => {
|
||||
const t = now()
|
||||
const w = size().width
|
||||
const h = size().height
|
||||
if (w === 0 || h === 0) return [] as RGBA[][]
|
||||
const cxv = props.centerX ?? w / 2
|
||||
const cyv = props.centerY ?? h / 2
|
||||
const reach = Math.hypot(Math.max(cxv, w - cxv), Math.max(cyv, h - cyv) * 2) + TAIL
|
||||
const ringStates = Array.from({ length: RINGS }, (_, i) => {
|
||||
const offset = i / RINGS
|
||||
const phase = (t / PERIOD + offset - PHASE_OFFSET + 1) % 1
|
||||
const envelope = Math.sin(phase * Math.PI)
|
||||
const eased = envelope * envelope * (3 - 2 * envelope)
|
||||
return {
|
||||
head: phase * reach,
|
||||
eased,
|
||||
}
|
||||
})
|
||||
const normalizedMasks = props.masks?.map((m) => {
|
||||
const pad = m.pad ?? 2
|
||||
return {
|
||||
left: m.x - pad,
|
||||
right: m.x + m.width + pad,
|
||||
top: m.y - pad,
|
||||
bottom: m.y + m.height + pad,
|
||||
pad,
|
||||
strength: m.strength ?? 0.85,
|
||||
}
|
||||
})
|
||||
const rows = [] as RGBA[][]
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = [] as RGBA[]
|
||||
for (let x = 0; x < w; x++) {
|
||||
const dx = x + 0.5 - cxv
|
||||
const dy = (y + 0.5 - cyv) * 2
|
||||
const dist = Math.hypot(dx, dy)
|
||||
let level = 0
|
||||
for (const ring of ringStates) {
|
||||
const delta = dist - ring.head
|
||||
const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0
|
||||
const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0
|
||||
level += (crest * AMP + tail * TAIL_AMP) * ring.eased
|
||||
}
|
||||
const edgeFalloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2)
|
||||
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
|
||||
let maskAtten = 1
|
||||
if (normalizedMasks) {
|
||||
for (const m of normalizedMasks) {
|
||||
if (x < m.left || x > m.right || y < m.top || y > m.bottom) continue
|
||||
const inX = Math.min(x - m.left, m.right - x)
|
||||
const inY = Math.min(y - m.top, m.bottom - y)
|
||||
const edge = Math.min(inX / m.pad, inY / m.pad, 1)
|
||||
const eased = edge * edge * (3 - 2 * edge)
|
||||
const reduce = 1 - m.strength * eased
|
||||
if (reduce < maskAtten) maskAtten = reduce
|
||||
}
|
||||
}
|
||||
const strength = Math.min(1, ((level / RINGS) * edgeFalloff + breath * edgeFalloff) * maskAtten)
|
||||
row.push(tint(theme.backgroundPanel, theme.primary, strength * 0.7))
|
||||
}
|
||||
rows.push(row)
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
return (
|
||||
<box ref={(item: BoxRenderable) => (box = item)} width="100%" height="100%">
|
||||
<For each={grid()}>
|
||||
{(row) => (
|
||||
<box flexDirection="row">
|
||||
<For each={row}>
|
||||
{(color) => (
|
||||
<text bg={color} fg={color} selectable={false}>
|
||||
{" "}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -63,6 +63,7 @@ function init() {
|
||||
useKeyboard((evt) => {
|
||||
if (suspended()) return
|
||||
if (dialog.stack.length > 0) return
|
||||
if (evt.defaultPrevented) return
|
||||
for (const option of entries()) {
|
||||
if (!isEnabled(option)) continue
|
||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import open from "open"
|
||||
import { createSignal } from "solid-js"
|
||||
import { createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||
import { useDialog, type DialogContext } from "@tui/ui/dialog"
|
||||
import { Link } from "@tui/ui/link"
|
||||
import { GoLogo } from "./logo"
|
||||
import { BgPulse, type BgPulseMask } from "./bg-pulse"
|
||||
|
||||
const GO_URL = "https://opencode.ai/go"
|
||||
const PAD_X = 3
|
||||
const PAD_TOP_OUTER = 1
|
||||
|
||||
export type DialogGoUpsellProps = {
|
||||
onClose?: (dontShowAgain?: boolean) => void
|
||||
@@ -27,62 +31,116 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const fg = selectedForeground(theme)
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe")
|
||||
const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>()
|
||||
const [masks, setMasks] = createSignal<BgPulseMask[]>([])
|
||||
let content: BoxRenderable | undefined
|
||||
let logoBox: BoxRenderable | undefined
|
||||
let headingBox: BoxRenderable | undefined
|
||||
let descBox: BoxRenderable | undefined
|
||||
let buttonsBox: BoxRenderable | undefined
|
||||
|
||||
const sync = () => {
|
||||
if (!content || !logoBox) return
|
||||
setCenter({
|
||||
x: logoBox.x - content.x + logoBox.width / 2,
|
||||
y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER,
|
||||
})
|
||||
const next: BgPulseMask[] = []
|
||||
const baseY = PAD_TOP_OUTER
|
||||
for (const b of [headingBox, descBox, buttonsBox]) {
|
||||
if (!b) continue
|
||||
next.push({
|
||||
x: b.x - content.x,
|
||||
y: b.y - content.y + baseY,
|
||||
width: b.width,
|
||||
height: b.height,
|
||||
pad: 2,
|
||||
strength: 0.78,
|
||||
})
|
||||
}
|
||||
setMasks(next)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
sync()
|
||||
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync)
|
||||
})
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
|
||||
setSelected((s) => (s === 0 ? 1 : 0))
|
||||
setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
|
||||
return
|
||||
}
|
||||
if (evt.name !== "return") return
|
||||
if (selected() === 0) subscribe(props, dialog)
|
||||
else dismiss(props, dialog)
|
||||
if (evt.name === "return") {
|
||||
if (selected() === "subscribe") subscribe(props, dialog)
|
||||
else dismiss(props, dialog)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Free limit reached
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
<box ref={(item: BoxRenderable) => (content = item)}>
|
||||
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
|
||||
<BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
|
||||
</box>
|
||||
<box gap={1} paddingBottom={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
|
||||
$5/month.
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
|
||||
<box ref={(item: BoxRenderable) => (headingBox = item)} flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Free limit reached
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.textMuted}>Subscribe to </text>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
|
||||
OpenCode Go
|
||||
</text>
|
||||
<text fg={theme.textMuted}> for reliable access to the</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>best open-source models, starting at $5/month.</text>
|
||||
</box>
|
||||
<box alignItems="center" gap={1} paddingBottom={1}>
|
||||
<box ref={(item: BoxRenderable) => (logoBox = item)}>
|
||||
<GoLogo />
|
||||
</box>
|
||||
<Link href={GO_URL} fg={theme.primary} />
|
||||
</box>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseOver={() => setSelected(0)}
|
||||
onMouseUp={() => subscribe(props, dialog)}
|
||||
>
|
||||
<text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
|
||||
subscribe
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseOver={() => setSelected(1)}
|
||||
onMouseUp={() => dismiss(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === 1 ? fg : theme.textMuted}
|
||||
attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
|
||||
<box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseOver={() => setSelected("dismiss")}
|
||||
onMouseUp={() => dismiss(props, dialog)}
|
||||
>
|
||||
don't show again
|
||||
</text>
|
||||
<text
|
||||
fg={selected() === "dismiss" ? fg : theme.textMuted}
|
||||
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
don't show again
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={selected() === "subscribe" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseOver={() => setSelected("subscribe")}
|
||||
onMouseUp={() => subscribe(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "subscribe" ? fg : theme.text}
|
||||
attributes={selected() === "subscribe" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
subscribe
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -113,7 +113,11 @@ export function DialogSessionList() {
|
||||
const today = new Date().toDateString()
|
||||
return sessions()
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.toSorted((a, b) => {
|
||||
const updatedDay = new Date(b.time.updated).setHours(0, 0, 0, 0) - new Date(a.time.updated).setHours(0, 0, 0, 0)
|
||||
if (updatedDay !== 0) return updatedDay
|
||||
return b.time.created - a.time.created
|
||||
})
|
||||
.map((x) => {
|
||||
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
||||
|
||||
@@ -135,15 +139,10 @@ export function DialogSessionList() {
|
||||
{desc}{" "}
|
||||
<span
|
||||
style={{
|
||||
fg:
|
||||
workspaceStatus === "error"
|
||||
? theme.error
|
||||
: workspaceStatus === "disconnected"
|
||||
? theme.textMuted
|
||||
: theme.success,
|
||||
fg: workspaceStatus === "connected" ? theme.success : theme.error,
|
||||
}}
|
||||
>
|
||||
■
|
||||
●
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -139,7 +139,13 @@ export async function restoreWorkspaceSession(input: {
|
||||
total: result.data.total,
|
||||
})
|
||||
|
||||
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => {
|
||||
input.project.workspace.set(input.workspaceID)
|
||||
|
||||
try {
|
||||
await input.sync.bootstrap({ fatal: false })
|
||||
} catch (e) {}
|
||||
|
||||
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => {
|
||||
log.error("session restore refresh failed", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
@@ -229,6 +235,10 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
||||
})
|
||||
|
||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
|
||||
toast.show({
|
||||
message: "Creating workspace failed",
|
||||
variant: "error",
|
||||
})
|
||||
log.error("workspace create request failed", {
|
||||
type,
|
||||
error: errorData(err),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
|
||||
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const [store, setStore] = createStore({
|
||||
active: "restore" as "cancel" | "restore",
|
||||
})
|
||||
|
||||
const options = ["cancel", "restore"] as const
|
||||
|
||||
async function confirm() {
|
||||
if (store.active === "cancel") {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
const result = await props.onRestore?.()
|
||||
if (result === false) return
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
void confirm()
|
||||
return
|
||||
}
|
||||
if (evt.name === "left") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
setStore("active", "cancel")
|
||||
return
|
||||
}
|
||||
if (evt.name === "right") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
setStore("active", "restore")
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Workspace Unavailable
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
This session is attached to a workspace that is no longer available.
|
||||
</text>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Would you like to restore this session into a new workspace?
|
||||
</text>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
|
||||
<For each={options}>
|
||||
{(item) => (
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={item === store.active ? theme.primary : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item)
|
||||
void confirm()
|
||||
}}
|
||||
>
|
||||
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,61 @@
|
||||
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
|
||||
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
|
||||
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
|
||||
import { useTheme, tint } from "@tui/context/theme"
|
||||
import * as Sound from "@tui/util/sound"
|
||||
import { logo } from "@/cli/logo"
|
||||
import { go, logo } from "@/cli/logo"
|
||||
|
||||
export type LogoShape = {
|
||||
left: string[]
|
||||
right: string[]
|
||||
}
|
||||
|
||||
type ShimmerConfig = {
|
||||
period: number
|
||||
rings: number
|
||||
sweepFraction: number
|
||||
coreWidth: number
|
||||
coreAmp: number
|
||||
softWidth: number
|
||||
softAmp: number
|
||||
tail: number
|
||||
tailAmp: number
|
||||
haloWidth: number
|
||||
haloOffset: number
|
||||
haloAmp: number
|
||||
breathBase: number
|
||||
noise: number
|
||||
ambientAmp: number
|
||||
ambientCenter: number
|
||||
ambientWidth: number
|
||||
shadowMix: number
|
||||
primaryMix: number
|
||||
originX: number
|
||||
originY: number
|
||||
}
|
||||
|
||||
const shimmerConfig: ShimmerConfig = {
|
||||
period: 4600,
|
||||
rings: 2,
|
||||
sweepFraction: 1,
|
||||
coreWidth: 1.2,
|
||||
coreAmp: 1.9,
|
||||
softWidth: 10,
|
||||
softAmp: 1.6,
|
||||
tail: 5,
|
||||
tailAmp: 0.64,
|
||||
haloWidth: 4.3,
|
||||
haloOffset: 0.6,
|
||||
haloAmp: 0.16,
|
||||
breathBase: 0.04,
|
||||
noise: 0.1,
|
||||
ambientAmp: 0.36,
|
||||
ambientCenter: 0.5,
|
||||
ambientWidth: 0.34,
|
||||
shadowMix: 0.1,
|
||||
primaryMix: 0.3,
|
||||
originX: 4.5,
|
||||
originY: 13.5,
|
||||
}
|
||||
|
||||
// Shadow markers (rendered chars in parens):
|
||||
// _ = full shadow cell (space with bg=shadow)
|
||||
@@ -74,9 +127,6 @@ type Frame = {
|
||||
spark: number
|
||||
}
|
||||
|
||||
const LEFT = logo.left[0]?.length ?? 0
|
||||
const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i])
|
||||
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
|
||||
const NEAR = [
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
@@ -140,7 +190,7 @@ function noise(x: number, y: number, t: number) {
|
||||
}
|
||||
|
||||
function lit(char: string) {
|
||||
return char !== " " && char !== "_" && char !== "~"
|
||||
return char !== " " && char !== "_" && char !== "~" && char !== ","
|
||||
}
|
||||
|
||||
function key(x: number, y: number) {
|
||||
@@ -188,12 +238,12 @@ function route(list: Array<{ x: number; y: number }>) {
|
||||
return path
|
||||
}
|
||||
|
||||
function mapGlyphs() {
|
||||
function mapGlyphs(full: string[]) {
|
||||
const cells = [] as Array<{ x: number; y: number }>
|
||||
|
||||
for (let y = 0; y < FULL.length; y++) {
|
||||
for (let x = 0; x < (FULL[y]?.length ?? 0); x++) {
|
||||
if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y })
|
||||
for (let y = 0; y < full.length; y++) {
|
||||
for (let x = 0; x < (full[y]?.length ?? 0); x++) {
|
||||
if (lit(full[y]?.[x] ?? " ")) cells.push({ x, y })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,9 +287,25 @@ function mapGlyphs() {
|
||||
return { glyph, trace, center }
|
||||
}
|
||||
|
||||
const MAP = mapGlyphs()
|
||||
type LogoContext = {
|
||||
LEFT: number
|
||||
FULL: string[]
|
||||
SPAN: number
|
||||
MAP: ReturnType<typeof mapGlyphs>
|
||||
shape: LogoShape
|
||||
}
|
||||
|
||||
function shimmer(x: number, y: number, frame: Frame) {
|
||||
function build(shape: LogoShape): LogoContext {
|
||||
const LEFT = shape.left[0]?.length ?? 0
|
||||
const FULL = shape.left.map((line, i) => line + " ".repeat(GAP) + shape.right[i])
|
||||
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
|
||||
return { LEFT, FULL, SPAN, MAP: mapGlyphs(FULL), shape }
|
||||
}
|
||||
|
||||
const DEFAULT = build(logo)
|
||||
const GO = build(go)
|
||||
|
||||
function shimmer(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||
return frame.list.reduce((best, item) => {
|
||||
const age = frame.t - item.at
|
||||
if (age < SHIMMER_IN || age > LIFE) return best
|
||||
@@ -247,7 +313,7 @@ function shimmer(x: number, y: number, frame: Frame) {
|
||||
const dy = y * 2 + 1 - item.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const p = age / LIFE
|
||||
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
||||
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
|
||||
const lag = r - dist
|
||||
if (lag < 0.18 || lag > SHIMMER_OUT) return best
|
||||
const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
|
||||
@@ -258,19 +324,19 @@ function shimmer(x: number, y: number, frame: Frame) {
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function remain(x: number, y: number, item: Release, t: number) {
|
||||
function remain(x: number, y: number, item: Release, t: number, ctx: LogoContext) {
|
||||
const age = t - item.at
|
||||
if (age < 0 || age > LIFE) return 0
|
||||
const p = age / LIFE
|
||||
const dx = x + 0.5 - item.x - 0.5
|
||||
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
||||
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
|
||||
if (dist > r) return 1
|
||||
return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
|
||||
}
|
||||
|
||||
function wave(x: number, y: number, frame: Frame, live: boolean) {
|
||||
function wave(x: number, y: number, frame: Frame, live: boolean, ctx: LogoContext) {
|
||||
return frame.list.reduce((sum, item) => {
|
||||
const age = frame.t - item.at
|
||||
if (age < 0 || age > LIFE) return sum
|
||||
@@ -278,7 +344,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
|
||||
const dx = x + 0.5 - item.x
|
||||
const dy = y * 2 + 1 - item.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
||||
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
|
||||
const fade = (1 - p) ** 1.32
|
||||
const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52
|
||||
const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
|
||||
@@ -292,7 +358,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function field(x: number, y: number, frame: Frame) {
|
||||
function field(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||
const held = frame.hold
|
||||
const rest = frame.release
|
||||
const item = held ?? rest
|
||||
@@ -326,11 +392,11 @@ function field(x: number, y: number, frame: Frame) {
|
||||
Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
|
||||
Math.exp(-(dist * dist) / 0.15) *
|
||||
lerp(0.08, 0.42, body)
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
|
||||
return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
|
||||
}
|
||||
|
||||
function pick(x: number, y: number, frame: Frame) {
|
||||
function pick(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||
const held = frame.hold
|
||||
const rest = frame.release
|
||||
const item = held ?? rest
|
||||
@@ -339,26 +405,26 @@ function pick(x: number, y: number, frame: Frame) {
|
||||
const dx = x + 0.5 - item.x - 0.5
|
||||
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
|
||||
return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
|
||||
}
|
||||
|
||||
function select(x: number, y: number) {
|
||||
const direct = MAP.glyph.get(key(x, y))
|
||||
function select(x: number, y: number, ctx: LogoContext) {
|
||||
const direct = ctx.MAP.glyph.get(key(x, y))
|
||||
if (direct !== undefined) return direct
|
||||
|
||||
const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find(
|
||||
const near = NEAR.map(([dx, dy]) => ctx.MAP.glyph.get(key(x + dx, y + dy))).find(
|
||||
(item): item is number => item !== undefined,
|
||||
)
|
||||
return near
|
||||
}
|
||||
|
||||
function trace(x: number, y: number, frame: Frame) {
|
||||
function trace(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||
const held = frame.hold
|
||||
const rest = frame.release
|
||||
const item = held ?? rest
|
||||
if (!item || item.glyph === undefined) return 0
|
||||
const step = MAP.trace.get(key(x, y))
|
||||
const step = ctx.MAP.trace.get(key(x, y))
|
||||
if (!step || step.glyph !== item.glyph || step.l < 2) return 0
|
||||
const age = frame.t - item.at
|
||||
const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
|
||||
@@ -368,29 +434,125 @@ function trace(x: number, y: number, frame: Frame) {
|
||||
const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
|
||||
const tail = (head - TAIL + step.l) % step.l
|
||||
const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail))
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
|
||||
const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise)
|
||||
const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise)
|
||||
const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
|
||||
return (core + glow + trail) * appear * fade
|
||||
}
|
||||
|
||||
function bloom(x: number, y: number, frame: Frame) {
|
||||
function idle(
|
||||
x: number,
|
||||
pixelY: number,
|
||||
frame: Frame,
|
||||
ctx: LogoContext,
|
||||
state: IdleState,
|
||||
): { glow: number; peak: number; primary: number } {
|
||||
const cfg = state.cfg
|
||||
const dx = x + 0.5 - cfg.originX
|
||||
const dy = pixelY - cfg.originY
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const angle = Math.atan2(dy, dx)
|
||||
const wob1 = noise(x * 0.32, pixelY * 0.25, frame.t * 0.0005) - 0.5
|
||||
const wob2 = noise(x * 0.12, pixelY * 0.08, frame.t * 0.00022) - 0.5
|
||||
const ripple = Math.sin(angle * 3 + frame.t * 0.0012) * 0.3
|
||||
const jitter = (wob1 * 0.55 + wob2 * 0.32 + ripple * 0.18) * cfg.noise
|
||||
const traveled = dist + jitter
|
||||
let glow = 0
|
||||
let peak = 0
|
||||
let halo = 0
|
||||
let primary = 0
|
||||
let ambient = 0
|
||||
for (const active of state.active) {
|
||||
const head = active.head
|
||||
const eased = active.eased
|
||||
const delta = traveled - head
|
||||
// Use shallower exponent (1.6 vs 2) for softer edges on the Gaussians
|
||||
// so adjacent pixels have smaller brightness deltas
|
||||
const core = Math.exp(-(Math.abs(delta / cfg.coreWidth) ** 1.8))
|
||||
const soft = Math.exp(-(Math.abs(delta / cfg.softWidth) ** 1.6))
|
||||
const tailRange = cfg.tail * 2.6
|
||||
const tail = delta < 0 && delta > -tailRange ? (1 + delta / tailRange) ** 2.6 : 0
|
||||
const haloDelta = delta + cfg.haloOffset
|
||||
const haloBand = Math.exp(-(Math.abs(haloDelta / cfg.haloWidth) ** 1.6))
|
||||
glow += (soft * cfg.softAmp + tail * cfg.tailAmp) * eased
|
||||
peak += core * cfg.coreAmp * eased
|
||||
halo += haloBand * cfg.haloAmp * eased
|
||||
// Primary-tinted fringe follows the halo (which trails behind the core) and the tail
|
||||
primary += (haloBand + tail * 0.6) * eased
|
||||
ambient += active.ambient
|
||||
}
|
||||
ambient /= state.rings
|
||||
return {
|
||||
glow: glow / state.rings,
|
||||
peak: cfg.breathBase + ambient + (peak + halo) / state.rings,
|
||||
primary: (primary / state.rings) * cfg.primaryMix,
|
||||
}
|
||||
}
|
||||
|
||||
function bloom(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||
const item = frame.glow
|
||||
if (!item) return 0
|
||||
const glyph = MAP.glyph.get(key(x, y))
|
||||
const glyph = ctx.MAP.glyph.get(key(x, y))
|
||||
if (glyph !== item.glyph) return 0
|
||||
const age = frame.t - item.at
|
||||
if (age < 0 || age > GLOW_OUT) return 0
|
||||
const p = age / GLOW_OUT
|
||||
const flash = (1 - p) ** 2
|
||||
const dx = x + 0.5 - MAP.center.get(item.glyph)!.x
|
||||
const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y
|
||||
const dx = x + 0.5 - ctx.MAP.center.get(item.glyph)!.x
|
||||
const dy = y * 2 + 1 - ctx.MAP.center.get(item.glyph)!.y
|
||||
const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
|
||||
return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
|
||||
}
|
||||
|
||||
export function Logo() {
|
||||
type IdleState = {
|
||||
cfg: ShimmerConfig
|
||||
reach: number
|
||||
rings: number
|
||||
active: Array<{
|
||||
head: number
|
||||
eased: number
|
||||
ambient: number
|
||||
}>
|
||||
}
|
||||
|
||||
function buildIdleState(t: number, ctx: LogoContext): IdleState {
|
||||
const cfg = shimmerConfig
|
||||
const w = ctx.FULL[0]?.length ?? 1
|
||||
const h = ctx.FULL.length * 2
|
||||
const corners: [number, number][] = [
|
||||
[0, 0],
|
||||
[w, 0],
|
||||
[0, h],
|
||||
[w, h],
|
||||
]
|
||||
let maxCorner = 0
|
||||
for (const [cx, cy] of corners) {
|
||||
const d = Math.hypot(cx - cfg.originX, cy - cfg.originY)
|
||||
if (d > maxCorner) maxCorner = d
|
||||
}
|
||||
const reach = maxCorner + cfg.tail * 2
|
||||
const rings = Math.max(1, Math.floor(cfg.rings))
|
||||
const active = [] as IdleState["active"]
|
||||
for (let i = 0; i < rings; i++) {
|
||||
const offset = i / rings
|
||||
const cyclePhase = (t / cfg.period + offset) % 1
|
||||
if (cyclePhase >= cfg.sweepFraction) continue
|
||||
const phase = cyclePhase / cfg.sweepFraction
|
||||
const envelope = Math.sin(phase * Math.PI)
|
||||
const eased = envelope * envelope * (3 - 2 * envelope)
|
||||
const d = (phase - cfg.ambientCenter) / cfg.ambientWidth
|
||||
active.push({
|
||||
head: phase * reach,
|
||||
eased,
|
||||
ambient: Math.abs(d) < 1 ? (1 - d * d) ** 2 * cfg.ambientAmp : 0,
|
||||
})
|
||||
}
|
||||
return { cfg, reach, rings, active }
|
||||
}
|
||||
|
||||
export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) {
|
||||
const ctx = props.shape ? build(props.shape) : DEFAULT
|
||||
const { theme } = useTheme()
|
||||
const [rings, setRings] = createSignal<Ring[]>([])
|
||||
const [hold, setHold] = createSignal<Hold>()
|
||||
@@ -430,6 +592,7 @@ export function Logo() {
|
||||
}
|
||||
if (!live) setRelease(undefined)
|
||||
if (live || hold() || release() || glow()) return
|
||||
if (props.idle) return
|
||||
stop()
|
||||
}
|
||||
|
||||
@@ -438,8 +601,20 @@ export function Logo() {
|
||||
timer = setInterval(tick, 16)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
hum = false
|
||||
Sound.dispose()
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (!props.idle) return
|
||||
setNow(performance.now())
|
||||
start()
|
||||
})
|
||||
|
||||
const hit = (x: number, y: number) => {
|
||||
const char = FULL[y]?.[x]
|
||||
const char = ctx.FULL[y]?.[x]
|
||||
return char !== undefined && char !== " "
|
||||
}
|
||||
|
||||
@@ -448,7 +623,7 @@ export function Logo() {
|
||||
if (last) burst(last.x, last.y)
|
||||
setNow(t)
|
||||
if (!last) setRelease(undefined)
|
||||
setHold({ x, y, at: t, glyph: select(x, y) })
|
||||
setHold({ x, y, at: t, glyph: select(x, y, ctx) })
|
||||
hum = false
|
||||
start()
|
||||
}
|
||||
@@ -508,6 +683,8 @@ export function Logo() {
|
||||
}
|
||||
})
|
||||
|
||||
const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined))
|
||||
|
||||
const renderLine = (
|
||||
line: string,
|
||||
y: number,
|
||||
@@ -516,24 +693,64 @@ export function Logo() {
|
||||
off: number,
|
||||
frame: Frame,
|
||||
dusk: Frame,
|
||||
state: IdleState | undefined,
|
||||
): JSX.Element[] => {
|
||||
const shadow = tint(theme.background, ink, 0.25)
|
||||
const attrs = bold ? TextAttributes.BOLD : undefined
|
||||
|
||||
return Array.from(line).map((char, i) => {
|
||||
const h = field(off + i, y, frame)
|
||||
const n = wave(off + i, y, frame, lit(char)) + h
|
||||
const s = wave(off + i, y, dusk, false) + h
|
||||
const p = lit(char) ? pick(off + i, y, frame) : 0
|
||||
const e = lit(char) ? trace(off + i, y, frame) : 0
|
||||
const b = lit(char) ? bloom(off + i, y, frame) : 0
|
||||
const q = shimmer(off + i, y, frame)
|
||||
if (char === " ") {
|
||||
return (
|
||||
<text fg={ink} attributes={attrs} selectable={false}>
|
||||
{char}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
const h = field(off + i, y, frame, ctx)
|
||||
const charLit = lit(char)
|
||||
// Sub-pixel sampling: cells are 2 pixels tall. Sample at top (y*2) and bottom (y*2+1) pixel rows.
|
||||
const pulseTop = state ? idle(off + i, y * 2, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
|
||||
const pulseBot = state ? idle(off + i, y * 2 + 1, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
|
||||
const peakMixTop = charLit ? Math.min(1, pulseTop.peak) : 0
|
||||
const peakMixBot = charLit ? Math.min(1, pulseBot.peak) : 0
|
||||
const primaryMixTop = charLit ? Math.min(1, pulseTop.primary) : 0
|
||||
const primaryMixBot = charLit ? Math.min(1, pulseBot.primary) : 0
|
||||
// Layer primary tint first, then white peak on top — so the halo/tail pulls toward primary,
|
||||
// while the bright core stays pure white
|
||||
const inkTopTint = primaryMixTop > 0 ? tint(ink, theme.primary, primaryMixTop) : ink
|
||||
const inkBotTint = primaryMixBot > 0 ? tint(ink, theme.primary, primaryMixBot) : ink
|
||||
const inkTop = peakMixTop > 0 ? tint(inkTopTint, PEAK, peakMixTop) : inkTopTint
|
||||
const inkBot = peakMixBot > 0 ? tint(inkBotTint, PEAK, peakMixBot) : inkBotTint
|
||||
// For the non-peak-aware brightness channels, use the average of top/bot
|
||||
const pulse = {
|
||||
glow: (pulseTop.glow + pulseBot.glow) / 2,
|
||||
peak: (pulseTop.peak + pulseBot.peak) / 2,
|
||||
primary: (pulseTop.primary + pulseBot.primary) / 2,
|
||||
}
|
||||
const peakMix = charLit ? Math.min(1, pulse.peak) : 0
|
||||
const primaryMix = charLit ? Math.min(1, pulse.primary) : 0
|
||||
const inkPrimary = primaryMix > 0 ? tint(ink, theme.primary, primaryMix) : ink
|
||||
const inkTinted = peakMix > 0 ? tint(inkPrimary, PEAK, peakMix) : inkPrimary
|
||||
const shadowMixCfg = state?.cfg.shadowMix ?? shimmerConfig.shadowMix
|
||||
const shadowMixTop = Math.min(1, pulseTop.peak * shadowMixCfg)
|
||||
const shadowMixBot = Math.min(1, pulseBot.peak * shadowMixCfg)
|
||||
const shadowTop = shadowMixTop > 0 ? tint(shadow, PEAK, shadowMixTop) : shadow
|
||||
const shadowBot = shadowMixBot > 0 ? tint(shadow, PEAK, shadowMixBot) : shadow
|
||||
const shadowMix = Math.min(1, pulse.peak * shadowMixCfg)
|
||||
const shadowTinted = shadowMix > 0 ? tint(shadow, PEAK, shadowMix) : shadow
|
||||
const n = wave(off + i, y, frame, charLit, ctx) + h
|
||||
const s = wave(off + i, y, dusk, false, ctx) + h
|
||||
const p = charLit ? pick(off + i, y, frame, ctx) : 0
|
||||
const e = charLit ? trace(off + i, y, frame, ctx) : 0
|
||||
const b = charLit ? bloom(off + i, y, frame, ctx) : 0
|
||||
const q = shimmer(off + i, y, frame, ctx)
|
||||
|
||||
if (char === "_") {
|
||||
return (
|
||||
<text
|
||||
fg={shade(ink, theme, s * 0.08)}
|
||||
bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))}
|
||||
fg={shade(inkTinted, theme, s * 0.08)}
|
||||
bg={shade(shadowTinted, theme, ghost(s, 0.24) + ghost(q, 0.06))}
|
||||
attributes={attrs}
|
||||
selectable={false}
|
||||
>
|
||||
@@ -545,8 +762,8 @@ export function Logo() {
|
||||
if (char === "^") {
|
||||
return (
|
||||
<text
|
||||
fg={shade(ink, theme, n + p + e + b)}
|
||||
bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
|
||||
fg={shade(inkTop, theme, n + p + e + b)}
|
||||
bg={shade(shadowBot, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
|
||||
attributes={attrs}
|
||||
selectable={false}
|
||||
>
|
||||
@@ -557,34 +774,60 @@ export function Logo() {
|
||||
|
||||
if (char === "~") {
|
||||
return (
|
||||
<text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
|
||||
<text fg={shade(shadowTop, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
|
||||
▀
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
if (char === " ") {
|
||||
if (char === ",") {
|
||||
return (
|
||||
<text fg={ink} attributes={attrs} selectable={false}>
|
||||
{char}
|
||||
<text fg={shade(shadowBot, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
|
||||
▄
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
// Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values
|
||||
if (char === "█") {
|
||||
return (
|
||||
<text
|
||||
fg={shade(inkTop, theme, n + p + e + b)}
|
||||
bg={shade(inkBot, theme, n + p + e + b)}
|
||||
attributes={attrs}
|
||||
selectable={false}
|
||||
>
|
||||
▀
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
// ▀ top-half-lit: fg uses top-pixel sample, bg stays transparent/panel
|
||||
if (char === "▀") {
|
||||
return (
|
||||
<text fg={shade(inkTop, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||
▀
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
// ▄ bottom-half-lit: fg uses bottom-pixel sample
|
||||
if (char === "▄") {
|
||||
return (
|
||||
<text fg={shade(inkBot, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||
▄
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<text fg={shade(ink, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||
<text fg={shade(inkTinted, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||
{char}
|
||||
</text>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
hum = false
|
||||
Sound.dispose()
|
||||
})
|
||||
|
||||
const mouse = (evt: MouseEvent) => {
|
||||
if (!box) return
|
||||
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
|
||||
@@ -613,17 +856,28 @@ export function Logo() {
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
width={FULL[0]?.length ?? 0}
|
||||
height={FULL.length}
|
||||
width={ctx.FULL[0]?.length ?? 0}
|
||||
height={ctx.FULL.length}
|
||||
zIndex={1}
|
||||
onMouse={mouse}
|
||||
/>
|
||||
<For each={logo.left}>
|
||||
<For each={ctx.shape.left}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
|
||||
<box flexDirection="row">
|
||||
{renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())}
|
||||
{renderLine(line, index(), props.ink ?? theme.textMuted, !!props.ink, 0, frame(), dusk(), idleState())}
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
{renderLine(
|
||||
ctx.shape.right[index()],
|
||||
index(),
|
||||
props.ink ?? theme.text,
|
||||
true,
|
||||
ctx.LEFT + GAP,
|
||||
frame(),
|
||||
dusk(),
|
||||
idleState(),
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
@@ -631,3 +885,9 @@ export function Logo() {
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export function GoLogo() {
|
||||
const { theme } = useTheme()
|
||||
const base = tint(theme.background, theme.text, 0.62)
|
||||
return <Logo shape={go} ink={base} idle />
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
|
||||
import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
|
||||
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Filesystem } from "@/util"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { tint, useTheme } from "@tui/context/theme"
|
||||
import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { MessageID, PartID } from "@/session/schema"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { usePromptHistory, type PromptInfo } from "./history"
|
||||
import { assign } from "./part"
|
||||
@@ -35,8 +36,11 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
|
||||
import { DialogAlert } from "../../ui/dialog-alert"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { createFadeIn } from "../../util/signal"
|
||||
import { useTextareaKeybindings } from "../textarea-keybindings"
|
||||
import { DialogSkill } from "../dialog-skill"
|
||||
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
|
||||
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
||||
import { useArgs } from "@tui/context/args"
|
||||
|
||||
export type PromptProps = {
|
||||
@@ -75,6 +79,12 @@ function randomIndex(count: number) {
|
||||
return Math.floor(Math.random() * count)
|
||||
}
|
||||
|
||||
function fadeColor(color: RGBA, alpha: number) {
|
||||
return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
|
||||
}
|
||||
|
||||
let stashed: { prompt: PromptInfo; cursor: number } | undefined
|
||||
|
||||
export function Prompt(props: PromptProps) {
|
||||
let input: TextareaRenderable
|
||||
let anchor: BoxRenderable
|
||||
@@ -85,6 +95,7 @@ export function Prompt(props: PromptProps) {
|
||||
const args = useArgs()
|
||||
const sdk = useSDK()
|
||||
const route = useRoute()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
@@ -95,6 +106,7 @@ export function Prompt(props: PromptProps) {
|
||||
const renderer = useRenderer()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||
@@ -233,9 +245,11 @@ export function Prompt(props: PromptProps) {
|
||||
keybind: "input_submit",
|
||||
category: "Prompt",
|
||||
hidden: true,
|
||||
onSelect: (dialog) => {
|
||||
onSelect: async (dialog) => {
|
||||
if (!input.focused) return
|
||||
void submit()
|
||||
const handled = await submit()
|
||||
if (!handled) return
|
||||
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -433,26 +447,47 @@ export function Prompt(props: PromptProps) {
|
||||
},
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const saved = stashed
|
||||
stashed = undefined
|
||||
if (store.prompt.input) return
|
||||
if (saved && saved.prompt.input) {
|
||||
input.setText(saved.prompt.input)
|
||||
setStore("prompt", saved.prompt)
|
||||
restoreExtmarksFromParts(saved.prompt.parts)
|
||||
input.cursorOffset = saved.cursor
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (store.prompt.input) {
|
||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||
}
|
||||
props.ref?.(undefined)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
if (props.visible === false || dialog.stack.length > 0) {
|
||||
input.blur()
|
||||
if (input.focused) input.blur()
|
||||
return
|
||||
}
|
||||
|
||||
// Slot/plugin updates can remount the background prompt while a dialog is open.
|
||||
// Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
|
||||
input.focus()
|
||||
if (!input.focused) input.focus()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
const capture =
|
||||
store.mode === "normal"
|
||||
? auto()?.visible
|
||||
? (["escape", "navigate", "submit", "tab"] as const)
|
||||
: (["tab"] as const)
|
||||
: undefined
|
||||
input.traits = {
|
||||
capture: auto()?.visible ? ["escape", "navigate", "submit", "tab"] : undefined,
|
||||
capture,
|
||||
suspend: !!props.disabled || store.mode === "shell",
|
||||
status: store.mode === "shell" ? "SHELL" : undefined,
|
||||
}
|
||||
@@ -599,20 +634,48 @@ export function Prompt(props: PromptProps) {
|
||||
setStore("prompt", "input", input.plainText)
|
||||
syncExtmarksWithPromptParts()
|
||||
}
|
||||
if (props.disabled) return
|
||||
if (autocomplete?.visible) return
|
||||
if (!store.prompt.input) return
|
||||
if (props.disabled) return false
|
||||
if (autocomplete?.visible) return false
|
||||
if (!store.prompt.input) return false
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return
|
||||
if (!agent) return false
|
||||
const trimmed = store.prompt.input.trim()
|
||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||
void exit()
|
||||
return
|
||||
return true
|
||||
}
|
||||
const selectedModel = local.model.current()
|
||||
if (!selectedModel) {
|
||||
void promptModelWarning()
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined
|
||||
const workspaceID = workspaceSession?.workspaceID
|
||||
const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined
|
||||
if (props.sessionID && workspaceID && workspaceStatus !== "connected") {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceUnavailable
|
||||
onRestore={() => {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceCreate
|
||||
onSelect={(nextWorkspaceID) =>
|
||||
restoreWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
workspaceID: nextWorkspaceID,
|
||||
sessionID: props.sessionID!,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
))
|
||||
return false
|
||||
}
|
||||
|
||||
let sessionID = props.sessionID
|
||||
@@ -627,7 +690,7 @@ export function Prompt(props: PromptProps) {
|
||||
variant: "error",
|
||||
})
|
||||
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
sessionID = res.data.id
|
||||
@@ -741,6 +804,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
}, 50)
|
||||
input.clear()
|
||||
return true
|
||||
}
|
||||
const exit = useExit()
|
||||
|
||||
@@ -841,6 +905,14 @@ export function Prompt(props: PromptProps) {
|
||||
return !!current
|
||||
})
|
||||
|
||||
const agentMetaAlpha = createFadeIn(() => !!local.agent.current(), animationsEnabled)
|
||||
const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled)
|
||||
const variantMetaAlpha = createFadeIn(
|
||||
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
|
||||
animationsEnabled,
|
||||
)
|
||||
const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha()))
|
||||
|
||||
const placeholderText = createMemo(() => {
|
||||
if (props.showPlaceholder === false) return undefined
|
||||
if (store.mode === "shell") {
|
||||
@@ -901,7 +973,7 @@ export function Prompt(props: PromptProps) {
|
||||
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={highlight()}
|
||||
borderColor={borderHighlight()}
|
||||
customBorderChars={{
|
||||
...SplitBorder.customBorderChars,
|
||||
bottomLeft: "╹",
|
||||
@@ -1122,17 +1194,25 @@ export function Prompt(props: PromptProps) {
|
||||
<Show when={local.agent.current()} fallback={<box height={1} />}>
|
||||
{(agent) => (
|
||||
<>
|
||||
<text fg={highlight()}>{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} </text>
|
||||
<text fg={fadeColor(highlight(), agentMetaAlpha())}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
|
||||
>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{currentProviderLabel()}</text>
|
||||
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>{currentProviderLabel()}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text fg={fadeColor(theme.textMuted, variantMetaAlpha())}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
<span style={{ fg: fadeColor(theme.warning, variantMetaAlpha()), bold: true }}>
|
||||
{local.model.variant.current()}
|
||||
</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -1152,7 +1232,7 @@ export function Prompt(props: PromptProps) {
|
||||
<box
|
||||
height={1}
|
||||
border={["left"]}
|
||||
borderColor={highlight()}
|
||||
borderColor={borderHighlight()}
|
||||
customBorderChars={{
|
||||
...EmptyBorder,
|
||||
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
|
||||
|
||||
@@ -26,7 +26,6 @@ const TuiLegacy = z
|
||||
interface MigrateInput {
|
||||
cwd: string
|
||||
directories: string[]
|
||||
custom?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,8 +132,10 @@ async function backupAndStripLegacy(file: string, source: string) {
|
||||
}
|
||||
|
||||
async function opencodeFiles(input: { directories: string[]; cwd: string }) {
|
||||
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd)
|
||||
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
|
||||
const files = [
|
||||
...ConfigPaths.fileInDirectory(Global.Path.config, "opencode"),
|
||||
...(await Filesystem.findUp(["opencode.json", "opencode.jsonc"], input.cwd, undefined, { rootFirst: true })),
|
||||
]
|
||||
for (const dir of unique(input.directories)) {
|
||||
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const TuiInfo = z
|
||||
$schema: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
keybinds: KeybindOverride.optional(),
|
||||
plugin: ConfigPlugin.Spec.array().optional(),
|
||||
plugin: ConfigPlugin.Spec.zod.array().optional(),
|
||||
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
|
||||
})
|
||||
.extend(TuiOptions.shape)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * as TuiConfig from "./tui"
|
||||
|
||||
import z from "zod"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import { Context, Effect, Fiber, Layer } from "effect"
|
||||
@@ -9,205 +11,209 @@ import { Flag } from "@/flag/flag"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Global } from "@/global"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Npm } from "@opencode-ai/shared/npm"
|
||||
import { CurrentWorkingDirectory } from "./cwd"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { ConfigKeybinds } from "@/config/keybinds"
|
||||
import { InstallationLocal, InstallationVersion } from "@/installation/version"
|
||||
import { makeRuntime } from "@/cli/effect/runtime"
|
||||
import { makeRuntime } from "@/effect/runtime"
|
||||
import { Filesystem, Log } from "@/util"
|
||||
import { ConfigVariable } from "@/config/variable"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
export namespace TuiConfig {
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
|
||||
export const Info = TuiInfo
|
||||
export const Info = TuiInfo
|
||||
|
||||
type Acc = {
|
||||
result: Info
|
||||
}
|
||||
type Acc = {
|
||||
result: Info
|
||||
}
|
||||
|
||||
type State = {
|
||||
config: Info
|
||||
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
||||
}
|
||||
type State = {
|
||||
config: Info
|
||||
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
||||
}
|
||||
|
||||
export type Info = z.output<typeof Info> & {
|
||||
// Internal resolved plugin list used by runtime loading.
|
||||
plugin_origins?: ConfigPlugin.Origin[]
|
||||
}
|
||||
export type Info = z.output<typeof Info> & {
|
||||
// Internal resolved plugin list used by runtime loading.
|
||||
plugin_origins?: ConfigPlugin.Origin[]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<Info>
|
||||
readonly waitForDependencies: () => Effect.Effect<void>
|
||||
}
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<Info>
|
||||
readonly waitForDependencies: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
|
||||
|
||||
function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
|
||||
if (Filesystem.contains(ctx.directory, file)) return "local"
|
||||
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
|
||||
return "global"
|
||||
}
|
||||
function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
|
||||
if (Filesystem.contains(ctx.directory, file)) return "local"
|
||||
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
|
||||
return "global"
|
||||
}
|
||||
|
||||
function customPath() {
|
||||
return Flag.OPENCODE_TUI_CONFIG
|
||||
}
|
||||
|
||||
function normalize(raw: Record<string, unknown>) {
|
||||
const data = { ...raw }
|
||||
if (!("tui" in data)) return data
|
||||
if (!isRecord(data.tui)) {
|
||||
delete data.tui
|
||||
return data
|
||||
}
|
||||
|
||||
const tui = data.tui
|
||||
function normalize(raw: Record<string, unknown>) {
|
||||
const data = { ...raw }
|
||||
if (!("tui" in data)) return data
|
||||
if (!isRecord(data.tui)) {
|
||||
delete data.tui
|
||||
return {
|
||||
...tui,
|
||||
...data,
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
async function resolvePlugins(config: Info, configFilepath: string) {
|
||||
if (!config.plugin) return config
|
||||
for (let i = 0; i < config.plugin.length; i++) {
|
||||
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
|
||||
const data = await loadFile(file)
|
||||
acc.result = mergeDeep(acc.result, data)
|
||||
if (!data.plugin?.length) return
|
||||
|
||||
const scope = pluginScope(file, ctx)
|
||||
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
||||
...(acc.result.plugin_origins ?? []),
|
||||
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
||||
])
|
||||
acc.result.plugin = plugins.map((item) => item.spec)
|
||||
acc.result.plugin_origins = plugins
|
||||
}
|
||||
|
||||
async function loadState(ctx: { directory: string }) {
|
||||
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
|
||||
const directories = await ConfigPaths.directories(ctx.directory)
|
||||
const custom = customPath()
|
||||
await migrateTuiConfig({ directories, custom, cwd: ctx.directory })
|
||||
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
|
||||
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
|
||||
|
||||
const acc: Acc = {
|
||||
result: {},
|
||||
}
|
||||
|
||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
await mergeFile(acc, custom, ctx)
|
||||
log.debug("loaded custom tui config", { path: custom })
|
||||
}
|
||||
|
||||
for (const file of projectFiles) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
|
||||
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
const keybinds = { ...(acc.result.keybinds ?? {}) }
|
||||
if (process.platform === "win32") {
|
||||
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
||||
keybinds.terminal_suspend = "none"
|
||||
keybinds.input_undo ??= unique([
|
||||
"ctrl+z",
|
||||
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
|
||||
]).join(",")
|
||||
}
|
||||
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
|
||||
|
||||
return {
|
||||
config: acc.result,
|
||||
dirs: acc.result.plugin?.length ? dirs : [],
|
||||
}
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* CurrentWorkingDirectory
|
||||
const npm = yield* Npm.Service
|
||||
const data = yield* Effect.promise(() => loadState({ directory }))
|
||||
const deps = yield* Effect.forEach(
|
||||
data.dirs,
|
||||
(dir) =>
|
||||
npm
|
||||
.install(dir, {
|
||||
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
|
||||
})
|
||||
.pipe(Effect.forkScoped),
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
)
|
||||
|
||||
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
|
||||
|
||||
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
|
||||
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
|
||||
)
|
||||
return Service.of({ get, waitForDependencies })
|
||||
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function waitForDependencies() {
|
||||
await runPromise((svc) => svc.waitForDependencies())
|
||||
}
|
||||
|
||||
export async function get() {
|
||||
return runPromise((svc) => svc.get())
|
||||
}
|
||||
|
||||
async function loadFile(filepath: string): Promise<Info> {
|
||||
const text = await ConfigPaths.readFile(filepath)
|
||||
if (!text) return {}
|
||||
return load(text, filepath).catch((error) => {
|
||||
log.warn("failed to load tui config", { path: filepath, error })
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||
return ConfigParse.load(Info, text, {
|
||||
type: "path",
|
||||
path: configFilepath,
|
||||
missing: "empty",
|
||||
normalize: (data) => {
|
||||
if (!isRecord(data)) return {}
|
||||
|
||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||
return normalize(data)
|
||||
},
|
||||
})
|
||||
.then((data) => resolvePlugins(data, configFilepath))
|
||||
.catch((error) => {
|
||||
log.warn("invalid tui config", { path: configFilepath, error })
|
||||
return {}
|
||||
})
|
||||
const tui = data.tui
|
||||
delete data.tui
|
||||
return {
|
||||
...tui,
|
||||
...data,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePlugins(config: Info, configFilepath: string) {
|
||||
if (!config.plugin) return config
|
||||
for (let i = 0; i < config.plugin.length; i++) {
|
||||
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
|
||||
const data = await loadFile(file)
|
||||
acc.result = mergeDeep(acc.result, data)
|
||||
if (!data.plugin?.length) return
|
||||
|
||||
const scope = pluginScope(file, ctx)
|
||||
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
||||
...(acc.result.plugin_origins ?? []),
|
||||
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
||||
])
|
||||
acc.result.plugin = plugins.map((item) => item.spec)
|
||||
acc.result.plugin_origins = plugins
|
||||
}
|
||||
|
||||
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
|
||||
// Every config dir we may read from: global config dir, any `.opencode`
|
||||
// folders between cwd and home, and OPENCODE_CONFIG_DIR.
|
||||
const directories = yield* ConfigPaths.directories(ctx.directory)
|
||||
yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory }))
|
||||
|
||||
const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* ConfigPaths.files("tui", ctx.directory)
|
||||
|
||||
const acc: Acc = {
|
||||
result: {},
|
||||
}
|
||||
|
||||
// 1. Global tui config (lowest precedence).
|
||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
// 2. Explicit OPENCODE_TUI_CONFIG override, if set.
|
||||
if (Flag.OPENCODE_TUI_CONFIG) {
|
||||
const configFile = Flag.OPENCODE_TUI_CONFIG
|
||||
yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie)
|
||||
log.debug("loaded custom tui config", { path: configFile })
|
||||
}
|
||||
|
||||
// 3. Project tui files, applied root-first so the closest file wins.
|
||||
for (const file of projectFiles) {
|
||||
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
// 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while
|
||||
// walking up the tree. Also returned below so callers can install plugin
|
||||
// dependencies from each location.
|
||||
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
|
||||
}
|
||||
}
|
||||
|
||||
const keybinds = { ...(acc.result.keybinds ?? {}) }
|
||||
if (process.platform === "win32") {
|
||||
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
||||
keybinds.terminal_suspend = "none"
|
||||
keybinds.input_undo ??= unique([
|
||||
"ctrl+z",
|
||||
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
|
||||
]).join(",")
|
||||
}
|
||||
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
|
||||
|
||||
return {
|
||||
config: acc.result,
|
||||
dirs: acc.result.plugin?.length ? dirs : [],
|
||||
}
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* CurrentWorkingDirectory
|
||||
const npm = yield* Npm.Service
|
||||
const data = yield* loadState({ directory })
|
||||
const deps = yield* Effect.forEach(
|
||||
data.dirs,
|
||||
(dir) =>
|
||||
npm
|
||||
.install(dir, {
|
||||
add: [
|
||||
{
|
||||
name: "@opencode-ai/plugin",
|
||||
version: InstallationLocal ? undefined : InstallationVersion,
|
||||
},
|
||||
],
|
||||
})
|
||||
.pipe(Effect.forkScoped),
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
)
|
||||
|
||||
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
|
||||
|
||||
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
|
||||
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
|
||||
)
|
||||
return Service.of({ get, waitForDependencies })
|
||||
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function waitForDependencies() {
|
||||
await runPromise((svc) => svc.waitForDependencies())
|
||||
}
|
||||
|
||||
export async function get() {
|
||||
return runPromise((svc) => svc.get())
|
||||
}
|
||||
|
||||
async function loadFile(filepath: string): Promise<Info> {
|
||||
const text = await ConfigPaths.readFile(filepath)
|
||||
if (!text) return {}
|
||||
return load(text, filepath).catch((error) => {
|
||||
log.warn("failed to load tui config", { path: filepath, error })
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||
return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" })
|
||||
.then((expanded) => ConfigParse.jsonc(expanded, configFilepath))
|
||||
.then((data) => {
|
||||
if (!isRecord(data)) return {}
|
||||
|
||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||
return ConfigParse.schema(Info, normalize(data), configFilepath)
|
||||
})
|
||||
.then((data) => resolvePlugins(data, configFilepath))
|
||||
.catch((error) => {
|
||||
log.warn("invalid tui config", { path: configFilepath, error })
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util"
|
||||
import { Flock } from "@opencode-ai/shared/util/flock"
|
||||
import { rename, rm } from "fs/promises"
|
||||
import { createSignal, type Setter } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createStore, unwrap } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import path from "path"
|
||||
|
||||
@@ -11,12 +13,29 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
||||
const [ready, setReady] = createSignal(false)
|
||||
const [store, setStore] = createStore<Record<string, any>>()
|
||||
const filePath = path.join(Global.Path.state, "kv.json")
|
||||
const lock = `tui-kv:${filePath}`
|
||||
// Queue same-process writes so rapid updates persist in order.
|
||||
let write = Promise.resolve()
|
||||
|
||||
Filesystem.readJson(filePath)
|
||||
// Write to a temp file first so kv.json is only replaced once the JSON is complete, avoiding partial writes if shutdown interrupts persistence.
|
||||
function writeSnapshot(snapshot: Record<string, any>) {
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
|
||||
return Filesystem.writeJson(tempPath, snapshot)
|
||||
.then(() => rename(tempPath, filePath))
|
||||
.catch(async (error) => {
|
||||
await rm(tempPath, { force: true }).catch(() => undefined)
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
// Read under the same lock used for writes because kv.json is shared across processes.
|
||||
Flock.withLock(lock, () => Filesystem.readJson<Record<string, any>>(filePath))
|
||||
.then((x) => {
|
||||
setStore(x)
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch((error) => {
|
||||
console.error("Failed to read KV state", { filePath, error })
|
||||
})
|
||||
.finally(() => {
|
||||
setReady(true)
|
||||
})
|
||||
@@ -44,7 +63,12 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
||||
},
|
||||
set(key: string, value: any) {
|
||||
setStore(key, value)
|
||||
void Filesystem.writeJson(filePath, store)
|
||||
const snapshot = structuredClone(unwrap(store))
|
||||
write = write
|
||||
.then(() => Flock.withLock(lock, () => writeSnapshot(snapshot)))
|
||||
.catch((error) => {
|
||||
console.error("Failed to write KV state", { filePath, error })
|
||||
})
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -75,7 +75,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
batch(() => {
|
||||
let next = agents().findIndex((x) => x.name === agentStore.current) + direction
|
||||
const current = this.current()
|
||||
if (!current) return
|
||||
let next = agents().findIndex((x) => x.name === current.name) + direction
|
||||
if (next < 0) next = agents().length - 1
|
||||
if (next >= agents().length) next = 0
|
||||
const value = agents()[next]
|
||||
|
||||
@@ -10,18 +10,21 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||
name: "Project",
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
|
||||
const defaultPath = {
|
||||
home: "",
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: "",
|
||||
directory: sdk.directory ?? "",
|
||||
} satisfies Path
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
project: {
|
||||
id: undefined as string | undefined,
|
||||
},
|
||||
instance: {
|
||||
path: {
|
||||
home: "",
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: "",
|
||||
directory: sdk.directory ?? "",
|
||||
} satisfies Path,
|
||||
path: defaultPath,
|
||||
},
|
||||
workspace: {
|
||||
current: undefined as string | undefined,
|
||||
@@ -38,7 +41,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||
])
|
||||
|
||||
batch(() => {
|
||||
setStore("instance", "path", reconcile(path.data!))
|
||||
setStore("instance", "path", reconcile(path.data || defaultPath))
|
||||
setStore("project", "id", project.data?.id)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import type { PromptInfo } from "../component/prompt/history"
|
||||
|
||||
export type HomeRoute = {
|
||||
type: "home"
|
||||
initialPrompt?: PromptInfo
|
||||
prompt?: PromptInfo
|
||||
}
|
||||
|
||||
export type SessionRoute = {
|
||||
type: "session"
|
||||
sessionID: string
|
||||
initialPrompt?: PromptInfo
|
||||
prompt?: PromptInfo
|
||||
}
|
||||
|
||||
export type PluginRoute = {
|
||||
@@ -23,13 +23,14 @@ export type Route = HomeRoute | SessionRoute | PluginRoute
|
||||
|
||||
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
name: "Route",
|
||||
init: () => {
|
||||
init: (props: { initialRoute?: Route }) => {
|
||||
const [store, setStore] = createStore<Route>(
|
||||
process.env["OPENCODE_ROUTE"]
|
||||
? JSON.parse(process.env["OPENCODE_ROUTE"])
|
||||
: {
|
||||
type: "home",
|
||||
},
|
||||
props.initialRoute ??
|
||||
(process.env["OPENCODE_ROUTE"]
|
||||
? JSON.parse(process.env["OPENCODE_ROUTE"])
|
||||
: {
|
||||
type: "home",
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -37,7 +38,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
return store
|
||||
},
|
||||
navigate(route: Route) {
|
||||
setStore(route)
|
||||
setStore(reconcile(route))
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
|
||||
export type EventSource = {
|
||||
@@ -39,6 +40,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
let queue: GlobalEvent[] = []
|
||||
let timer: Timer | undefined
|
||||
let last = 0
|
||||
const retryDelay = 1000
|
||||
const maxRetryDelay = 30000
|
||||
|
||||
const flush = () => {
|
||||
if (queue.length === 0) return
|
||||
@@ -73,9 +76,20 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
const ctrl = new AbortController()
|
||||
sse = ctrl
|
||||
;(async () => {
|
||||
let attempt = 0
|
||||
while (true) {
|
||||
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||
const events = await sdk.global.event({ signal: ctrl.signal })
|
||||
|
||||
const events = await sdk.global.event({
|
||||
signal: ctrl.signal,
|
||||
sseMaxRetryAttempts: 0,
|
||||
})
|
||||
|
||||
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(() => {})
|
||||
}
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (ctrl.signal.aborted) break
|
||||
@@ -84,6 +98,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
|
||||
if (timer) clearTimeout(timer)
|
||||
if (queue.length > 0) flush()
|
||||
attempt += 1
|
||||
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||
|
||||
// Exponential backoff
|
||||
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)
|
||||
await new Promise((resolve) => setTimeout(resolve, backoff))
|
||||
}
|
||||
})().catch(() => {})
|
||||
}
|
||||
@@ -92,6 +112,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
if (props.events) {
|
||||
const unsub = await props.events.subscribe(handleEvent)
|
||||
onCleanup(unsub)
|
||||
|
||||
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(() => {})
|
||||
}
|
||||
} else {
|
||||
startSSE()
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { createSimpleContext } from "./helper"
|
||||
import type { Snapshot } from "@/snapshot"
|
||||
import { useExit } from "./exit"
|
||||
import { useArgs } from "./args"
|
||||
import { batch, createEffect, on } from "solid-js"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { Log } from "@/util"
|
||||
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
||||
|
||||
@@ -108,6 +108,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
let syncedWorkspace = project.workspace.current()
|
||||
|
||||
event.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed":
|
||||
@@ -350,9 +353,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const exit = useExit()
|
||||
const args = useArgs()
|
||||
|
||||
async function bootstrap() {
|
||||
console.log("bootstrapping")
|
||||
async function bootstrap(input: { fatal?: boolean } = {}) {
|
||||
const fatal = input.fatal ?? true
|
||||
const workspace = project.workspace.current()
|
||||
if (workspace !== syncedWorkspace) {
|
||||
fullSyncedSessions.clear()
|
||||
syncedWorkspace = workspace
|
||||
}
|
||||
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||
const sessionListPromise = sdk.client.session
|
||||
.list({ start: start })
|
||||
@@ -441,20 +448,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: e instanceof Error ? e.name : undefined,
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
})
|
||||
await exit(e)
|
||||
if (fatal) {
|
||||
await exit(e)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
createEffect(
|
||||
on(
|
||||
() => project.workspace.current(),
|
||||
() => {
|
||||
fullSyncedSessions.clear()
|
||||
void bootstrap()
|
||||
},
|
||||
),
|
||||
)
|
||||
onMount(() => {
|
||||
void bootstrap()
|
||||
})
|
||||
|
||||
const result = {
|
||||
data: store,
|
||||
|
||||
@@ -314,8 +314,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const lock = pick(kv.get("theme_mode_lock"))
|
||||
const mode = pick(kv.get("theme_mode", props.mode))
|
||||
draft.mode = lock ?? mode ?? props.mode
|
||||
const mode = lock ?? props.mode
|
||||
if (!lock && pick(kv.get("theme_mode")) !== undefined) {
|
||||
kv.set("theme_mode", undefined)
|
||||
}
|
||||
draft.mode = mode
|
||||
draft.lock = lock
|
||||
const active = config.theme ?? kv.get("theme", "opencode")
|
||||
draft.active = typeof active === "string" ? active : "opencode"
|
||||
@@ -373,7 +376,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
function apply(mode: "dark" | "light") {
|
||||
kv.set("theme_mode", mode)
|
||||
if (store.lock !== undefined) kv.set("theme_mode", mode)
|
||||
if (store.mode === mode) return
|
||||
setStore("mode", mode)
|
||||
renderer.clearPaletteCache()
|
||||
@@ -389,6 +392,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
function free() {
|
||||
setStore("lock", undefined)
|
||||
kv.set("theme_mode_lock", undefined)
|
||||
kv.set("theme_mode", undefined)
|
||||
const mode = renderer.themeMode
|
||||
if (mode) apply(mode)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Layer } from "effect"
|
||||
import { TuiConfig } from "./config/tui"
|
||||
import { Npm } from "@opencode-ai/shared/npm"
|
||||
import { Npm } from "@/npm"
|
||||
import { Observability } from "@/effect/observability"
|
||||
|
||||
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
|
||||
|
||||
@@ -91,7 +91,7 @@ function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]
|
||||
name: "session",
|
||||
params: {
|
||||
sessionID: route.data.sessionID,
|
||||
initialPrompt: route.data.initialPrompt,
|
||||
prompt: route.data.prompt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -918,113 +918,113 @@ async function installPluginBySpec(
|
||||
}
|
||||
}
|
||||
|
||||
export namespace TuiPluginRuntime {
|
||||
let dir = ""
|
||||
let loaded: Promise<void> | undefined
|
||||
let runtime: RuntimeState | undefined
|
||||
export const Slot = View
|
||||
let dir = ""
|
||||
let loaded: Promise<void> | undefined
|
||||
let runtime: RuntimeState | undefined
|
||||
export const Slot = View
|
||||
|
||||
export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
|
||||
const cwd = process.cwd()
|
||||
if (loaded) {
|
||||
if (dir !== cwd) {
|
||||
throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
|
||||
}
|
||||
return loaded
|
||||
export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
|
||||
const cwd = process.cwd()
|
||||
if (loaded) {
|
||||
if (dir !== cwd) {
|
||||
throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
|
||||
}
|
||||
|
||||
dir = cwd
|
||||
loaded = load(input)
|
||||
return loaded
|
||||
}
|
||||
|
||||
export function list() {
|
||||
if (!runtime) return []
|
||||
return listPluginStatus(runtime)
|
||||
}
|
||||
dir = cwd
|
||||
loaded = load(input)
|
||||
return loaded
|
||||
}
|
||||
|
||||
export async function activatePlugin(id: string) {
|
||||
return activatePluginById(runtime, id, true)
|
||||
}
|
||||
export function list() {
|
||||
if (!runtime) return []
|
||||
return listPluginStatus(runtime)
|
||||
}
|
||||
|
||||
export async function deactivatePlugin(id: string) {
|
||||
return deactivatePluginById(runtime, id, true)
|
||||
}
|
||||
export async function activatePlugin(id: string) {
|
||||
return activatePluginById(runtime, id, true)
|
||||
}
|
||||
|
||||
export async function addPlugin(spec: string) {
|
||||
return addPluginBySpec(runtime, spec)
|
||||
}
|
||||
export async function deactivatePlugin(id: string) {
|
||||
return deactivatePluginById(runtime, id, true)
|
||||
}
|
||||
|
||||
export async function installPlugin(spec: string, options?: { global?: boolean }) {
|
||||
return installPluginBySpec(runtime, spec, options?.global)
|
||||
}
|
||||
export async function addPlugin(spec: string) {
|
||||
return addPluginBySpec(runtime, spec)
|
||||
}
|
||||
|
||||
export async function dispose() {
|
||||
const task = loaded
|
||||
loaded = undefined
|
||||
dir = ""
|
||||
if (task) await task
|
||||
const state = runtime
|
||||
runtime = undefined
|
||||
if (!state) return
|
||||
const queue = [...state.plugins].reverse()
|
||||
for (const plugin of queue) {
|
||||
await deactivatePluginEntry(state, plugin, false)
|
||||
}
|
||||
}
|
||||
export async function installPlugin(spec: string, options?: { global?: boolean }) {
|
||||
return installPluginBySpec(runtime, spec, options?.global)
|
||||
}
|
||||
|
||||
async function load(input: { api: Api; config: TuiConfig.Info }) {
|
||||
const { api, config } = input
|
||||
const cwd = process.cwd()
|
||||
const slots = setupSlots(api)
|
||||
const next: RuntimeState = {
|
||||
directory: cwd,
|
||||
api,
|
||||
slots,
|
||||
plugins: [],
|
||||
plugins_by_id: new Map(),
|
||||
pending: new Map(),
|
||||
}
|
||||
runtime = next
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: cwd,
|
||||
fn: async () => {
|
||||
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
|
||||
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
|
||||
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
|
||||
}
|
||||
|
||||
for (const item of INTERNAL_TUI_PLUGINS) {
|
||||
log.info("loading internal tui plugin", { id: item.id })
|
||||
const entry = loadInternalPlugin(item)
|
||||
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
|
||||
addPluginEntry(next, {
|
||||
id: entry.id,
|
||||
load: entry,
|
||||
meta,
|
||||
themes: {},
|
||||
plugin: entry.module.tui,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
|
||||
await addExternalPluginEntries(next, ready)
|
||||
|
||||
applyInitialPluginEnabledState(next, config)
|
||||
for (const plugin of next.plugins) {
|
||||
if (!plugin.enabled) continue
|
||||
// Keep plugin execution sequential for deterministic side effects:
|
||||
// command registration order affects keybind/command precedence,
|
||||
// route registration is last-wins when ids collide,
|
||||
// and hook chains rely on stable plugin ordering.
|
||||
await activatePluginEntry(next, plugin, false)
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
fail("failed to load tui plugins", { directory: cwd, error })
|
||||
}
|
||||
export async function dispose() {
|
||||
const task = loaded
|
||||
loaded = undefined
|
||||
dir = ""
|
||||
if (task) await task
|
||||
const state = runtime
|
||||
runtime = undefined
|
||||
if (!state) return
|
||||
const queue = [...state.plugins].reverse()
|
||||
for (const plugin of queue) {
|
||||
await deactivatePluginEntry(state, plugin, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function load(input: { api: Api; config: TuiConfig.Info }) {
|
||||
const { api, config } = input
|
||||
const cwd = process.cwd()
|
||||
const slots = setupSlots(api)
|
||||
const next: RuntimeState = {
|
||||
directory: cwd,
|
||||
api,
|
||||
slots,
|
||||
plugins: [],
|
||||
plugins_by_id: new Map(),
|
||||
pending: new Map(),
|
||||
}
|
||||
runtime = next
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: cwd,
|
||||
fn: async () => {
|
||||
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
|
||||
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
|
||||
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
|
||||
}
|
||||
|
||||
for (const item of INTERNAL_TUI_PLUGINS) {
|
||||
log.info("loading internal tui plugin", { id: item.id })
|
||||
const entry = loadInternalPlugin(item)
|
||||
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
|
||||
addPluginEntry(next, {
|
||||
id: entry.id,
|
||||
load: entry,
|
||||
meta,
|
||||
themes: {},
|
||||
plugin: entry.module.tui,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
|
||||
await addExternalPluginEntries(next, ready)
|
||||
|
||||
applyInitialPluginEnabledState(next, config)
|
||||
for (const plugin of next.plugins) {
|
||||
if (!plugin.enabled) continue
|
||||
// Keep plugin execution sequential for deterministic side effects:
|
||||
// command registration order affects keybind/command precedence,
|
||||
// route registration is last-wins when ids collide,
|
||||
// and hook chains rely on stable plugin ordering.
|
||||
await activatePluginEntry(next, plugin, false)
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
fail("failed to load tui plugins", { directory: cwd, error })
|
||||
}
|
||||
}
|
||||
|
||||
export * as TuiPluginRuntime from "./runtime"
|
||||
|
||||
@@ -10,7 +10,6 @@ import { usePromptRef } from "../context/prompt"
|
||||
import { useLocal } from "../context/local"
|
||||
import { TuiPluginRuntime } from "../plugin"
|
||||
|
||||
// TODO: what is the best way to do this?
|
||||
let once = false
|
||||
const placeholder = {
|
||||
normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
|
||||
@@ -31,8 +30,8 @@ export function Home() {
|
||||
setRef(r)
|
||||
promptRef.set(r)
|
||||
if (once || !r) return
|
||||
if (route.initialPrompt) {
|
||||
r.set(route.initialPrompt)
|
||||
if (route.prompt) {
|
||||
r.set(route.prompt)
|
||||
once = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ import type { TextPart } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "@/util"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { useDialog, type DialogContext } from "../../ui/dialog"
|
||||
import type { PromptInfo } from "@tui/component/prompt/history"
|
||||
import { strip } from "@tui/component/prompt/part"
|
||||
|
||||
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
|
||||
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
@@ -19,9 +19,21 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
const options = createMemo((): DialogSelectOption<string>[] => {
|
||||
const options = createMemo((): DialogSelectOption<string | undefined>[] => {
|
||||
const messages = sync.data.message[props.sessionID] ?? []
|
||||
const result = [] as DialogSelectOption<string>[]
|
||||
const fullSession = {
|
||||
title: "Full session",
|
||||
value: undefined,
|
||||
onSelect: async (dialog: DialogContext) => {
|
||||
const forked = await sdk.client.session.fork({ sessionID: props.sessionID })
|
||||
route.navigate({
|
||||
sessionID: forked.data!.id,
|
||||
type: "session",
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
} satisfies DialogSelectOption<string | undefined>
|
||||
const result = [] as DialogSelectOption<string | undefined>[]
|
||||
for (const message of messages) {
|
||||
if (message.role !== "user") continue
|
||||
const part = (sync.data.part[message.id] ?? []).find(
|
||||
@@ -38,7 +50,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
||||
messageID: message.id,
|
||||
})
|
||||
const parts = sync.data.part[message.id] ?? []
|
||||
const initialPrompt = parts.reduce(
|
||||
const prompt = parts.reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
@@ -51,15 +63,14 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
||||
route.navigate({
|
||||
sessionID: forked.data!.id,
|
||||
type: "session",
|
||||
initialPrompt,
|
||||
prompt,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
})
|
||||
}
|
||||
result.reverse()
|
||||
return result
|
||||
return [fullSession, ...result.reverse()]
|
||||
})
|
||||
|
||||
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork from message" options={options()} />
|
||||
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork session" options={options()} />
|
||||
}
|
||||
|
||||
@@ -81,25 +81,23 @@ export function DialogMessage(props: {
|
||||
sessionID: props.sessionID,
|
||||
messageID: props.messageID,
|
||||
})
|
||||
const initialPrompt = (() => {
|
||||
const msg = message()
|
||||
if (!msg) return undefined
|
||||
const parts = sync.data.part[msg.id]
|
||||
return parts.reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(part)
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
)
|
||||
})()
|
||||
const msg = message()
|
||||
const prompt = msg
|
||||
? sync.data.part[msg.id].reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(part)
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
)
|
||||
: undefined
|
||||
route.navigate({
|
||||
sessionID: result.data!.id,
|
||||
type: "session",
|
||||
initialPrompt,
|
||||
prompt,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
|
||||
@@ -54,7 +54,6 @@ import { useSDK } from "@tui/context/sdk"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import type { DialogContext } from "@tui/ui/dialog"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { parsePatch } from "diff"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
@@ -69,6 +68,7 @@ import { Flag } from "@/flag/flag"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import parsers from "../../../../../../parsers-config.ts"
|
||||
import * as Clipboard from "../../util/clipboard"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { Toast, useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv.tsx"
|
||||
import * as Editor from "../../util/editor"
|
||||
@@ -88,6 +88,7 @@ import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { TuiPluginRuntime } from "../../plugin"
|
||||
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
|
||||
import { SessionRetry } from "@/session/retry"
|
||||
import { getRevertDiffFiles } from "../../util/revert-diff"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -180,28 +181,45 @@ export function Session() {
|
||||
const toast = useToast()
|
||||
const sdk = useSDK()
|
||||
|
||||
createEffect(async () => {
|
||||
await sdk.client.session
|
||||
.get({ sessionID: route.sessionID }, { throwOnError: true })
|
||||
.then((x) => {
|
||||
project.workspace.set(x.data?.workspaceID)
|
||||
})
|
||||
.then(() => sync.session.sync(route.sessionID))
|
||||
.then(() => {
|
||||
if (scroll) scroll.scrollBy(100_000)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
createEffect(() => {
|
||||
const sessionID = route.sessionID
|
||||
void (async () => {
|
||||
const previousWorkspace = project.workspace.current()
|
||||
const result = await sdk.client.session.get({ sessionID }, { throwOnError: true })
|
||||
if (!result.data) {
|
||||
toast.show({
|
||||
message: `Session not found: ${route.sessionID}`,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
return navigate({ type: "home" })
|
||||
navigate({ type: "home" })
|
||||
return
|
||||
}
|
||||
|
||||
if (result.data.workspaceID !== previousWorkspace) {
|
||||
project.workspace.set(result.data.workspaceID)
|
||||
|
||||
// Sync all the data for this workspace. Note that this
|
||||
// workspace may not exist anymore which is why this is not
|
||||
// fatal. If it doesn't we still want to show the session
|
||||
// (which will be non-interactive)
|
||||
try {
|
||||
await sync.bootstrap({ fatal: false })
|
||||
} catch {}
|
||||
}
|
||||
await sync.session.sync(sessionID)
|
||||
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
||||
})().catch((error) => {
|
||||
if (route.sessionID !== sessionID) return
|
||||
toast.show({
|
||||
message: errorMessage(error),
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
navigate({ type: "home" })
|
||||
})
|
||||
})
|
||||
|
||||
// Handle initial prompt from fork
|
||||
let seeded = false
|
||||
let lastSwitch: string | undefined = undefined
|
||||
event.on("message.part.updated", (evt) => {
|
||||
const part = evt.properties.part
|
||||
@@ -219,14 +237,15 @@ export function Session() {
|
||||
}
|
||||
})
|
||||
|
||||
let seeded = false
|
||||
let scroll: ScrollBoxRenderable
|
||||
let prompt: PromptRef | undefined
|
||||
const bind = (r: PromptRef | undefined) => {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
if (seeded || !route.initialPrompt || !r) return
|
||||
if (seeded || !route.prompt || !r) return
|
||||
seeded = true
|
||||
r.set(route.initialPrompt)
|
||||
r.set(route.prompt)
|
||||
}
|
||||
const keybind = useKeybind()
|
||||
const dialog = useDialog()
|
||||
@@ -445,7 +464,7 @@ export function Session() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Fork from message",
|
||||
title: "Fork session",
|
||||
value: "session.fork",
|
||||
keybind: "session_fork",
|
||||
category: "Session",
|
||||
@@ -456,6 +475,7 @@ export function Session() {
|
||||
dialog.replace(() => (
|
||||
<DialogForkFromTimeline
|
||||
onMove={(messageID) => {
|
||||
if (!messageID) return
|
||||
const child = scroll.getChildren().find((child) => {
|
||||
return child.id === messageID
|
||||
})
|
||||
@@ -991,31 +1011,7 @@ export function Session() {
|
||||
const revertInfo = createMemo(() => session()?.revert)
|
||||
const revertMessageID = createMemo(() => revertInfo()?.messageID)
|
||||
|
||||
const revertDiffFiles = createMemo(() => {
|
||||
const diffText = revertInfo()?.diff ?? ""
|
||||
if (!diffText) return []
|
||||
|
||||
try {
|
||||
const patches = parsePatch(diffText)
|
||||
return patches.map((patch) => {
|
||||
const filename = patch.newFileName || patch.oldFileName || "unknown"
|
||||
const cleanFilename = filename.replace(/^[ab]\//, "")
|
||||
return {
|
||||
filename: cleanFilename,
|
||||
additions: patch.hunks.reduce(
|
||||
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length,
|
||||
0,
|
||||
),
|
||||
deletions: patch.hunks.reduce(
|
||||
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length,
|
||||
0,
|
||||
),
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? ""))
|
||||
|
||||
const revertRevertedMessages = createMemo(() => {
|
||||
const messageID = revertMessageID()
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../component/border"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
|
||||
import { useProject } from "../../context/project"
|
||||
import path from "path"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import { Keybind } from "@/util"
|
||||
@@ -131,6 +132,7 @@ function TextBody(props: { title: string; description?: string; icon?: string })
|
||||
|
||||
export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
const sdk = useSDK()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const [store, setStore] = createStore({
|
||||
stage: "permission" as PermissionStage,
|
||||
@@ -187,6 +189,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
void sdk.client.permission.reply({
|
||||
reply: "always",
|
||||
requestID: props.request.id,
|
||||
workspace: project.workspace.current(),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
@@ -198,6 +201,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
reply: "reject",
|
||||
requestID: props.request.id,
|
||||
message: message || undefined,
|
||||
workspace: project.workspace.current(),
|
||||
})
|
||||
}}
|
||||
onCancel={() => {
|
||||
@@ -450,12 +454,14 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
void sdk.client.permission.reply({
|
||||
reply: "reject",
|
||||
requestID: props.request.id,
|
||||
workspace: project.workspace.current(),
|
||||
})
|
||||
return
|
||||
}
|
||||
void sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: props.request.id,
|
||||
workspace: project.workspace.current(),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
import { InstallationVersion } from "@/installation/version"
|
||||
import { InstallationChannel, InstallationVersion } from "@/installation/version"
|
||||
import { TuiPluginRuntime } from "../../plugin"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const session = createMemo(() => sync.session.get(props.sessionID))
|
||||
const workspaceStatus = () => {
|
||||
const workspaceID = session()?.workspaceID
|
||||
if (!workspaceID) return "error"
|
||||
return project.workspace.status(workspaceID) ?? "error"
|
||||
}
|
||||
const workspaceLabel = () => {
|
||||
const workspaceID = session()?.workspaceID
|
||||
if (!workspaceID) return "unknown"
|
||||
const info = project.workspace.get(workspaceID)
|
||||
if (!info) return "unknown"
|
||||
return `${info.type}: ${info.name}`
|
||||
}
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
|
||||
return (
|
||||
@@ -48,6 +62,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
<text fg={theme.text}>
|
||||
<b>{session()!.title}</b>
|
||||
</text>
|
||||
<Show when={InstallationChannel !== "latest"}>
|
||||
<text fg={theme.textMuted}>{props.sessionID}</text>
|
||||
</Show>
|
||||
<Show when={session()!.workspaceID}>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
|
||||
{workspaceLabel()}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={session()!.share?.url}>
|
||||
<text fg={theme.textMuted}>{session()!.share!.url}</text>
|
||||
</Show>
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import { TuiConfig } from "./config/tui"
|
||||
import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process"
|
||||
import { validateSession } from "./validate-session"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_WORKER_PATH: string
|
||||
@@ -129,11 +131,13 @@ export const TuiThreadCommand = cmd({
|
||||
return
|
||||
}
|
||||
const cwd = Filesystem.resolve(process.cwd())
|
||||
const env = sanitizedProcessEnv({
|
||||
[OPENCODE_PROCESS_ROLE]: "worker",
|
||||
[OPENCODE_RUN_ID]: ensureRunID(),
|
||||
})
|
||||
|
||||
const worker = new Worker(file, {
|
||||
env: Object.fromEntries(
|
||||
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
||||
),
|
||||
env,
|
||||
})
|
||||
worker.onerror = (e) => {
|
||||
Log.Default.error("thread error", {
|
||||
@@ -199,6 +203,19 @@ export const TuiThreadCommand = cmd({
|
||||
events: createEventSource(client),
|
||||
}
|
||||
|
||||
try {
|
||||
await validateSession({
|
||||
url: transport.url,
|
||||
sessionID: args.session,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
})
|
||||
} catch (error) {
|
||||
UI.error(errorMessage(error))
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
|
||||
}, 1000).unref?.()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { parsePatch } from "diff"
|
||||
|
||||
export function getRevertDiffFiles(diffText: string) {
|
||||
if (!diffText) return []
|
||||
|
||||
try {
|
||||
return parsePatch(diffText).map((patch) => {
|
||||
const filename = [patch.newFileName, patch.oldFileName].find((item) => item && item !== "/dev/null") ?? "unknown"
|
||||
return {
|
||||
filename: filename.replace(/^[ab]\//, ""),
|
||||
additions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, 0),
|
||||
deletions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, 0),
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,41 @@
|
||||
import { createSignal, type Accessor } from "solid-js"
|
||||
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
|
||||
import { debounce, type Scheduled } from "@solid-primitives/scheduled"
|
||||
|
||||
export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, Scheduled<[value: T]>] {
|
||||
const [get, set] = createSignal(value)
|
||||
return [get, debounce((v: T) => set(() => v), ms)]
|
||||
}
|
||||
|
||||
export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
|
||||
const [alpha, setAlpha] = createSignal(show() ? 1 : 0)
|
||||
let revealed = show()
|
||||
|
||||
createEffect(
|
||||
on([show, enabled], ([visible, animate]) => {
|
||||
if (!visible) {
|
||||
setAlpha(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (!animate || revealed) {
|
||||
revealed = true
|
||||
setAlpha(1)
|
||||
return
|
||||
}
|
||||
|
||||
const start = performance.now()
|
||||
revealed = true
|
||||
setAlpha(0)
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const progress = Math.min((performance.now() - start) / 160, 1)
|
||||
setAlpha(progress * progress * (3 - 2 * progress))
|
||||
if (progress >= 1) clearInterval(timer)
|
||||
}, 16)
|
||||
|
||||
onCleanup(() => clearInterval(timer))
|
||||
}),
|
||||
)
|
||||
|
||||
return alpha
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ function parse(color: string): RGBA | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function mode(bg: RGBA | null): "dark" | "light" {
|
||||
if (!bg) return "dark"
|
||||
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
|
||||
function mode(background: RGBA | null): "dark" | "light" {
|
||||
if (!background) return "dark"
|
||||
const luminance = (0.299 * background.r + 0.587 * background.g + 0.114 * background.b) / 255
|
||||
return luminance > 0.5 ? "light" : "dark"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { SessionID } from "@/session/schema"
|
||||
|
||||
export async function validateSession(input: {
|
||||
url: string
|
||||
sessionID?: string
|
||||
directory?: string
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
}) {
|
||||
if (!input.sessionID) return
|
||||
|
||||
const result = SessionID.zod.safeParse(input.sessionID)
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`)
|
||||
}
|
||||
|
||||
await createOpencodeClient({
|
||||
baseUrl: input.url,
|
||||
directory: input.directory,
|
||||
fetch: input.fetch,
|
||||
headers: input.headers,
|
||||
}).session.get({ sessionID: result.data }, { throwOnError: true })
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import { Flag } from "@/flag/flag"
|
||||
import { writeHeapSnapshot } from "node:v8"
|
||||
import { Heap } from "@/cli/heap"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { ensureProcessMetadata } from "@/util/opencode-process"
|
||||
|
||||
ensureProcessMetadata("worker")
|
||||
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Observability } from "@/effect/observability"
|
||||
import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
|
||||
|
||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||
|
||||
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||
const getRuntime = () =>
|
||||
(rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer) as Layer.Layer<I, E>, { memoMap }))
|
||||
|
||||
return {
|
||||
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
|
||||
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||
getRuntime().runPromiseExit(service.use(fn), options),
|
||||
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||
getRuntime().runPromise(service.use(fn), options),
|
||||
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
|
||||
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,10 @@ export function FormatError(input: unknown) {
|
||||
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
|
||||
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
|
||||
const data = (input as ErrorLike).data
|
||||
const suggestions = data?.suggestions as string[] | undefined
|
||||
const suggestions: string[] = Array.isArray(data?.suggestions) ? data.suggestions : []
|
||||
return [
|
||||
`Model not found: ${data?.providerID}/${data?.modelID}`,
|
||||
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
||||
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
||||
`Try: \`opencode models\` to list available models`,
|
||||
`Or check your config (opencode.json) provider/model names`,
|
||||
].join("\n")
|
||||
@@ -64,10 +64,10 @@ export function FormatError(input: unknown) {
|
||||
const data = (input as ErrorLike).data
|
||||
const path = data?.path
|
||||
const message = data?.message
|
||||
const issues = data?.issues as Array<{ message: string; path: string[] }> | undefined
|
||||
const issues: Array<{ message: string; path: string[] }> = Array.isArray(data?.issues) ? data.issues : []
|
||||
return [
|
||||
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
|
||||
...(issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
|
||||
...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -8,52 +8,52 @@ const log = Log.create({ service: "heap" })
|
||||
const MINUTE = 60_000
|
||||
const LIMIT = 2 * 1024 * 1024 * 1024
|
||||
|
||||
export namespace Heap {
|
||||
let timer: Timer | undefined
|
||||
let lock = false
|
||||
let armed = true
|
||||
let timer: Timer | undefined
|
||||
let lock = false
|
||||
let armed = true
|
||||
|
||||
export function start() {
|
||||
if (!Flag.OPENCODE_AUTO_HEAP_SNAPSHOT) return
|
||||
if (timer) return
|
||||
export function start() {
|
||||
if (!Flag.OPENCODE_AUTO_HEAP_SNAPSHOT) return
|
||||
if (timer) return
|
||||
|
||||
const run = async () => {
|
||||
if (lock) return
|
||||
const run = async () => {
|
||||
if (lock) return
|
||||
|
||||
const stat = process.memoryUsage()
|
||||
if (stat.rss <= LIMIT) {
|
||||
armed = true
|
||||
return
|
||||
}
|
||||
if (!armed) return
|
||||
const stat = process.memoryUsage()
|
||||
if (stat.rss <= LIMIT) {
|
||||
armed = true
|
||||
return
|
||||
}
|
||||
if (!armed) return
|
||||
|
||||
lock = true
|
||||
armed = false
|
||||
const file = path.join(
|
||||
Global.Path.log,
|
||||
`heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`,
|
||||
)
|
||||
log.warn("heap usage exceeded limit", {
|
||||
rss: stat.rss,
|
||||
heap: stat.heapUsed,
|
||||
file,
|
||||
lock = true
|
||||
armed = false
|
||||
const file = path.join(
|
||||
Global.Path.log,
|
||||
`heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`,
|
||||
)
|
||||
log.warn("heap usage exceeded limit", {
|
||||
rss: stat.rss,
|
||||
heap: stat.heapUsed,
|
||||
file,
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
.then(() => writeHeapSnapshot(file))
|
||||
.catch((err) => {
|
||||
log.error("failed to write heap snapshot", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
file,
|
||||
})
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
.then(() => writeHeapSnapshot(file))
|
||||
.catch((err) => {
|
||||
log.error("failed to write heap snapshot", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
file,
|
||||
})
|
||||
})
|
||||
|
||||
lock = false
|
||||
}
|
||||
|
||||
timer = setInterval(() => {
|
||||
void run()
|
||||
}, MINUTE)
|
||||
timer.unref?.()
|
||||
lock = false
|
||||
}
|
||||
|
||||
timer = setInterval(() => {
|
||||
void run()
|
||||
}, MINUTE)
|
||||
timer.unref?.()
|
||||
}
|
||||
|
||||
export * as Heap from "./heap"
|
||||
|
||||
@@ -3,4 +3,9 @@ export const logo = {
|
||||
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
|
||||
}
|
||||
|
||||
export const marks = "_^~"
|
||||
export const go = {
|
||||
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
|
||||
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
|
||||
}
|
||||
|
||||
export const marks = "_^~,"
|
||||
|
||||
+113
-113
@@ -3,131 +3,131 @@ import { EOL } from "os"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { logo as glyphs } from "./logo"
|
||||
|
||||
export namespace UI {
|
||||
const wordmark = [
|
||||
`⠀ ▄ `,
|
||||
`█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█`,
|
||||
`█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀`,
|
||||
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
|
||||
]
|
||||
const wordmark = [
|
||||
`⠀ ▄ `,
|
||||
`█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█`,
|
||||
`█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀`,
|
||||
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
|
||||
]
|
||||
|
||||
export const CancelledError = NamedError.create("UICancelledError", z.void())
|
||||
export const CancelledError = NamedError.create("UICancelledError", z.void())
|
||||
|
||||
export const Style = {
|
||||
TEXT_HIGHLIGHT: "\x1b[96m",
|
||||
TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m",
|
||||
TEXT_DIM: "\x1b[90m",
|
||||
TEXT_DIM_BOLD: "\x1b[90m\x1b[1m",
|
||||
TEXT_NORMAL: "\x1b[0m",
|
||||
TEXT_NORMAL_BOLD: "\x1b[1m",
|
||||
TEXT_WARNING: "\x1b[93m",
|
||||
TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m",
|
||||
TEXT_DANGER: "\x1b[91m",
|
||||
TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m",
|
||||
TEXT_SUCCESS: "\x1b[92m",
|
||||
TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m",
|
||||
TEXT_INFO: "\x1b[94m",
|
||||
TEXT_INFO_BOLD: "\x1b[94m\x1b[1m",
|
||||
}
|
||||
export const Style = {
|
||||
TEXT_HIGHLIGHT: "\x1b[96m",
|
||||
TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m",
|
||||
TEXT_DIM: "\x1b[90m",
|
||||
TEXT_DIM_BOLD: "\x1b[90m\x1b[1m",
|
||||
TEXT_NORMAL: "\x1b[0m",
|
||||
TEXT_NORMAL_BOLD: "\x1b[1m",
|
||||
TEXT_WARNING: "\x1b[93m",
|
||||
TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m",
|
||||
TEXT_DANGER: "\x1b[91m",
|
||||
TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m",
|
||||
TEXT_SUCCESS: "\x1b[92m",
|
||||
TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m",
|
||||
TEXT_INFO: "\x1b[94m",
|
||||
TEXT_INFO_BOLD: "\x1b[94m\x1b[1m",
|
||||
}
|
||||
|
||||
export function println(...message: string[]) {
|
||||
print(...message)
|
||||
process.stderr.write(EOL)
|
||||
}
|
||||
export function println(...message: string[]) {
|
||||
print(...message)
|
||||
process.stderr.write(EOL)
|
||||
}
|
||||
|
||||
export function print(...message: string[]) {
|
||||
blank = false
|
||||
process.stderr.write(message.join(" "))
|
||||
}
|
||||
export function print(...message: string[]) {
|
||||
blank = false
|
||||
process.stderr.write(message.join(" "))
|
||||
}
|
||||
|
||||
let blank = false
|
||||
export function empty() {
|
||||
if (blank) return
|
||||
println("" + Style.TEXT_NORMAL)
|
||||
blank = true
|
||||
}
|
||||
let blank = false
|
||||
export function empty() {
|
||||
if (blank) return
|
||||
println("" + Style.TEXT_NORMAL)
|
||||
blank = true
|
||||
}
|
||||
|
||||
export function logo(pad?: string) {
|
||||
if (!process.stdout.isTTY && !process.stderr.isTTY) {
|
||||
const result = []
|
||||
for (const row of wordmark) {
|
||||
if (pad) result.push(pad)
|
||||
result.push(row)
|
||||
result.push(EOL)
|
||||
}
|
||||
return result.join("").trimEnd()
|
||||
}
|
||||
|
||||
const result: string[] = []
|
||||
const reset = "\x1b[0m"
|
||||
const left = {
|
||||
fg: "\x1b[90m",
|
||||
shadow: "\x1b[38;5;235m",
|
||||
bg: "\x1b[48;5;235m",
|
||||
}
|
||||
const right = {
|
||||
fg: reset,
|
||||
shadow: "\x1b[38;5;238m",
|
||||
bg: "\x1b[48;5;238m",
|
||||
}
|
||||
const gap = " "
|
||||
const draw = (line: string, fg: string, shadow: string, bg: string) => {
|
||||
const parts: string[] = []
|
||||
for (const char of line) {
|
||||
if (char === "_") {
|
||||
parts.push(bg, " ", reset)
|
||||
continue
|
||||
}
|
||||
if (char === "^") {
|
||||
parts.push(fg, bg, "▀", reset)
|
||||
continue
|
||||
}
|
||||
if (char === "~") {
|
||||
parts.push(shadow, "▀", reset)
|
||||
continue
|
||||
}
|
||||
if (char === " ") {
|
||||
parts.push(" ")
|
||||
continue
|
||||
}
|
||||
parts.push(fg, char, reset)
|
||||
}
|
||||
return parts.join("")
|
||||
}
|
||||
glyphs.left.forEach((row, index) => {
|
||||
export function logo(pad?: string) {
|
||||
if (!process.stdout.isTTY && !process.stderr.isTTY) {
|
||||
const result = []
|
||||
for (const row of wordmark) {
|
||||
if (pad) result.push(pad)
|
||||
result.push(draw(row, left.fg, left.shadow, left.bg))
|
||||
result.push(gap)
|
||||
const other = glyphs.right[index] ?? ""
|
||||
result.push(draw(other, right.fg, right.shadow, right.bg))
|
||||
result.push(row)
|
||||
result.push(EOL)
|
||||
})
|
||||
}
|
||||
return result.join("").trimEnd()
|
||||
}
|
||||
|
||||
export async function input(prompt: string): Promise<string> {
|
||||
const readline = require("readline")
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer: string) => {
|
||||
rl.close()
|
||||
resolve(answer.trim())
|
||||
})
|
||||
})
|
||||
const result: string[] = []
|
||||
const reset = "\x1b[0m"
|
||||
const left = {
|
||||
fg: "\x1b[90m",
|
||||
shadow: "\x1b[38;5;235m",
|
||||
bg: "\x1b[48;5;235m",
|
||||
}
|
||||
|
||||
export function error(message: string) {
|
||||
if (message.startsWith("Error: ")) {
|
||||
message = message.slice("Error: ".length)
|
||||
const right = {
|
||||
fg: reset,
|
||||
shadow: "\x1b[38;5;238m",
|
||||
bg: "\x1b[48;5;238m",
|
||||
}
|
||||
const gap = " "
|
||||
const draw = (line: string, fg: string, shadow: string, bg: string) => {
|
||||
const parts: string[] = []
|
||||
for (const char of line) {
|
||||
if (char === "_") {
|
||||
parts.push(bg, " ", reset)
|
||||
continue
|
||||
}
|
||||
if (char === "^") {
|
||||
parts.push(fg, bg, "▀", reset)
|
||||
continue
|
||||
}
|
||||
if (char === "~") {
|
||||
parts.push(shadow, "▀", reset)
|
||||
continue
|
||||
}
|
||||
if (char === " ") {
|
||||
parts.push(" ")
|
||||
continue
|
||||
}
|
||||
parts.push(fg, char, reset)
|
||||
}
|
||||
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
|
||||
}
|
||||
|
||||
export function markdown(text: string): string {
|
||||
return text
|
||||
return parts.join("")
|
||||
}
|
||||
glyphs.left.forEach((row, index) => {
|
||||
if (pad) result.push(pad)
|
||||
result.push(draw(row, left.fg, left.shadow, left.bg))
|
||||
result.push(gap)
|
||||
const other = glyphs.right[index] ?? ""
|
||||
result.push(draw(other, right.fg, right.shadow, right.bg))
|
||||
result.push(EOL)
|
||||
})
|
||||
return result.join("").trimEnd()
|
||||
}
|
||||
|
||||
export async function input(prompt: string): Promise<string> {
|
||||
const readline = require("readline")
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer: string) => {
|
||||
rl.close()
|
||||
resolve(answer.trim())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function error(message: string) {
|
||||
if (message.startsWith("Error: ")) {
|
||||
message = message.slice("Error: ".length)
|
||||
}
|
||||
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
|
||||
}
|
||||
|
||||
export function markdown(text: string): string {
|
||||
return text
|
||||
}
|
||||
|
||||
export * as UI from "./ui"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InstallationVersion } from "@/installation/version"
|
||||
|
||||
export async function upgrade() {
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
||||
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
|
||||
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
|
||||
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
|
||||
if (!latest) return
|
||||
@@ -17,7 +18,6 @@ export async function upgrade() {
|
||||
}
|
||||
|
||||
if (InstallationVersion === latest) return
|
||||
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
|
||||
|
||||
const kind = Installation.getReleaseType(InstallationVersion, latest)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user